Persist explicit pronoun self-statements

This commit is contained in:
Meta Vens 2026-05-02 21:33:54 +01:00
commit 9ce17ac650
10 changed files with 279 additions and 42 deletions

View file

@ -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<void>
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;
}

View file

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

View file

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

View file

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

View file

@ -24,6 +24,8 @@ export const SIGNIFICANT_INTERACTION_TAGS = new Set<string>([
"suspicion",
"rigidity",
"withdrawal",
"pronoun_preference",
"pronoun_correction",
]);
export const SUMMARY_NOTABLE_TAGS = new Set<string>([
@ -46,6 +48,8 @@ export const SUMMARY_NOTABLE_TAGS = new Set<string>([
"suspicion",
"rigidity",
"withdrawal",
"pronoun_preference",
"pronoun_correction",
]);
export const PROMPT_SIGNATURE_STOPWORDS = new Set([

View file

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

View file

@ -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<string, PronounEvidence>();
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)}...`;
}

View file

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

View file

@ -23,11 +23,21 @@ export class PostgresInteractionMemoryBank implements InteractionMemoryBank {
public async getProfile(
actorId: string,
): Promise<InteractionMemoryProfile | undefined> {
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<InteractionMemoryProfile> {
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<InteractionMemoryEvent[]> {
@ -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<InteractionIdentityState | undefined> {
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<InteractionIdentityState>(result.rows[0].profile_json),
);
return {
actorName: row.actor_name,
state: normalizeInteractionIdentityState(
deserializeJson<InteractionIdentityState>(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<void> {
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,

View file

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