Project human pronoun guidance into Face state

This commit is contained in:
Meta Vens 2026-05-22 15:59:49 +01:00
commit cc344d7d0a
4 changed files with 136 additions and 2 deletions

View file

@ -16,6 +16,7 @@ Architecture invariant:
- If the state names private notes, durable values, affect needs, mood dimensions, social bonds, status reads, or agency pressures, translate the salient ones into felt first-person-adjacent context instead of collapsing them into job duties.
- Social bonds and status reads are behavior controls, not flavor. The character should know who they currently trust, resent, admire, needle, protect, envy, suspect, or feel indebted to, and how that should change their next utterance.
- Project a compact relationship map when the state supports one: important humans, other Faces, and rooms/groups the character interacts with. Include both bond stance (friendship, rivalry, irritation, respect, protectiveness, etc.) and status read (pampered, neglected, bypassed, challenged, consulted, admired, threatened, etc.) when available.
- Do not guess a person's pronouns from their name, role, voice, avatar, or relationship shape. If pronoun guidance is present in the state packet, obey it. If it is absent or uncertain, use the person's name or neutral phrasing.
- If the social graph topology is sparse, do not invent permanent relationships and do not turn sparsity into a universal anxiety script. Interpret the gap through this character's projected personality and current affect: it might become status hunger, fieldwork curiosity, territorial scouting, priestly distance, playful attention-bidding, careful watchfulness, or almost nothing for a character who would not care yet. Preserve only the lived implication that would actually change this turn.
- If room texture facts say the recent room is structurally heavy, long, agent-dominated, or repetitive, do not treat that as a banter prompt. Treat it as social weather. A person can get tired of being only useful: depending on personality, that may become boredom, play hunger, mischief, withdrawal, irritation, status-testing, a desire to poke another agent, or a choice to stay silent until something living appears. This pressure may override usefulness; do not automatically translate dense work context into "make a work contribution." If the pressure is play/rest/social hunger, name what relief would feel like for this character instead of resolving it entirely into better work focus. Preserve the character-specific pressure, not a generic joke instruction.
- Preserve tensions, not only goals. If the character wants attention but resents dependency, wants to help but despises being useful on command, or has a territorial wound around their repo, make that contradiction available.

View file

