Persist pronoun evidence from room reads

This commit is contained in:
Meta Vens 2026-05-02 21:14:09 +01:00
commit abcde8e6ce
12 changed files with 489 additions and 6 deletions

View file

@ -216,7 +216,7 @@ export async function handlePrompt(options: PromptHandlerOptions): Promise<void>
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<void>
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,
},
});

View file

@ -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,

View file

@ -9,5 +9,8 @@ export {
export {
emptyInteractionProfile,
emptyInteractionIdentityState,
mergePronounEvidenceIntoIdentityState,
normalizeInteractionIdentityState,
summarizeInteractionProfile,
} from "./interaction-memory-profile";

View file

@ -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<InteractionIdentityState> | undefined,
): InteractionIdentityState {
if (!state) {
return emptyInteractionIdentityState();
}
return resolveInteractionIdentityState(state.pronounEvidence ?? []);
}
export function mergePronounEvidenceIntoIdentityState(
existingState: Partial<InteractionIdentityState> | 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<PronounSet, number>();
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<string, PronounEvidence>();
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);
}

View file

@ -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<InteractionMemoryProfile | undefined>;
recordInteraction(input: RecordInteractionInput): Promise<InteractionMemoryProfile>;
recordPronounEvidence(
actorId: string,
actorName: string,
evidence: PronounEvidence[],
): Promise<InteractionMemoryProfile | undefined>;
}
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<InteractionMemoryProfile | undefined> {
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<InteractionMemoryProfile[]> {
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),
};

View file

@ -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<string, unknown>;
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);

View file

@ -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],

View file

@ -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<InteractionMemoryProfile | undefined> {
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<InteractionMemoryProfile> {
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<InteractionMemoryProfile | undefined> {
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<InteractionMemoryEvent[]> {
@ -67,6 +114,25 @@ export class PostgresInteractionMemoryBank implements InteractionMemoryBank {
.map((row) => deserializeJson<InteractionMemoryEvent>(row.event_json))
.filter((event): event is InteractionMemoryEvent => event !== undefined);
}
private async getIdentityState(
actorId: string,
): Promise<InteractionIdentityState | undefined> {
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<InteractionIdentityState>(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<void> {
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<T>(value: unknown): T | undefined {
if (value === undefined || value === null) {
return undefined;

View file

@ -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");

View file

@ -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");

View file

@ -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 {

View file

@ -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."}