diff --git a/apps/bot/src/discord-bot-handlers.ts b/apps/bot/src/discord-bot-handlers.ts index 1bf297d..090e6c8 100644 --- a/apps/bot/src/discord-bot-handlers.ts +++ b/apps/bot/src/discord-bot-handlers.ts @@ -11,7 +11,6 @@ import { import type { AppConfig } from "@voidbot/config"; import { ContextBuilder, - extractDirectPromptPronounEvidence, type OllamaSituationalSocialReadInferer, PermissionEngine, type AuditLog, @@ -211,6 +210,14 @@ export async function handlePrompt(options: PromptHandlerOptions): Promise return; } + let interactionMemory = await options.interactionMemory.getProfile(options.actor.id); + + const recentMessages = await getRecentMessages(options.channel, 10); + const situationalSocialRead = await inferSituationalSocialRead(options, { + recentMessages, + interactionMemory, + }); + const pronounEvidence = situationalSocialRead?.pronounEvidence ?? []; const rememberedInteraction = await options.interactionMemory.recordInteraction({ actorId: options.actor.id, actorName: options.actor.displayName, @@ -221,29 +228,19 @@ export async function handlePrompt(options: PromptHandlerOptions): Promise command: options.command, prompt: options.prompt, eventId: options.requestMessageId, + pronounEvidence, }); - let interactionMemory = - rememberedInteraction.totalInteractions > 0 ? rememberedInteraction : undefined; + interactionMemory = + rememberedInteraction.totalInteractions > 0 || rememberedInteraction.pronounEvidence.length > 0 + ? rememberedInteraction + : undefined; - const recentMessages = await getRecentMessages(options.channel, 10); - const situationalSocialRead = await inferSituationalSocialRead(options, { - recentMessages, - interactionMemory, - }); - const directPromptPronounEvidence = extractDirectPromptPronounEvidence( - options.prompt, - ); - const combinedPronounEvidence = [ - ...directPromptPronounEvidence, - ...(situationalSocialRead?.pronounEvidence ?? []), - ]; - - if (combinedPronounEvidence.length > 0) { + if (pronounEvidence.length > 0) { interactionMemory = (await options.interactionMemory.recordPronounEvidence( options.actor.id, options.actor.displayName, - combinedPronounEvidence, + pronounEvidence, )) ?? interactionMemory; } diff --git a/apps/bot/src/discord-bot-support.ts b/apps/bot/src/discord-bot-support.ts index e49e587..0ec4270 100644 --- a/apps/bot/src/discord-bot-support.ts +++ b/apps/bot/src/discord-bot-support.ts @@ -508,7 +508,7 @@ export function renderInteractionProfileDisclosure( "", "Pronoun handling:", `- Policy: ${profile.pronounPolicy}`, - `- Resolved set: ${profile.resolvedPronounSet ?? "none"}`, + `- Resolved sets: ${profile.resolvedPronounSets.length > 0 ? profile.resolvedPronounSets.join(", ") : profile.resolvedPronounSet ?? "none"}`, `- Confidence: ${profile.pronounConfidence !== undefined ? `${Math.round(profile.pronounConfidence * 100)}%` : "n/a"}`, `- Guidance: ${profile.pronounGuidance}`, "- Evidence:", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 58f6c8f..d70b97a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,7 +4,6 @@ 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 e0232a0..4b2113c 100644 --- a/packages/core/src/interaction-memory-analysis.ts +++ b/packages/core/src/interaction-memory-analysis.ts @@ -5,6 +5,7 @@ import { type InteractionMemoryEvent, type InteractionMemorySentiment, type InteractionMemorySourceKind, + type PronounEvidence, } from "@voidbot/shared"; import { @@ -13,7 +14,6 @@ import { REPETITION_LOOKBACK_HOURS, SIGNIFICANT_INTERACTION_TAGS, } from "./interaction-memory-shared"; -import { extractDirectPromptPronounEvidence } from "./pronoun-evidence"; export interface RecordInteractionInput { actorId: string; @@ -26,6 +26,7 @@ export interface RecordInteractionInput { prompt: string; timestamp?: string; eventId?: string; + pronounEvidence?: PronounEvidence[]; } interface ToneAnalysis { @@ -44,7 +45,13 @@ export function buildInteractionMemoryEvent( ): InteractionMemoryEvent { const timestamp = input.timestamp ?? new Date().toISOString(); const prompt = input.prompt.trim(); - const analysis = analyzeInteractionTone(prompt, input.sourceKind, timestamp, priorEvents); + const analysis = analyzeInteractionTone( + prompt, + input.sourceKind, + timestamp, + priorEvents, + input.pronounEvidence, + ); return { id: input.eventId ?? randomUUID(), @@ -66,6 +73,10 @@ export function buildInteractionMemoryEvent( } export function shouldPersistInteractionEvent(event: InteractionMemoryEvent): boolean { + if (event.sourceKind === "direct_prompt") { + return true; + } + if (event.sourceKind === "ambient_mention") { return true; } @@ -88,14 +99,15 @@ export function normalizeInteractionEvent( event.timestamp, priorEvents, ); + const mergedTags = mergePronounTags(analysis.tags, event.tags); return { ...event, sourceKind, - summary: buildEventSummary(sourceKind, analysis.sentiment, new Set(analysis.tags)), + summary: buildEventSummary(sourceKind, analysis.sentiment, new Set(mergedTags)), sentiment: analysis.sentiment, score: analysis.score, - tags: analysis.tags, + tags: mergedTags, }; } @@ -104,6 +116,7 @@ function analyzeInteractionTone( sourceKind: InteractionMemorySourceKind, timestamp: string, priorEvents: InteractionMemoryEvent[], + pronounEvidence: PronounEvidence[] = [], ): ToneAnalysis { const normalized = ` ${prompt .toLowerCase() @@ -307,8 +320,6 @@ function analyzeInteractionTone( " stop replying ", ]); - const pronounEvidence = extractDirectPromptPronounEvidence(prompt, timestamp); - if (pronounEvidence.some((entry) => entry.stance === "prefer")) { tags.add("pronoun_preference"); } @@ -351,6 +362,21 @@ function analyzeInteractionTone( }; } +function mergePronounTags( + derivedTags: string[], + priorTags: string[] | undefined, +): string[] { + const merged = new Set(derivedTags); + + for (const tag of priorTags ?? []) { + if (tag === "pronoun_preference" || tag === "pronoun_correction") { + merged.add(tag); + } + } + + return [...merged]; +} + function matchWeightedPhrases( normalizedPrompt: string, tags: Set, diff --git a/packages/core/src/interaction-memory-profile.ts b/packages/core/src/interaction-memory-profile.ts index 3b8b565..00fe509 100644 --- a/packages/core/src/interaction-memory-profile.ts +++ b/packages/core/src/interaction-memory-profile.ts @@ -29,6 +29,7 @@ interface PsychologicalRead { export interface InteractionIdentityState { pronounPolicy: PronounPolicy; resolvedPronounSet?: PronounSet; + resolvedPronounSets: PronounSet[]; pronounConfidence?: number; pronounGuidance: string; pronounEvidence: PronounEvidence[]; @@ -86,6 +87,7 @@ export function summarizeInteractionProfile( responseGuidance: psychologicalRead.responseGuidance, pronounPolicy: normalizedIdentityState.pronounPolicy, resolvedPronounSet: normalizedIdentityState.resolvedPronounSet, + resolvedPronounSets: normalizedIdentityState.resolvedPronounSets, pronounConfidence: normalizedIdentityState.pronounConfidence, pronounGuidance: normalizedIdentityState.pronounGuidance, pronounEvidence: normalizedIdentityState.pronounEvidence, @@ -108,6 +110,7 @@ export function emptyInteractionProfile( export function emptyInteractionIdentityState(): InteractionIdentityState { return { pronounPolicy: "unknown", + resolvedPronounSets: [], pronounGuidance: "No reliable pronoun preference is established for this speaker yet. Use their name or neutral phrasing unless the current context states otherwise.", pronounEvidence: [], @@ -599,12 +602,45 @@ function resolveInteractionIdentityState( ) .sort((left, right) => right.timestamp.localeCompare(left.timestamp)); - if (explicitPreferred.length > 0) { - const winning = explicitPreferred[0]!; + const explicitPreferredSets = [ + ...new Set( + explicitPreferred + .map((entry) => entry.pronounSet) + .filter( + (pronounSet) => + !normalizedEvidence.some( + (entry) => + entry.pronounSet === pronounSet && + entry.stance === "avoid" && + (entry.source === "explicit_self_statement" || + entry.source === "explicit_correction") && + entry.timestamp >= + (explicitPreferred.find((candidate) => candidate.pronounSet === pronounSet) + ?.timestamp ?? ""), + ), + ), + ), + ]; + + if (explicitPreferredSets.length > 1) { + return { + pronounPolicy: "explicit", + resolvedPronounSets: explicitPreferredSets, + pronounConfidence: 1, + pronounGuidance: `Any of these pronoun sets are explicitly acceptable for this speaker: ${explicitPreferredSets.join(", ")}. Avoid pronoun sets they explicitly rejected.`, + pronounEvidence: normalizedEvidence, + }; + } + + if (explicitPreferredSets.length === 1) { + const winning = explicitPreferred.find( + (entry) => entry.pronounSet === explicitPreferredSets[0], + )!; return { pronounPolicy: "explicit", resolvedPronounSet: winning.pronounSet, + resolvedPronounSets: [winning.pronounSet], pronounConfidence: winning.confidence, pronounGuidance: `Use ${winning.pronounSet} for this speaker. This preference came from an explicit statement or correction and should override softer contextual guesses.`, pronounEvidence: normalizedEvidence, @@ -629,6 +665,7 @@ function resolveInteractionIdentityState( if (!winner || winner[1] <= 0) { return { pronounPolicy: "unknown", + resolvedPronounSets: [], pronounGuidance: "Pronoun evidence exists, but it does not establish a clear usable preference yet. Use the speaker's name or neutral phrasing unless they clarify.", pronounEvidence: normalizedEvidence, @@ -638,6 +675,7 @@ function resolveInteractionIdentityState( if (runnerUp && Math.abs(winner[1] - runnerUp[1]) < 1) { return { pronounPolicy: "conflicted", + resolvedPronounSets: [], pronounGuidance: "Pronoun evidence for this speaker is conflicted or too close to call. Avoid guessing; use their name or neutral phrasing unless they clarify.", pronounEvidence: normalizedEvidence, @@ -649,6 +687,7 @@ function resolveInteractionIdentityState( return { pronounPolicy: "inferred", resolvedPronounSet: winner[0], + resolvedPronounSets: [winner[0]], pronounConfidence: confidence, pronounGuidance: `Prefer ${winner[0]} for this speaker based on accumulated contextual evidence, but switch immediately if they or the room explicitly correct it.`, pronounEvidence: normalizedEvidence, diff --git a/packages/core/src/pronoun-evidence.ts b/packages/core/src/pronoun-evidence.ts deleted file mode 100644 index f4f5091..0000000 --- a/packages/core/src/pronoun-evidence.ts +++ /dev/null @@ -1,153 +0,0 @@ -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/situational-social-read.ts b/packages/core/src/situational-social-read.ts index d2c98f0..cee68f8 100644 --- a/packages/core/src/situational-social-read.ts +++ b/packages/core/src/situational-social-read.ts @@ -38,6 +38,10 @@ interface InferSituationalSocialReadResponseBody { }; } +interface InferPronounEvidenceResponseBody { + pronounEvidence?: unknown; +} + export interface OllamaSituationalSocialReadInfererOptions { ollamaBaseUrl: string; ollamaModel: string; @@ -57,11 +61,12 @@ export class OllamaSituationalSocialReadInferer { public async infer( input: SituationalSocialReadInput, ): Promise { + const prompt = input.prompt.trim(); const recentMessages = (input.recentMessages ?? []) .filter((message) => message.content.trim().length > 0) .slice(-MAX_RECENT_MESSAGES); - if (recentMessages.length === 0 && !input.interactionMemory) { + if (prompt.length === 0 && recentMessages.length === 0 && !input.interactionMemory) { return undefined; } @@ -72,53 +77,32 @@ export class OllamaSituationalSocialReadInferer { ); try { - const requestBody: InferSituationalSocialReadRequestBody = { - model: this.options.ollamaModel, - messages: [ - { - role: "system", - content: buildSystemPrompt(), - }, - { - role: "user", - content: buildUserPrompt(input, recentMessages), - }, - ], - stream: false, - think: false, - keep_alive: this.options.ollamaKeepAlive, - format: "json", - options: { - num_ctx: this.options.ollamaNumCtx, - temperature: 0.2, - }, - }; - - const response = await fetch(`${normalizeBaseUrl(this.options.ollamaBaseUrl)}/api/chat`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - signal: controller.signal, - }); - - if (!response.ok) { - throw new Error( - `Ollama situational social read request failed with ${response.status}: ${await response.text()}`, - ); - } - - const payload = - (await response.json()) as InferSituationalSocialReadResponseBody; - const rawContent = normalizeModelText(payload.message?.content); + const rawContent = await this.requestStructuredJson( + buildSystemPrompt(), + buildUserPrompt(input, recentMessages), + controller.signal, + ); if (!rawContent) { return undefined; } const parsed = parseSituationalSocialRead(rawContent); - return parsed ?? undefined; + + if (!parsed) { + return undefined; + } + + const pronounEvidence = await this.inferPronounEvidence( + input, + recentMessages, + controller.signal, + ); + + return { + ...parsed, + pronounEvidence, + }; } catch (error) { if ((error as Error).name === "AbortError") { throw new Error( @@ -131,6 +115,74 @@ export class OllamaSituationalSocialReadInferer { clearTimeout(timeoutHandle); } } + + private async inferPronounEvidence( + input: SituationalSocialReadInput, + recentMessages: SourceMessage[], + signal: AbortSignal, + ): Promise { + try { + const rawContent = await this.requestStructuredJson( + buildPronounSystemPrompt(), + buildPronounUserPrompt(input, recentMessages), + signal, + ); + + if (!rawContent) { + return []; + } + + return parsePronounEvidencePayload(rawContent); + } catch { + return []; + } + } + + private async requestStructuredJson( + systemPrompt: string, + userPrompt: string, + signal: AbortSignal, + ): Promise { + const requestBody: InferSituationalSocialReadRequestBody = { + model: this.options.ollamaModel, + messages: [ + { + role: "system", + content: systemPrompt, + }, + { + role: "user", + content: userPrompt, + }, + ], + stream: false, + think: false, + keep_alive: this.options.ollamaKeepAlive, + format: "json", + options: { + num_ctx: this.options.ollamaNumCtx, + temperature: 0.2, + }, + }; + + const response = await fetch(`${normalizeBaseUrl(this.options.ollamaBaseUrl)}/api/chat`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + signal, + }); + + if (!response.ok) { + throw new Error( + `Ollama situational social read request failed with ${response.status}: ${await response.text()}`, + ); + } + + const payload = (await response.json()) as InferSituationalSocialReadResponseBody; + return normalizeModelText(payload.message?.content); + } } function buildSystemPrompt(): string { @@ -147,12 +199,27 @@ function buildSystemPrompt(): string { '- "socialFrame": what kind of interaction this is and how it should be framed', '- "responseGuidance": private guidance for the final reply model', '- "supportingSignals": an array of short evidence bullets tied to the visible context', - '- "pronounEvidence": an array of evidence objects about the current speaker only, each with "pronounSet", "source", "stance", "confidence", and "excerpt"', - 'Only emit pronounEvidence when the room context provides a real clue about the current speaker. Never guess from the speaker name alone.', + "If the evidence is weak, say so plainly inside the fields instead of inventing drama.", + ].join("\n"); +} + +function buildPronounSystemPrompt(): string { + return [ + "You are extracting structured pronoun evidence for the current Discord speaker.", + "Use the current prompt itself plus any recent room transcript as evidence.", + "Never guess from the speaker name alone.", + "Return strict JSON only with one key: \"pronounEvidence\".", + "\"pronounEvidence\" must be an array of evidence objects about the current speaker only.", + "Each evidence object must contain: \"pronounSet\", \"source\", \"stance\", \"confidence\", and \"excerpt\".", 'Use only these pronoun sets: "they/them", "he/him", "she/her".', 'Use only these evidence sources: "explicit_self_statement", "explicit_correction", "direct_third_party_statement", "contextual_relational_inference", "ambient_usage".', - 'Use stance "prefer" when the evidence supports usage and "avoid" when the evidence explicitly rejects a set.', - "If the evidence is weak, say so plainly inside the fields instead of inventing drama.", + 'Use stance "prefer" when the speaker accepts a pronoun set and "avoid" when the speaker explicitly rejects it.', + "If the speaker explicitly accepts multiple pronoun sets, emit one evidence object per accepted set.", + "Examples:", + '- "I prefer male or gender neutral pronouns" -> emit "he/him" prefer and "they/them" prefer.', + '- "call me by they/them preferably" -> emit "they/them" prefer.', + '- "do not call me he" -> emit "he/him" avoid.', + 'If there is no real evidence, return {"pronounEvidence":[]}.', ].join("\n"); } @@ -196,6 +263,34 @@ function buildUserPrompt( ].join("\n"); } +function buildPronounUserPrompt( + input: SituationalSocialReadInput, + recentMessages: SourceMessage[], +): string { + const recentTranscript = + recentMessages.length > 0 + ? recentMessages + .map( + (message) => + `- [${message.timestamp}] ${message.authorName}${message.isBot ? " (bot)" : ""}: ${truncate(message.content, MAX_MESSAGE_CHARS)}`, + ) + .join("\n") + : "- No recent room transcript was attached."; + + return [ + `Current speaker: ${input.actor.displayName} (${input.actor.id})`, + "", + "Current prompt:", + input.prompt, + "", + "Recent room transcript:", + recentTranscript, + "", + "Extract pronoun evidence for the current speaker only.", + "Do not output anything except the requested JSON object.", + ].join("\n"); +} + function parseSituationalSocialRead( input: string, ): SituationalSocialRead | undefined { @@ -213,7 +308,6 @@ function parseSituationalSocialRead( const socialFrame = normalizeModelText(parsed.socialFrame); const responseGuidance = normalizeModelText(parsed.responseGuidance); const supportingSignals = normalizeStringArray(parsed.supportingSignals); - const pronounEvidence = normalizePronounEvidenceArray(parsed.pronounEvidence); if ( !summary || @@ -232,13 +326,28 @@ function parseSituationalSocialRead( socialFrame, responseGuidance, supportingSignals, - pronounEvidence, + pronounEvidence: [], }; } catch { return undefined; } } +function parsePronounEvidencePayload(input: string): PronounEvidence[] { + const normalized = unwrapStructuredFence(input); + + if (!normalized.startsWith("{")) { + return []; + } + + try { + const parsed = JSON.parse(normalized) as InferPronounEvidenceResponseBody; + return normalizePronounEvidenceArray(parsed.pronounEvidence); + } catch { + return []; + } +} + function normalizeStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; diff --git a/packages/core/src/state-storage-postgres-bootstrap.ts b/packages/core/src/state-storage-postgres-bootstrap.ts index 659e37e..f2ccb52 100644 --- a/packages/core/src/state-storage-postgres-bootstrap.ts +++ b/packages/core/src/state-storage-postgres-bootstrap.ts @@ -80,6 +80,7 @@ export async function migrateLegacyFileStateIfNeeded( { pronounPolicy: profile.pronounPolicy, resolvedPronounSet: profile.resolvedPronounSet, + resolvedPronounSets: profile.resolvedPronounSets, pronounConfidence: profile.pronounConfidence, pronounGuidance: profile.pronounGuidance, pronounEvidence: profile.pronounEvidence, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9db87df..e57a63e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -204,6 +204,7 @@ export interface InteractionMemoryProfile { responseGuidance: string; pronounPolicy: PronounPolicy; resolvedPronounSet?: PronounSet; + resolvedPronounSets: PronounSet[]; pronounConfidence?: number; pronounGuidance: string; pronounEvidence: PronounEvidence[]; diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 508e139..04a7aff 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -27,4 +27,5 @@ {"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."} +{"ts":"2026-05-02T21:33:00+01:00","type":"bugfix","status":"ok","note":"Identity-only profiles are allowed to exist long enough to remember explicit pronoun corrections or preferences, even when a speaker has not yet built up rich broader interaction history with the bot."} +{"ts":"2026-05-02T22:10:00+01:00","type":"architecture","status":"ok","note":"Pronoun inference belongs to the situational sidecar, not a parallel regex parser. The sidecar now treats the current prompt itself as valid context, can emit multiple explicit acceptable pronoun sets, and its evidence is threaded into both event summaries and durable profile state."}