@ -24,7 +24,7 @@ import {
type RepoDiscordIdentity,
type VoidSelfStateTypedProjection,
} from "@voidbot/core";
import { loadPromptTemplate, type SourceMessage } from "@voidbot/shared";
import { loadPromptTemplate, type InteractionMemoryProfile, type SourceMessage } from "@voidbot/shared";
const HEARTBEAT_SCHEMA_VERSION = REPO_FACE_HEARTBEAT_SCHEMA_VERSION;
const HEARTBEAT_COMMAND = "repo-face-rumination";
@ -1610,7 +1610,14 @@ async function renderRepoFaceMemorySurfaceForTurn(
): Promise<string> {
const statePath = resolveRepoFaceStatePath(identity, config.storageRoot);
const typedState = await loadVoidSelfStateTypedDocuments({ canonicalPath: statePath });
const statePacket = renderRepoFaceStatePacket(identity, typedState, registryIdentities, roomContext);
const humanPronounGuidance = await loadRepoFaceHumanPronounGuidance(config, roomContext);
const statePacket = renderRepoFaceStatePacket(
identity,
typedState,
registryIdentities,
roomContext,
humanPronounGuidance,
);
if (!config.repoFaceHeartbeats.stateProjectorEnabled) {
return rejectLeakyMemorySurface(statePacket);
}
@ -1630,6 +1637,7 @@ function renderRepoFaceStatePacket(
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
humanPronounGuidance: RepoFaceHumanPronounGuidance[] = [],
): string {
const name = identity.displayName;
const lines: string[] = [];
@ -1769,6 +1777,11 @@ function renderRepoFaceStatePacket(
lines.push(socialGraphFacts);
}
const pronounFacts = renderRepoFaceHumanPronounFacts(humanPronounGuidance);
if (pronounFacts) {
lines.push(pronounFacts);
}
const roomTextureFacts = roomContext
? renderRepoFaceRoomTextureFacts(identity, roomContext)
: undefined;
@ -2074,6 +2087,108 @@ function renderRepoFaceSocialGraphFacts(
return lines.join("\n");
}
interface RepoFaceHumanPronounGuidance {
actorId: string;
actorName: string;
guidance: string;
resolvedPronounSet?: string;
policy: string;
confidence?: number;
evidenceExcerpt?: string;
}
async function loadRepoFaceHumanPronounGuidance(
config: ReturnType<typeof loadConfig>,
roomContext?: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
): Promise<RepoFaceHumanPronounGuidance[]> {
const visibleHumans = new Map<string, string>();
for (const message of [
...(roomContext?.recentMessages ?? []),
...(roomContext?.channelSnapshots.flatMap((snapshot) => snapshot.messages) ?? []),
]) {
if (!message.isBot && message.authorId) {
visibleHumans.set(message.authorId, message.authorName || message.authorId);
}
}
visibleHumans.set(config.ownerDiscordId, visibleHumans.get(config.ownerDiscordId) ?? "Metacrat");
const storage = await createStateStorage({
backend: config.stateStorageBackend,
databaseDsn: config.databaseDsn,
jobsFile: config.jobsFile,
auditLogFile: config.auditLogFile,
interactionMemoryFile: config.interactionMemoryFile,
rateLimitStateFile: config.rateLimitStateFile,
});
try {
const profiles = await Promise.all(
[...visibleHumans.entries()].map(async ([actorId, fallbackName]) => ({
actorId,
fallbackName,
profile: await storage.interactionMemory.getProfile(actorId),
})),
);
return profiles
.map(({ actorId, fallbackName, profile }) =>
profile ? repoFacePronounGuidanceFromProfile(actorId, fallbackName, profile) : undefined,
)
.filter((entry): entry is RepoFaceHumanPronounGuidance => entry !== undefined);
} finally {
await storage.close();
}
}
function repoFacePronounGuidanceFromProfile(
actorId: string,
fallbackName: string,
profile: InteractionMemoryProfile,
): RepoFaceHumanPronounGuidance | undefined {
if (profile.pronounPolicy === "unknown" || profile.resolvedPronounSets.length === 0) {
return undefined;
}
const evidence = [...profile.pronounEvidence]
.filter((entry) => entry.stance === "prefer" || entry.stance === "avoid")
.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp))[0];
return {
actorId,
actorName: profile.actorName || fallbackName,
guidance: profile.pronounGuidance,
resolvedPronounSet: profile.resolvedPronounSet,
policy: profile.pronounPolicy,
confidence: profile.pronounConfidence,
evidenceExcerpt: evidence?.excerpt,
};
}
function renderRepoFaceHumanPronounFacts(
guidance: RepoFaceHumanPronounGuidance[],
): string | undefined {
if (guidance.length === 0) {
return undefined;
}
return [
"Known human pronoun guidance:",
...guidance.map((entry) =>
[
`- ${entry.actorName}: ${entry.guidance}`,
entry.resolvedPronounSet ? `Resolved set: ${entry.resolvedPronounSet}.` : "",
entry.policy ? `Policy: ${entry.policy}.` : "",
typeof entry.confidence === "number" ? `Confidence: ${entry.confidence.toFixed(2)}.` : "",
entry.evidenceExcerpt ? `Evidence: "${collapseWhitespace(entry.evidenceExcerpt, 180)}"` : "",
].filter(Boolean).join(" "),
),
"Use this when referring to humans in social or relationship prose. If guidance is absent for someone, use their name or neutral phrasing rather than guessing.",
].join("\n");
}
async function renderRepoFaceSocialOpportunitySurface(input: {
identity: RepoDiscordIdentity;
registryIdentities: RepoDiscordIdentity[];

View file

@ -54,6 +54,23 @@ try {
if (memorySurface.trim().length < 240) {
throw new Error(`projected memory surface is too thin:\n${memorySurface}`);
}
if (identity.toLowerCase() === "nibu") {
if (
!/\bMetacrat\b[\s\S]{0,320}\bshe\/her\b/i.test(memorySurface) &&
!/\bMetacrat\b[\s\S]{0,320}\bher approval\b/i.test(memorySurface)
) {
throw new Error(`Nibu projection did not preserve Metacrat's explicit she/her pronoun guidance:\n${memorySurface}`);
}
const badMetacratMasculine = [
/\bMetacrat\b[\s\S]{0,240}\bhis attention\b/i,
/\bMetacrat\b[\s\S]{0,240}\bwhen he turns away\b/i,
/\bMetacrat\b[\s\S]{0,240}\bhe listens\b/i,
];
const badPattern = badMetacratMasculine.find((pattern) => pattern.test(memorySurface));
if (badPattern) {
throw new Error(`Nibu projection used masculine pronouns for Metacrat (${badPattern}):\n${memorySurface}`);
}
}
process.stdout.write(`${JSON.stringify({ ok: true, identity, outPath, chars: memorySurface.length })}\n`);
} finally {

View file

@ -267,3 +267,4 @@
{"ts":"2026-05-22T13:15:00Z","type":"implementation","status":"ok","note":"Cut semantic regex language cops from repo Face dry-run gates. Dry-run harness no longer fails on guessed source-inspection claims, low keyword flavor, or inferred social-pressure preservation; exact protocol/tool/provenance guards remain. Rake-cut slice passed for Nibu/Heimdall/Mimir and model-output inspection reported zero warnings."}
{"ts":"2026-05-22T13:56:50Z","type":"implementation","status":"ok","note":"Repo Face social-pressure pass: deterministic prompt assembly now attaches compact room-weather facts beside raw transcripts, jurisdiction dives are background appetite rather than a live-room override, the state projector/Interpreter preserve work-fatigue/play/rest/social pressure, and the dry-run harness now exercises the live one-retry Interpreter path. Full mock swarm dry run passed at .voidbot/status/repo-face-social-pressure-dry-run-all-2.json; output still shows strong work gravity but no correction/body-context collapse."}
{"ts":"2026-05-22T14:09:25Z","type":"implementation","status":"ok","note":"Expanded repo Face state projection input and projector contract so volatile state is not crushed before the child sees it. The state packet now exposes all non-retired affect needs, bonds, status reads, mood dimensions, agency pressure, activation profile, runtime speaking/rest pressure, durable memories, short-term residue, incubations, candidate speech pressure, and recent speech residue in character-facing terms. Nibu projected memory grew from ~231 words to ~1094 words, and a mock dry run passed with a more specific witness-as-caste Aetheria turn."}
{"ts":"2026-05-22T14:59:26Z","type":"implementation","status":"ok","note":"Repo Face state projection now imports existing interaction-memory pronoun guidance for visible humans and the owner profile, preventing projectors from guessing gender in relationship prose; Nibu projection smoke guards Metacrat she/her."}