diff --git a/apps/bot/src/discord-bot-handlers.ts b/apps/bot/src/discord-bot-handlers.ts index d698771..70022a4 100644 --- a/apps/bot/src/discord-bot-handlers.ts +++ b/apps/bot/src/discord-bot-handlers.ts @@ -216,7 +216,7 @@ export async function handlePrompt(options: PromptHandlerOptions): Promise prompt: options.prompt, eventId: options.requestMessageId, }); - const interactionMemory = + let interactionMemory = rememberedInteraction.totalInteractions > 0 ? rememberedInteraction : undefined; const recentMessages = await getRecentMessages(options.channel, 10); @@ -224,6 +224,14 @@ export async function handlePrompt(options: PromptHandlerOptions): Promise recentMessages, interactionMemory, }); + if (situationalSocialRead?.pronounEvidence.length) { + interactionMemory = + (await options.interactionMemory.recordPronounEvidence( + options.actor.id, + options.actor.displayName, + situationalSocialRead.pronounEvidence, + )) ?? interactionMemory; + } const sourceGrounding = inferSourceGroundingHint( options.prompt, await options.sourceArchiveRepository.listRepoSummaries(), @@ -678,6 +686,7 @@ async function inferSituationalSocialRead( socialFrame: read.socialFrame, responseGuidance: read.responseGuidance, supportingSignals: read.supportingSignals, + pronounEvidence: read.pronounEvidence, }, }); diff --git a/packages/core/sql/bootstrap.sql b/packages/core/sql/bootstrap.sql index a1d0e59..445fd6e 100644 --- a/packages/core/sql/bootstrap.sql +++ b/packages/core/sql/bootstrap.sql @@ -109,6 +109,15 @@ create table if not exists interaction_memory_events ( create index if not exists interaction_memory_events_actor_timestamp_idx on interaction_memory_events (actor_id, event_timestamp desc); +create table if not exists interaction_identity_profiles ( + actor_id text primary key, + pronoun_policy text not null default 'unknown', + resolved_pronoun_set text, + pronoun_confidence double precision, + profile_json jsonb not null default '{}'::jsonb, + updated_at timestamptz not null default now() +); + 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/interaction-memory-logic.ts b/packages/core/src/interaction-memory-logic.ts index 8846964..bf103fc 100644 --- a/packages/core/src/interaction-memory-logic.ts +++ b/packages/core/src/interaction-memory-logic.ts @@ -9,5 +9,8 @@ export { export { emptyInteractionProfile, + emptyInteractionIdentityState, + mergePronounEvidenceIntoIdentityState, + normalizeInteractionIdentityState, summarizeInteractionProfile, } from "./interaction-memory-profile"; diff --git a/packages/core/src/interaction-memory-profile.ts b/packages/core/src/interaction-memory-profile.ts index 8386507..3b8b565 100644 --- a/packages/core/src/interaction-memory-profile.ts +++ b/packages/core/src/interaction-memory-profile.ts @@ -3,6 +3,9 @@ import { type InteractionMemoryDisposition, type InteractionMemoryEvent, type InteractionMemoryProfile, + type PronounEvidence, + type PronounPolicy, + type PronounSet, } from "@voidbot/shared"; import { @@ -23,10 +26,21 @@ interface PsychologicalRead { responseGuidance: string; } +export interface InteractionIdentityState { + pronounPolicy: PronounPolicy; + resolvedPronounSet?: PronounSet; + pronounConfidence?: number; + pronounGuidance: string; + pronounEvidence: PronounEvidence[]; +} + +const MAX_PRONOUN_EVIDENCE = 12; + export function summarizeInteractionProfile( actorId: string, actorName: string, events: InteractionMemoryEvent[], + identityState?: InteractionIdentityState, ): InteractionMemoryProfile { const chronologicalEvents = events .slice() @@ -48,6 +62,7 @@ export function summarizeInteractionProfile( MAX_AFFINITY_SCORE, ); const psychologicalRead = buildPsychologicalRead(normalizedEvents); + const normalizedIdentityState = normalizeInteractionIdentityState(identityState); const profile: InteractionMemoryProfile = { actorId, @@ -69,6 +84,11 @@ export function summarizeInteractionProfile( inferredTraits: psychologicalRead.inferredTraits, interactionDimensions: psychologicalRead.interactionDimensions, responseGuidance: psychologicalRead.responseGuidance, + pronounPolicy: normalizedIdentityState.pronounPolicy, + resolvedPronounSet: normalizedIdentityState.resolvedPronounSet, + pronounConfidence: normalizedIdentityState.pronounConfidence, + pronounGuidance: normalizedIdentityState.pronounGuidance, + pronounEvidence: normalizedIdentityState.pronounEvidence, lastInteractionAt: normalizedEvents[normalizedEvents.length - 1]?.timestamp, recentEvents, }; @@ -85,6 +105,36 @@ export function emptyInteractionProfile( return summarizeInteractionProfile(actorId, actorName, []); } +export function emptyInteractionIdentityState(): InteractionIdentityState { + return { + pronounPolicy: "unknown", + pronounGuidance: + "No reliable pronoun preference is established for this speaker yet. Use their name or neutral phrasing unless the current context states otherwise.", + pronounEvidence: [], + }; +} + +export function normalizeInteractionIdentityState( + state: Partial | undefined, +): InteractionIdentityState { + if (!state) { + return emptyInteractionIdentityState(); + } + + return resolveInteractionIdentityState(state.pronounEvidence ?? []); +} + +export function mergePronounEvidenceIntoIdentityState( + existingState: Partial | undefined, + newEvidence: PronounEvidence[], +): InteractionIdentityState { + const mergedEvidence = dedupePronounEvidence([ + ...(existingState?.pronounEvidence ?? []), + ...newEvidence, + ]); + return resolveInteractionIdentityState(mergedEvidence); +} + function determineDisposition( profile: InteractionMemoryProfile, ): InteractionMemoryDisposition { @@ -530,3 +580,112 @@ function formatShortDate(timestamp: string): string { function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } + +function resolveInteractionIdentityState( + evidence: PronounEvidence[], +): InteractionIdentityState { + const normalizedEvidence = dedupePronounEvidence(evidence); + + if (normalizedEvidence.length === 0) { + return emptyInteractionIdentityState(); + } + + const explicitPreferred = normalizedEvidence + .filter( + (entry) => + entry.stance === "prefer" && + (entry.source === "explicit_self_statement" || + entry.source === "explicit_correction"), + ) + .sort((left, right) => right.timestamp.localeCompare(left.timestamp)); + + if (explicitPreferred.length > 0) { + const winning = explicitPreferred[0]!; + + return { + pronounPolicy: "explicit", + resolvedPronounSet: 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, + }; + } + + const weightedScores = new Map(); + + for (const entry of normalizedEvidence) { + const weight = weightPronounEvidence(entry); + const signedWeight = entry.stance === "prefer" ? weight : -weight; + weightedScores.set( + entry.pronounSet, + (weightedScores.get(entry.pronounSet) ?? 0) + signedWeight, + ); + } + + const ranked = [...weightedScores.entries()].sort((left, right) => right[1] - left[1]); + const winner = ranked[0]; + const runnerUp = ranked[1]; + + if (!winner || winner[1] <= 0) { + return { + pronounPolicy: "unknown", + 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, + }; + } + + if (runnerUp && Math.abs(winner[1] - runnerUp[1]) < 1) { + return { + pronounPolicy: "conflicted", + 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, + }; + } + + const confidence = Math.min(1, winner[1] / 6); + + return { + pronounPolicy: "inferred", + resolvedPronounSet: 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, + }; +} + +function dedupePronounEvidence(evidence: PronounEvidence[]): PronounEvidence[] { + const deduped = new Map(); + + for (const entry of evidence) { + const key = [ + entry.pronounSet, + entry.source, + entry.stance, + entry.excerpt.trim().toLowerCase(), + ].join("::"); + const existing = deduped.get(key); + + if (!existing || existing.timestamp < entry.timestamp) { + deduped.set(key, entry); + } + } + + return [...deduped.values()] + .sort((left, right) => right.timestamp.localeCompare(left.timestamp)) + .slice(0, MAX_PRONOUN_EVIDENCE); +} + +function weightPronounEvidence(entry: PronounEvidence): number { + const base = + entry.source === "explicit_self_statement" || entry.source === "explicit_correction" + ? 6 + : entry.source === "direct_third_party_statement" + ? 4 + : entry.source === "contextual_relational_inference" + ? 2 + : 1; + + return base * clamp(entry.confidence, 0, 1); +} diff --git a/packages/core/src/interaction-memory.ts b/packages/core/src/interaction-memory.ts index e3f14be..9f24cf9 100644 --- a/packages/core/src/interaction-memory.ts +++ b/packages/core/src/interaction-memory.ts @@ -1,12 +1,17 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; -import { type InteractionMemoryProfile } from "@voidbot/shared"; +import { + type InteractionMemoryProfile, + type PronounEvidence, +} from "@voidbot/shared"; import { buildInteractionMemoryEvent, emptyInteractionProfile, + mergePronounEvidenceIntoIdentityState, MAX_RECENT_INTERACTION_EVENTS, + normalizeInteractionIdentityState, normalizeInteractionEvent, type RecordInteractionInput, shouldPersistInteractionEvent, @@ -21,6 +26,11 @@ interface InteractionMemoryStore { export interface InteractionMemoryBank { getProfile(actorId: string): Promise; recordInteraction(input: RecordInteractionInput): Promise; + recordPronounEvidence( + actorId: string, + actorName: string, + evidence: PronounEvidence[], + ): Promise; } export type { RecordInteractionInput } from "./interaction-memory-logic"; @@ -56,8 +66,18 @@ export class FileInteractionMemoryBank implements InteractionMemoryBank { ); const nextEvent = buildInteractionMemoryEvent(input, priorEvents); const nextProfile = shouldPersistInteractionEvent(nextEvent) - ? summarizeInteractionProfile(input.actorId, input.actorName, [...priorEvents, nextEvent]) - : summarizeInteractionProfile(input.actorId, input.actorName, priorEvents); + ? summarizeInteractionProfile( + input.actorId, + input.actorName, + [...priorEvents, nextEvent], + existingProfile, + ) + : summarizeInteractionProfile( + input.actorId, + input.actorName, + priorEvents, + existingProfile, + ); if (nextProfile.totalInteractions === 0) { if (existingProfileIndex !== -1) { @@ -74,6 +94,42 @@ export class FileInteractionMemoryBank implements InteractionMemoryBank { }); } + public async recordPronounEvidence( + actorId: string, + actorName: string, + evidence: PronounEvidence[], + ): Promise { + if (evidence.length === 0) { + return this.getProfile(actorId); + } + + return this.serialize(async () => { + const store = await this.readUnlocked(); + const existingProfileIndex = store.profiles.findIndex( + (candidate) => candidate.actorId === actorId, + ); + + if (existingProfileIndex === -1) { + return undefined; + } + + const existingProfile = store.profiles[existingProfileIndex]!; + const mergedIdentityState = mergePronounEvidenceIntoIdentityState( + existingProfile, + evidence, + ); + const nextProfile = summarizeInteractionProfile( + actorId, + actorName, + existingProfile.recentEvents, + mergedIdentityState, + ); + store.profiles[existingProfileIndex] = nextProfile; + await this.writeUnlocked(store); + return structuredClone(nextProfile); + }); + } + public async listProfiles(): Promise { await this.writeChain; const store = await this.readUnlocked(); @@ -125,6 +181,7 @@ function normalizeStore(store: InteractionMemoryStore): InteractionMemoryStore { (profile.recentEvents ?? []) .map((event) => normalizeInteractionEvent(event)) .slice(-MAX_RECENT_INTERACTION_EVENTS), + normalizeInteractionIdentityState(profile), )) .filter((profile) => profile.totalInteractions > 0), }; diff --git a/packages/core/src/situational-social-read.ts b/packages/core/src/situational-social-read.ts index 6641463..d2c98f0 100644 --- a/packages/core/src/situational-social-read.ts +++ b/packages/core/src/situational-social-read.ts @@ -1,6 +1,10 @@ import { type Actor, type InteractionMemoryProfile, + type PronounEvidence, + type PronounEvidenceSource, + type PronounEvidenceStance, + type PronounSet, type SituationalSocialRead, type SourceMessage, } from "@voidbot/shared"; @@ -143,6 +147,11 @@ 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.', + '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.", ].join("\n"); } @@ -181,6 +190,7 @@ function buildUserPrompt( interactionMemory, "", "Infer a private situational social read for this one reply.", + "If you emit pronounEvidence, it must be about the current speaker rather than unrelated third parties.", "Ground it in the visible room context and the current prompt.", "Do not output anything except the requested JSON object.", ].join("\n"); @@ -203,6 +213,7 @@ function parseSituationalSocialRead( const socialFrame = normalizeModelText(parsed.socialFrame); const responseGuidance = normalizeModelText(parsed.responseGuidance); const supportingSignals = normalizeStringArray(parsed.supportingSignals); + const pronounEvidence = normalizePronounEvidenceArray(parsed.pronounEvidence); if ( !summary || @@ -221,6 +232,7 @@ function parseSituationalSocialRead( socialFrame, responseGuidance, supportingSignals, + pronounEvidence, }; } catch { return undefined; @@ -237,6 +249,77 @@ function normalizeStringArray(value: unknown): string[] { .filter((entry): entry is string => Boolean(entry)); } +function normalizePronounEvidenceArray(value: unknown): PronounEvidence[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((entry) => normalizePronounEvidence(entry)) + .filter((entry): entry is PronounEvidence => entry !== undefined); +} + +function normalizePronounEvidence(value: unknown): PronounEvidence | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + const record = value as Record; + const pronounSet = normalizePronounSet(record.pronounSet); + const source = normalizePronounEvidenceSource(record.source); + const stance = normalizePronounEvidenceStance(record.stance); + const confidence = normalizeConfidence(record.confidence); + const excerpt = normalizeModelText(record.excerpt); + + if (!pronounSet || !source || !stance || confidence === undefined || !excerpt) { + return undefined; + } + + return { + pronounSet, + source, + stance, + confidence, + excerpt, + timestamp: new Date().toISOString(), + }; +} + +function normalizePronounSet(value: unknown): PronounSet | undefined { + return value === "they/them" || value === "he/him" || value === "she/her" + ? value + : undefined; +} + +function normalizePronounEvidenceSource( + value: unknown, +): PronounEvidenceSource | undefined { + switch (value) { + case "explicit_self_statement": + case "explicit_correction": + case "direct_third_party_statement": + case "contextual_relational_inference": + case "ambient_usage": + return value; + default: + return undefined; + } +} + +function normalizePronounEvidenceStance( + value: unknown, +): PronounEvidenceStance | undefined { + return value === "prefer" || value === "avoid" ? value : undefined; +} + +function normalizeConfidence(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + + return Math.min(1, Math.max(0, value)); +} + function unwrapStructuredFence(input: string): string { const trimmed = input.trim(); const match = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed); diff --git a/packages/core/src/state-storage-postgres-bootstrap.ts b/packages/core/src/state-storage-postgres-bootstrap.ts index 2ca7f65..0b6eb91 100644 --- a/packages/core/src/state-storage-postgres-bootstrap.ts +++ b/packages/core/src/state-storage-postgres-bootstrap.ts @@ -14,6 +14,7 @@ import { upsertJob, } from "./state-storage-postgres-job-queue"; import { + upsertInteractionIdentityState, upsertInteractionMemoryEvent, } from "./state-storage-postgres-interaction-memory"; import { type StateStorageConfig } from "./state-storage-types"; @@ -67,6 +68,20 @@ export async function migrateLegacyFileStateIfNeeded( await upsertInteractionMemoryEvent(client, event); } + for (const profile of profiles) { + if (profile.pronounEvidence.length === 0) { + continue; + } + + await upsertInteractionIdentityState(client, profile.actorId, { + pronounPolicy: profile.pronounPolicy, + resolvedPronounSet: profile.resolvedPronounSet, + pronounConfidence: profile.pronounConfidence, + pronounGuidance: profile.pronounGuidance, + pronounEvidence: profile.pronounEvidence, + }); + } + await client.query( "insert into state_migrations (name) values ($1) on conflict (name) do nothing", [LEGACY_FILE_STATE_IMPORT], diff --git a/packages/core/src/state-storage-postgres-interaction-memory.ts b/packages/core/src/state-storage-postgres-interaction-memory.ts index 69eddce..d3a8199 100644 --- a/packages/core/src/state-storage-postgres-interaction-memory.ts +++ b/packages/core/src/state-storage-postgres-interaction-memory.ts @@ -3,14 +3,18 @@ import { type Pool, type PoolClient } from "pg"; import { type InteractionMemoryEvent, type InteractionMemoryProfile, + type PronounEvidence, } from "@voidbot/shared"; import { buildInteractionMemoryEvent, + mergePronounEvidenceIntoIdentityState, + normalizeInteractionIdentityState, type RecordInteractionInput, shouldPersistInteractionEvent, summarizeInteractionProfile, } from "./interaction-memory-logic"; +import { type InteractionIdentityState } from "./interaction-memory-profile"; import { type InteractionMemoryBank } from "./interaction-memory"; export class PostgresInteractionMemoryBank implements InteractionMemoryBank { @@ -19,6 +23,7 @@ export class PostgresInteractionMemoryBank implements InteractionMemoryBank { public async getProfile( actorId: string, ): Promise { + const identityState = await this.getIdentityState(actorId); const events = await this.listActorEvents(actorId); if (events.length === 0) { @@ -29,6 +34,7 @@ export class PostgresInteractionMemoryBank implements InteractionMemoryBank { actorId, events[events.length - 1].actorName, events, + identityState, ); return profile.totalInteractions > 0 ? profile : undefined; @@ -37,6 +43,7 @@ export class PostgresInteractionMemoryBank implements InteractionMemoryBank { public async recordInteraction( input: RecordInteractionInput, ): Promise { + const identityState = await this.getIdentityState(input.actorId); const existingEvents = await this.listActorEvents(input.actorId); const priorEvents = existingEvents.filter((event) => event.id !== input.eventId); const event = buildInteractionMemoryEvent(input, priorEvents); @@ -46,12 +53,52 @@ export class PostgresInteractionMemoryBank implements InteractionMemoryBank { await this.pool.query("delete from interaction_memory_events where id = $1", [input.eventId]); } - return summarizeInteractionProfile(input.actorId, input.actorName, priorEvents); + return summarizeInteractionProfile( + input.actorId, + input.actorName, + priorEvents, + identityState, + ); } await upsertInteractionMemoryEvent(this.pool, event); - return summarizeInteractionProfile(input.actorId, input.actorName, [...priorEvents, event]); + return summarizeInteractionProfile( + input.actorId, + input.actorName, + [...priorEvents, event], + identityState, + ); + } + + public async recordPronounEvidence( + actorId: string, + actorName: string, + evidence: PronounEvidence[], + ): Promise { + if (evidence.length === 0) { + return this.getProfile(actorId); + } + + const existingEvents = await this.listActorEvents(actorId); + + if (existingEvents.length === 0) { + return undefined; + } + + const mergedIdentityState = mergePronounEvidenceIntoIdentityState( + await this.getIdentityState(actorId), + evidence, + ); + + await upsertInteractionIdentityState(this.pool, actorId, mergedIdentityState); + + return summarizeInteractionProfile( + actorId, + actorName, + existingEvents, + mergedIdentityState, + ); } private async listActorEvents(actorId: string): Promise { @@ -67,6 +114,25 @@ export class PostgresInteractionMemoryBank implements InteractionMemoryBank { .map((row) => deserializeJson(row.event_json)) .filter((event): event is InteractionMemoryEvent => event !== undefined); } + + private async getIdentityState( + actorId: string, + ): Promise { + const result = await this.pool.query<{ profile_json: unknown }>( + `select profile_json + from interaction_identity_profiles + where actor_id = $1`, + [actorId], + ); + + if (!result.rows[0]) { + return undefined; + } + + return normalizeInteractionIdentityState( + deserializeJson(result.rows[0].profile_json), + ); + } } export async function upsertInteractionMemoryEvent( @@ -129,6 +195,36 @@ export async function upsertInteractionMemoryEvent( ); } +export async function upsertInteractionIdentityState( + client: Pool | PoolClient, + actorId: string, + state: InteractionIdentityState, +): Promise { + await client.query( + `insert into interaction_identity_profiles ( + actor_id, + pronoun_policy, + resolved_pronoun_set, + pronoun_confidence, + profile_json, + updated_at + ) values ($1, $2, $3, $4, $5::jsonb, now()) + on conflict (actor_id) do update + set 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, + state.pronounPolicy, + state.resolvedPronounSet ?? null, + state.pronounConfidence ?? null, + JSON.stringify(state), + ], + ); +} + function deserializeJson(value: unknown): T | undefined { if (value === undefined || value === null) { return undefined; diff --git a/packages/providers/src/local-llm-render.ts b/packages/providers/src/local-llm-render.ts index 0ed0084..dbbf750 100644 --- a/packages/providers/src/local-llm-render.ts +++ b/packages/providers/src/local-llm-render.ts @@ -125,6 +125,7 @@ function renderInteractionMemory(context: ContextBundle): string { `- Relationship summary: ${context.interactionMemory.summary}`, `- Current stance: ${context.interactionMemory.disposition}; affinity=${context.interactionMemory.affinityScore}`, `- Private response guidance (do not reveal): ${context.interactionMemory.responseGuidance}`, + `- Private pronoun guidance (do not reveal): ${context.interactionMemory.pronounGuidance}`, "- Specific remembered incidents:", recentEvents, ].join("\n"); @@ -141,6 +142,14 @@ function renderSituationalSocialRead(context: ContextBundle): string { `- Speaker current read: ${context.situationalSocialRead.speakerCurrentRead}`, `- Social frame: ${context.situationalSocialRead.socialFrame}`, `- Private response guidance (do not reveal): ${context.situationalSocialRead.responseGuidance}`, + context.situationalSocialRead.pronounEvidence.length > 0 + ? `- Pronoun cues observed for the current speaker: ${context.situationalSocialRead.pronounEvidence + .map( + (entry) => + `${entry.stance} ${entry.pronounSet} via ${entry.source} (${Math.round(entry.confidence * 100)}%)`, + ) + .join("; ")}` + : "- Pronoun cues observed for the current speaker: none worth trusting yet.", "- Supporting signals:", ...context.situationalSocialRead.supportingSignals.map((signal) => ` ${signal}`), ].join("\n"); diff --git a/packages/providers/src/owner-codex-shared.ts b/packages/providers/src/owner-codex-shared.ts index b23557c..97f2dcd 100644 --- a/packages/providers/src/owner-codex-shared.ts +++ b/packages/providers/src/owner-codex-shared.ts @@ -453,6 +453,7 @@ export function renderInteractionMemory(context: ContextBundle): string { `- Relationship summary: ${context.interactionMemory.summary}`, `- Current stance: ${context.interactionMemory.disposition}; affinity=${context.interactionMemory.affinityScore}`, `- Private response guidance (do not reveal): ${context.interactionMemory.responseGuidance}`, + `- Private pronoun guidance (do not reveal): ${context.interactionMemory.pronounGuidance}`, "- Specific remembered incidents:", recentEvents, ].join("\n"); @@ -469,6 +470,14 @@ export function renderSituationalSocialRead(context: ContextBundle): string { `- Speaker current read: ${context.situationalSocialRead.speakerCurrentRead}`, `- Social frame: ${context.situationalSocialRead.socialFrame}`, `- Private response guidance (do not reveal): ${context.situationalSocialRead.responseGuidance}`, + context.situationalSocialRead.pronounEvidence.length > 0 + ? `- Pronoun cues observed for the current speaker: ${context.situationalSocialRead.pronounEvidence + .map( + (entry) => + `${entry.stance} ${entry.pronounSet} via ${entry.source} (${Math.round(entry.confidence * 100)}%)`, + ) + .join("; ")}` + : "- Pronoun cues observed for the current speaker: none worth trusting yet.", "- Supporting signals:", ...context.situationalSocialRead.supportingSignals.map((signal) => ` ${signal}`), ].join("\n"); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9b6aa0b..9d0d48c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -158,6 +158,33 @@ export interface InteractionMemoryDimension { summary: string; } +export const pronounSets = ["they/them", "he/him", "she/her"] as const; +export type PronounSet = (typeof pronounSets)[number]; + +export const pronounEvidenceSources = [ + "explicit_self_statement", + "explicit_correction", + "direct_third_party_statement", + "contextual_relational_inference", + "ambient_usage", +] as const; +export type PronounEvidenceSource = (typeof pronounEvidenceSources)[number]; + +export const pronounEvidenceStances = ["prefer", "avoid"] as const; +export type PronounEvidenceStance = (typeof pronounEvidenceStances)[number]; + +export const pronounPolicies = ["explicit", "inferred", "conflicted", "unknown"] as const; +export type PronounPolicy = (typeof pronounPolicies)[number]; + +export interface PronounEvidence { + pronounSet: PronounSet; + source: PronounEvidenceSource; + stance: PronounEvidenceStance; + confidence: number; + excerpt: string; + timestamp: string; +} + export interface InteractionMemoryProfile { actorId: string; actorName: string; @@ -174,6 +201,11 @@ export interface InteractionMemoryProfile { inferredTraits: string[]; interactionDimensions: InteractionMemoryDimension[]; responseGuidance: string; + pronounPolicy: PronounPolicy; + resolvedPronounSet?: PronounSet; + pronounConfidence?: number; + pronounGuidance: string; + pronounEvidence: PronounEvidence[]; lastInteractionAt?: string; recentEvents: InteractionMemoryEvent[]; } @@ -191,6 +223,7 @@ export interface SituationalSocialRead { socialFrame: string; responseGuidance: string; supportingSignals: string[]; + pronounEvidence: PronounEvidence[]; } export interface ContextBundle { diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 8c1c5e6..56181ab 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -25,3 +25,4 @@ {"ts":"2026-05-02T19:11:44Z","type":"bugfix","status":"ok","note":"Public local replies were too free to surface internal user-analysis language and too vulnerable to prompt-echo history results; interaction memory scaffolding is now slimmer/private and local history tool results filter echoed current asks before the model sees them."} {"ts":"2026-05-02T20:25:00+01:00","type":"architecture","status":"ok","note":"Ephemeral room-reading now comes from a separate `think=false` local Ollama sidecar pass over recent room context instead of hand-built phrase heuristics, and the inferred reads are stored as audit events so they can later be aggregated into richer profiles without immediately hardening every passing vibe into durable identity truth."} {"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."}