From 9ce17ac6509082b3ac1f3d8c5588c04cf00cfdbb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 2 May 2026 21:33:54 +0100 Subject: [PATCH] Persist explicit pronoun self-statements --- apps/bot/src/discord-bot-handlers.ts | 13 +- packages/core/sql/bootstrap.sql | 4 + packages/core/src/index.ts | 1 + .../core/src/interaction-memory-analysis.ts | 29 ++++ .../core/src/interaction-memory-shared.ts | 4 + packages/core/src/interaction-memory.ts | 33 ++-- packages/core/src/pronoun-evidence.ts | 153 ++++++++++++++++++ .../src/state-storage-postgres-bootstrap.ts | 19 ++- ...ate-storage-postgres-interaction-memory.ts | 64 +++++--- state/evidence.jsonl | 1 + 10 files changed, 279 insertions(+), 42 deletions(-) create mode 100644 packages/core/src/pronoun-evidence.ts diff --git a/apps/bot/src/discord-bot-handlers.ts b/apps/bot/src/discord-bot-handlers.ts index eda6c53..1bf297d 100644 --- a/apps/bot/src/discord-bot-handlers.ts +++ b/apps/bot/src/discord-bot-handlers.ts @@ -11,6 +11,7 @@ import { import type { AppConfig } from "@voidbot/config"; import { ContextBuilder, + extractDirectPromptPronounEvidence, type OllamaSituationalSocialReadInferer, PermissionEngine, type AuditLog, @@ -229,12 +230,20 @@ export async function handlePrompt(options: PromptHandlerOptions): Promise recentMessages, interactionMemory, }); - if (situationalSocialRead?.pronounEvidence.length) { + const directPromptPronounEvidence = extractDirectPromptPronounEvidence( + options.prompt, + ); + const combinedPronounEvidence = [ + ...directPromptPronounEvidence, + ...(situationalSocialRead?.pronounEvidence ?? []), + ]; + + if (combinedPronounEvidence.length > 0) { interactionMemory = (await options.interactionMemory.recordPronounEvidence( options.actor.id, options.actor.displayName, - situationalSocialRead.pronounEvidence, + combinedPronounEvidence, )) ?? interactionMemory; } diff --git a/packages/core/sql/bootstrap.sql b/packages/core/sql/bootstrap.sql index 445fd6e..a8d93a5 100644 --- a/packages/core/sql/bootstrap.sql +++ b/packages/core/sql/bootstrap.sql @@ -111,6 +111,7 @@ create index if not exists interaction_memory_events_actor_timestamp_idx create table if not exists interaction_identity_profiles ( actor_id text primary key, + actor_name text not null default '', pronoun_policy text not null default 'unknown', resolved_pronoun_set text, pronoun_confidence double precision, @@ -118,6 +119,9 @@ create table if not exists interaction_identity_profiles ( updated_at timestamptz not null default now() ); +alter table if exists interaction_identity_profiles + add column if not exists actor_name text not null default ''; + create table if not exists void_usage_rate_limit_state ( actor_id text primary key, daily_bucket date not null, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d70b97a..58f6c8f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ export * from "./context-builder"; export * from "./interaction-memory"; export * from "./job-queue"; export * from "./permission-engine"; +export * from "./pronoun-evidence"; export * from "./situational-social-read"; export * from "./state-storage"; export * from "./style-pack-loader"; diff --git a/packages/core/src/interaction-memory-analysis.ts b/packages/core/src/interaction-memory-analysis.ts index 09aafd4..e0232a0 100644 --- a/packages/core/src/interaction-memory-analysis.ts +++ b/packages/core/src/interaction-memory-analysis.ts @@ -13,6 +13,7 @@ import { REPETITION_LOOKBACK_HOURS, SIGNIFICANT_INTERACTION_TAGS, } from "./interaction-memory-shared"; +import { extractDirectPromptPronounEvidence } from "./pronoun-evidence"; export interface RecordInteractionInput { actorId: string; @@ -306,6 +307,16 @@ function analyzeInteractionTone( " stop replying ", ]); + const pronounEvidence = extractDirectPromptPronounEvidence(prompt, timestamp); + + if (pronounEvidence.some((entry) => entry.stance === "prefer")) { + tags.add("pronoun_preference"); + } + + if (pronounEvidence.some((entry) => entry.stance === "avoid")) { + tags.add("pronoun_correction"); + } + if (tags.has("courtesy") && (tags.has("gratitude") || tags.has("apology") || tags.has("praise"))) { score += 1; } @@ -429,6 +440,24 @@ function buildEventSummary( return ambient ? "Talked about shutting you down or pushing you away." : "Tried to shut you down or push you away."; } + if (tags.has("pronoun_correction") && tags.has("pronoun_preference")) { + return ambient + ? "Clarified someone else's pronoun preference while talking about identity." + : "Explicitly corrected how they want to be referred to."; + } + + if (tags.has("pronoun_correction")) { + return ambient + ? "Corrected pronoun usage while talking about identity." + : "Explicitly rejected a pronoun they do not want used for them."; + } + + if (tags.has("pronoun_preference")) { + return ambient + ? "Named a pronoun preference while talking about identity." + : "Explicitly stated how they prefer to be referred to."; + } + if (tags.has("ambition") && (tags.has("anxiety") || tags.has("insecurity"))) { return ambient ? "Revealed a large ambition while sounding uneasy about whether it would hold together." diff --git a/packages/core/src/interaction-memory-shared.ts b/packages/core/src/interaction-memory-shared.ts index ba3b9a5..272edce 100644 --- a/packages/core/src/interaction-memory-shared.ts +++ b/packages/core/src/interaction-memory-shared.ts @@ -24,6 +24,8 @@ export const SIGNIFICANT_INTERACTION_TAGS = new Set([ "suspicion", "rigidity", "withdrawal", + "pronoun_preference", + "pronoun_correction", ]); export const SUMMARY_NOTABLE_TAGS = new Set([ @@ -46,6 +48,8 @@ export const SUMMARY_NOTABLE_TAGS = new Set([ "suspicion", "rigidity", "withdrawal", + "pronoun_preference", + "pronoun_correction", ]); export const PROMPT_SIGNATURE_STOPWORDS = new Set([ diff --git a/packages/core/src/interaction-memory.ts b/packages/core/src/interaction-memory.ts index 9f24cf9..f1e51d2 100644 --- a/packages/core/src/interaction-memory.ts +++ b/packages/core/src/interaction-memory.ts @@ -46,7 +46,10 @@ export class FileInteractionMemoryBank implements InteractionMemoryBank { await this.writeChain; const store = await this.readUnlocked(); const profile = store.profiles.find((candidate) => candidate.actorId === actorId); - return profile && profile.totalInteractions > 0 ? structuredClone(profile) : undefined; + return profile && + (profile.totalInteractions > 0 || profile.pronounEvidence.length > 0) + ? structuredClone(profile) + : undefined; } public async recordInteraction( @@ -79,7 +82,10 @@ export class FileInteractionMemoryBank implements InteractionMemoryBank { existingProfile, ); - if (nextProfile.totalInteractions === 0) { + if ( + nextProfile.totalInteractions === 0 && + nextProfile.pronounEvidence.length === 0 + ) { if (existingProfileIndex !== -1) { store.profiles.splice(existingProfileIndex, 1); } @@ -108,12 +114,10 @@ export class FileInteractionMemoryBank implements InteractionMemoryBank { const existingProfileIndex = store.profiles.findIndex( (candidate) => candidate.actorId === actorId, ); - - if (existingProfileIndex === -1) { - return undefined; - } - - const existingProfile = store.profiles[existingProfileIndex]!; + const existingProfile = + existingProfileIndex === -1 + ? emptyInteractionProfile(actorId, actorName) + : store.profiles[existingProfileIndex]!; const mergedIdentityState = mergePronounEvidenceIntoIdentityState( existingProfile, evidence, @@ -124,7 +128,13 @@ export class FileInteractionMemoryBank implements InteractionMemoryBank { existingProfile.recentEvents, mergedIdentityState, ); - store.profiles[existingProfileIndex] = nextProfile; + + if (existingProfileIndex === -1) { + store.profiles.push(nextProfile); + } else { + store.profiles[existingProfileIndex] = nextProfile; + } + await this.writeUnlocked(store); return structuredClone(nextProfile); }); @@ -183,7 +193,10 @@ function normalizeStore(store: InteractionMemoryStore): InteractionMemoryStore { .slice(-MAX_RECENT_INTERACTION_EVENTS), normalizeInteractionIdentityState(profile), )) - .filter((profile) => profile.totalInteractions > 0), + .filter( + (profile) => + profile.totalInteractions > 0 || profile.pronounEvidence.length > 0, + ), }; } diff --git a/packages/core/src/pronoun-evidence.ts b/packages/core/src/pronoun-evidence.ts new file mode 100644 index 0000000..f4f5091 --- /dev/null +++ b/packages/core/src/pronoun-evidence.ts @@ -0,0 +1,153 @@ +import { type PronounEvidence, type PronounSet } from "@voidbot/shared"; + +const MAX_EXCERPT_LENGTH = 160; + +const PREFER_PATTERNS: Array<{ pronounSet: PronounSet; pattern: RegExp }> = [ + { + pronounSet: "she/her", + pattern: + /\bmy pronouns (?:are|re)\s+(?:she\s*\/\s*her|she her|female pronouns?)\b/i, + }, + { + pronounSet: "she/her", + pattern: + /\bi (?:prefer|use|want|would like)\s+(?:she\s*\/\s*her|she her|female pronouns?)\b/i, + }, + { + pronounSet: "she/her", + pattern: + /\b(?:use|call|refer to) me(?: as)?\s+(?:she\s*\/\s*her|she her)\b/i, + }, + { + pronounSet: "she/her", + pattern: /\b(?:use|prefer)\s+female pronouns?\s+for me\b/i, + }, + { + pronounSet: "he/him", + pattern: + /\bmy pronouns (?:are|re)\s+(?:he\s*\/\s*him|he him|male pronouns?)\b/i, + }, + { + pronounSet: "he/him", + pattern: + /\bi (?:prefer|use|want|would like)\s+(?:he\s*\/\s*him|he him|male pronouns?)\b/i, + }, + { + pronounSet: "he/him", + pattern: + /\b(?:use|call|refer to) me(?: as)?\s+(?:he\s*\/\s*him|he him)\b/i, + }, + { + pronounSet: "he/him", + pattern: /\b(?:use|prefer)\s+male pronouns?\s+for me\b/i, + }, + { + pronounSet: "they/them", + pattern: + /\bmy pronouns (?:are|re)\s+(?:they\s*\/\s*them|they them|neutral pronouns?)\b/i, + }, + { + pronounSet: "they/them", + pattern: + /\bi (?:prefer|use|want|would like)\s+(?:they\s*\/\s*them|they them|neutral pronouns?)\b/i, + }, + { + pronounSet: "they/them", + pattern: + /\b(?:use|call|refer to) me(?: as)?\s+(?:they\s*\/\s*them|they them)\b/i, + }, + { + pronounSet: "they/them", + pattern: /\b(?:use|prefer)\s+neutral pronouns?\s+for me\b/i, + }, +]; + +const AVOID_PATTERNS: Array<{ pronounSet: PronounSet; pattern: RegExp }> = [ + { + pronounSet: "she/her", + pattern: + /\b(?:do not|don't) call me(?: as)?\s+(?:she|she\s*\/\s*her|she her)\b/i, + }, + { + pronounSet: "she/her", + pattern: + /\bi (?:prefer|would prefer|want) not to be called(?: as)?\s+(?:she|she\s*\/\s*her|she her)\b/i, + }, + { + pronounSet: "he/him", + pattern: + /\b(?:do not|don't) call me(?: as)?\s+(?:he|he\s*\/\s*him|he him)\b/i, + }, + { + pronounSet: "he/him", + pattern: + /\bi (?:prefer|would prefer|want) not to be called(?: as)?\s+(?:he|he\s*\/\s*him|he him)\b/i, + }, + { + pronounSet: "they/them", + pattern: + /\b(?:do not|don't) call me(?: as)?\s+(?:they|they\s*\/\s*them|they them)\b/i, + }, + { + pronounSet: "they/them", + pattern: + /\bi (?:prefer|would prefer|want) not to be called(?: as)?\s+(?:they|they\s*\/\s*them|they them)\b/i, + }, +]; + +export function extractDirectPromptPronounEvidence( + prompt: string, + timestamp = new Date().toISOString(), +): PronounEvidence[] { + const excerpt = buildExcerpt(prompt); + const evidence: PronounEvidence[] = []; + + for (const candidate of PREFER_PATTERNS) { + if (candidate.pattern.test(prompt)) { + evidence.push({ + pronounSet: candidate.pronounSet, + source: "explicit_self_statement", + stance: "prefer", + confidence: 1, + excerpt, + timestamp, + }); + } + } + + for (const candidate of AVOID_PATTERNS) { + if (candidate.pattern.test(prompt)) { + evidence.push({ + pronounSet: candidate.pronounSet, + source: "explicit_correction", + stance: "avoid", + confidence: 1, + excerpt, + timestamp, + }); + } + } + + return dedupePronounEvidence(evidence); +} + +function dedupePronounEvidence(evidence: PronounEvidence[]): PronounEvidence[] { + const deduped = new Map(); + + for (const entry of evidence) { + const key = `${entry.pronounSet}::${entry.source}::${entry.stance}`; + + if (!deduped.has(key)) { + deduped.set(key, entry); + } + } + + return [...deduped.values()]; +} + +function buildExcerpt(prompt: string): string { + const compact = prompt.replace(/\s+/g, " ").trim(); + return compact.length <= MAX_EXCERPT_LENGTH + ? compact + : `${compact.slice(0, MAX_EXCERPT_LENGTH - 3)}...`; +} diff --git a/packages/core/src/state-storage-postgres-bootstrap.ts b/packages/core/src/state-storage-postgres-bootstrap.ts index 0b6eb91..659e37e 100644 --- a/packages/core/src/state-storage-postgres-bootstrap.ts +++ b/packages/core/src/state-storage-postgres-bootstrap.ts @@ -73,13 +73,18 @@ export async function migrateLegacyFileStateIfNeeded( continue; } - await upsertInteractionIdentityState(client, profile.actorId, { - pronounPolicy: profile.pronounPolicy, - resolvedPronounSet: profile.resolvedPronounSet, - pronounConfidence: profile.pronounConfidence, - pronounGuidance: profile.pronounGuidance, - pronounEvidence: profile.pronounEvidence, - }); + await upsertInteractionIdentityState( + client, + profile.actorId, + profile.actorName, + { + pronounPolicy: profile.pronounPolicy, + resolvedPronounSet: profile.resolvedPronounSet, + pronounConfidence: profile.pronounConfidence, + pronounGuidance: profile.pronounGuidance, + pronounEvidence: profile.pronounEvidence, + }, + ); } await client.query( diff --git a/packages/core/src/state-storage-postgres-interaction-memory.ts b/packages/core/src/state-storage-postgres-interaction-memory.ts index d3a8199..4de0d58 100644 --- a/packages/core/src/state-storage-postgres-interaction-memory.ts +++ b/packages/core/src/state-storage-postgres-interaction-memory.ts @@ -23,11 +23,21 @@ export class PostgresInteractionMemoryBank implements InteractionMemoryBank { public async getProfile( actorId: string, ): Promise { - const identityState = await this.getIdentityState(actorId); + const identityRecord = await this.getIdentityRecord(actorId); + const identityState = identityRecord?.state; const events = await this.listActorEvents(actorId); if (events.length === 0) { - return undefined; + if (!identityState) { + return undefined; + } + + return summarizeInteractionProfile( + actorId, + identityRecord?.actorName || actorId, + [], + identityState, + ); } const profile = summarizeInteractionProfile( @@ -37,13 +47,16 @@ export class PostgresInteractionMemoryBank implements InteractionMemoryBank { identityState, ); - return profile.totalInteractions > 0 ? profile : undefined; + return profile.totalInteractions > 0 || profile.pronounEvidence.length > 0 + ? profile + : undefined; } public async recordInteraction( input: RecordInteractionInput, ): Promise { - const identityState = await this.getIdentityState(input.actorId); + const identityRecord = await this.getIdentityRecord(input.actorId); + const identityState = identityRecord?.state; const existingEvents = await this.listActorEvents(input.actorId); const priorEvents = existingEvents.filter((event) => event.id !== input.eventId); const event = buildInteractionMemoryEvent(input, priorEvents); @@ -82,23 +95,19 @@ export class PostgresInteractionMemoryBank implements InteractionMemoryBank { const existingEvents = await this.listActorEvents(actorId); - if (existingEvents.length === 0) { - return undefined; - } - const mergedIdentityState = mergePronounEvidenceIntoIdentityState( - await this.getIdentityState(actorId), + (await this.getIdentityRecord(actorId))?.state, evidence, ); - await upsertInteractionIdentityState(this.pool, actorId, mergedIdentityState); - - return summarizeInteractionProfile( + await upsertInteractionIdentityState( + this.pool, actorId, actorName, - existingEvents, mergedIdentityState, ); + + return summarizeInteractionProfile(actorId, actorName, existingEvents, mergedIdentityState); } private async listActorEvents(actorId: string): Promise { @@ -115,23 +124,28 @@ export class PostgresInteractionMemoryBank implements InteractionMemoryBank { .filter((event): event is InteractionMemoryEvent => event !== undefined); } - private async getIdentityState( + private async getIdentityRecord( actorId: string, - ): Promise { - const result = await this.pool.query<{ profile_json: unknown }>( - `select profile_json + ): Promise<{ actorName: string; state: InteractionIdentityState } | undefined> { + const result = await this.pool.query<{ actor_name: string; profile_json: unknown }>( + `select actor_name, profile_json from interaction_identity_profiles where actor_id = $1`, [actorId], ); - if (!result.rows[0]) { + const row = result.rows[0]; + + if (!row) { return undefined; } - return normalizeInteractionIdentityState( - deserializeJson(result.rows[0].profile_json), - ); + return { + actorName: row.actor_name, + state: normalizeInteractionIdentityState( + deserializeJson(row.profile_json), + ), + }; } } @@ -198,25 +212,29 @@ export async function upsertInteractionMemoryEvent( export async function upsertInteractionIdentityState( client: Pool | PoolClient, actorId: string, + actorName: string, state: InteractionIdentityState, ): Promise { await client.query( `insert into interaction_identity_profiles ( actor_id, + actor_name, pronoun_policy, resolved_pronoun_set, pronoun_confidence, profile_json, updated_at - ) values ($1, $2, $3, $4, $5::jsonb, now()) + ) values ($1, $2, $3, $4, $5, $6::jsonb, now()) on conflict (actor_id) do update - set pronoun_policy = excluded.pronoun_policy, + set actor_name = excluded.actor_name, + pronoun_policy = excluded.pronoun_policy, resolved_pronoun_set = excluded.resolved_pronoun_set, pronoun_confidence = excluded.pronoun_confidence, profile_json = excluded.profile_json, updated_at = now()`, [ actorId, + actorName, state.pronounPolicy, state.resolvedPronounSet ?? null, state.pronounConfidence ?? null, diff --git a/state/evidence.jsonl b/state/evidence.jsonl index b3c652f..508e139 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -27,3 +27,4 @@ {"ts":"2026-05-02T21:05:00+01:00","type":"bugfix","status":"ok","note":"Archived-history answers should not make the model infer time sense from bare ISO strings alone; history tool results now carry human-readable time context, and both reply lanes are explicitly told to narrate old events in the correct tense and avoid guessing pronouns when the supplied context does not state them."} {"ts":"2026-05-02T21:13:34+01:00","type":"architecture","status":"ok","note":"Pronoun handling now runs on structured evidence rather than name-guessing: the room-read sidecar emits current-speaker pronoun cues with provenance and confidence, interaction memory persists and merges them as identity state, and explicit self-statements or corrections override softer contextual inferences before the reply lanes consume private pronoun guidance."} {"ts":"2026-05-02T21:23:00+01:00","type":"implementation","status":"ok","note":"Profile disclosure is now a dedicated `/profile` slash command instead of fuzzy chat intent detection: it DMs the caller a fuller internal profile read, keeps the public channel to a short confirmation or failure notice, and avoids turning privacy-sensitive introspection into another free-form language parsing stunt."} +{"ts":"2026-05-02T21:33:00+01:00","type":"bugfix","status":"ok","note":"Pronoun storage no longer depends entirely on the room-read sidecar noticing the obvious. Direct self-statements like 'I prefer female pronouns' are now extracted deterministically, tagged as significant memory, and can persist even when they are the only meaningful thing the speaker has said so far. Identity-only profiles are allowed to exist long enough to remember that kind of correction."}