Move typed Persona memory rendering into projector

This commit is contained in:
Meta Vens 2026-07-16 01:11:22 +01:00
commit 85c8da9d71
7 changed files with 103 additions and 202 deletions

View file

@ -1,4 +1,62 @@
import { loadPromptTemplate } from "@voidbot/shared";
import type { VoidSelfStateTypedProjection } from "@voidbot/core";
export interface PersonaTypedStateSections {
opening: string[];
needs: string[];
bonds: string[];
statusMoodMemory: string[];
}
export function renderPersonaTypedStateSections(input: {
identityName: string;
state: VoidSelfStateTypedProjection;
}): PersonaTypedStateSections {
const { identityName: name, state } = input;
const opening: string[] = [];
const selfTexture = [
...state.selfProfile.privateNotes.map(projectPrivateNote),
...[...state.selfProfile.values].sort((left, right) => right.priority - left.priority).map((value) => value.summary || value.label),
].map(cleanSentence).filter(Boolean).slice(0, 18);
if (selfTexture.length > 0) opening.push(`Right now, ${name} is carrying this close to the skin: ${joinNarrative(selfTexture)}.`);
const activation = renderActivation(state.selfProfile.activationProfile);
if (activation) opening.push(activation);
const runtime = renderRuntime(name, state);
if (runtime) opening.push(runtime);
const needs = [...state.faceAffect.needs].filter((entry) => entry.status !== "retired").sort(sortAffect);
const bonds = [...state.faceAffect.socialBonds].filter((entry) => entry.status !== "retired").sort(sortAffect);
const statusReads = [...state.faceAffect.statusReads].filter((entry) => !entry.retiredAt).sort(sortAffect);
const mood = [...state.faceAffect.moodDimensions].sort((left, right) => right.value - left.value);
const durable = [...state.thoughtMemory.memories].filter((entry) => !entry.retiredAt).slice(-12).reverse();
const shortTerm = [...state.thoughtMemory.shortTerm].filter((entry) => !entry.retiredAt).slice(-12).reverse();
return {
opening,
needs: needs.length === 0 ? [] : [[`${name}'s explicit needs and frictions:`, ...needs.map((need) => [
`- ${need.kind} need toward ${targetLabel(need.target)} [${need.status}, intensity ${need.intensity.toFixed(2)}, valence ${need.valence.toFixed(2)}]: ${sentence(need.summary)}`,
need.claim ? `Claim: ${sentence(need.claim)}` : need.question ? `Question: ${sentence(need.question)}` : "",
`Tension: ${sentence(need.tension)}`,
`Behavioral pull: ${sentence(need.actionImplication)}`,
].filter(Boolean).join(" "))].join("\n")],
bonds: bonds.length === 0 ? [] : [["The social map has teeth:", ...bonds.map((bond) => [
`- ${targetLabel(bond.target)} draws ${bond.stance} [${bond.status}, intensity ${bond.intensity.toFixed(2)}]: ${sentence(bond.summary)}`,
`Read: ${sentence(bond.claim)}`,
`Tension: ${sentence(bond.tension)}`,
`Behavioral pull: ${sentence(bond.actionImplication)}`,
].join(" "))].join("\n")],
statusMoodMemory: [
statusReads.length === 0 ? undefined : ["Status in the swarm is part of the weather:", ...statusReads.map((read) => [
`- Around ${targetLabel(read.target)}, ${name} feels ${read.status} [intensity ${read.intensity.toFixed(2)}]: ${sentence(read.summary)}`,
`Read: ${sentence(read.claim)}`,
`Tension: ${sentence(read.tension)}`,
`Behavioral pull: ${sentence(read.actionImplication)}`,
].join(" "))].join("\n"),
mood.length === 0 ? undefined : ["Mood dimensions currently bending the turn:", ...mood.map((dimension) => `- ${dimension.name}=${dimension.value.toFixed(2)}${dimension.source ? ` from ${cleanSentence(dimension.source)}` : ""}`)].join("\n"),
durable.length === 0 ? undefined : ["Durable memories that should still bias judgment:", ...durable.map((memory) => renderMemory(name, memory))].join("\n"),
shortTerm.length === 0 ? undefined : ["Short-term residue waiting to settle:", ...shortTerm.map((memory) => renderMemory(name, memory))].join("\n"),
].filter((section): section is string => Boolean(section)),
};
}
export async function projectPersonaMemorySurface(input: {
identityId: string;
@ -33,3 +91,34 @@ export function validatePersonaMemorySurface(surface: string): string {
}
return surface;
}
function sortAffect(left: { status?: string; intensity?: number }, right: { status?: string; intensity?: number }): number {
const rank = (status?: string): number => ["neglected", "ready_to_act"].includes(status ?? "") ? 0 : ["active", "challenged"].includes(status ?? "") ? 1 : status === "cooling" ? 2 : ["satisfied", "resolved"].includes(status ?? "") ? 3 : 4;
return rank(left.status) - rank(right.status) || (right.intensity ?? 0) - (left.intensity ?? 0);
}
function renderActivation(profile: VoidSelfStateTypedProjection["selfProfile"]["activationProfile"]): string | undefined {
const sections = Object.entries(profile).map(([section, values]) => {
const entries = Object.entries(values).sort(([, left], [, right]) => vectorWeight(right) - vectorWeight(left)).map(([key, value]) => `${key}=${vectorWeight(value).toFixed(2)}${vectorNote(value) ? ` (${cleanSentence(vectorNote(value))})` : ""}`);
return entries.length > 0 ? `- ${section}: ${entries.join("; ")}` : undefined;
}).filter((entry): entry is string => Boolean(entry));
return sections.length > 0 ? ["Activation profile that should color behavior:", ...sections].join("\n") : undefined;
}
function renderRuntime(name: string, state: VoidSelfStateTypedProjection): string {
const lines: string[] = [];
const sleep = state.scheduledRuntime.sleepCycle;
if (sleep.isNapping || sleep.activeDreamThemes.length > 0) lines.push(`${name}'s rest state: ${sleep.isNapping ? "currently in a sleep/low-output phase" : "awake but carrying dream residue"}${sleep.activeDreamThemes.length > 0 ? ` around ${joinNarrative(sleep.activeDreamThemes.map(cleanSentence))}` : ""}.`);
const speaking = state.scheduledRuntime.speakingPressure;
const parts = [`need to speak ${speaking.needToSpeak.toFixed(2)}`, typeof speaking.confessionPressure === "number" ? `confession ${speaking.confessionPressure.toFixed(2)}` : "", typeof speaking.noveltyPressure === "number" ? `novelty ${speaking.noveltyPressure.toFixed(2)}` : "", typeof speaking.recentSpeechDamping === "number" ? `recent-speech damping ${speaking.recentSpeechDamping.toFixed(2)}` : ""].filter(Boolean);
lines.push(`Speaking pressure: ${parts.join(", ")}. Treat this as appetite/restraint, not an order.`);
if (state.scheduledRuntime.lastRuns.length > 0) lines.push(`Recent internal passes: ${state.scheduledRuntime.lastRuns.slice(-4).map((run) => cleanSentence(run.summary)).join(" | ")}`);
return lines.join("\n");
}
function renderMemory(name: string, memory: VoidSelfStateTypedProjection["thoughtMemory"]["memories"][number]): string { return [`- ${memory.kind} about ${targetLabel(memory.target)}: ${sentence(memory.summary)}`, memory.claim ? `Claim: ${sentence(memory.claim)}` : "", memory.question ? `Question: ${sentence(memory.question)}` : "", memory.tension ? `Tension: ${sentence(memory.tension)}` : "", memory.actionImplication ? `Behavioral pull for ${name}: ${sentence(memory.actionImplication)}` : ""].filter(Boolean).join(" "); }
function targetLabel(target: { label?: string; id?: string; kind?: string } | undefined): string { return target?.label ?? target?.id ?? target?.kind ?? "an unnamed target"; }
function vectorWeight(value: unknown): number { if (!record(value)) return 0; const candidate = [value.weight, value.current_activation, value.currentActivation, value.mean].find((entry) => typeof entry === "number" && Number.isFinite(entry)); return typeof candidate === "number" ? candidate : 0; }
function vectorNote(value: unknown): string | undefined { return record(value) && typeof value.note === "string" ? value.note : undefined; }
function projectPrivateNote(note: string): string { return note.replace(/\bdo not prompt (?:her|him|them|it|[A-Z][A-Za-z0-9_-]*) as\b/gi, "she refuses to be treated as").replace(/\bdo not prompt\b/gi, "do not treat").replace(/\bprompt (?:her|him|them|it)\b/gi, "treat them").replace(/\bprompt [A-Z][A-Za-z0-9_-]*\b/g, "treat them"); }
function cleanSentence(value: string | undefined): string { return (value ?? "").replace(/\s*\|\s*/g, " ").replace(/\bFace of\s+[A-Za-z0-9_-]+\b/gi, "").replace(/\bgrants:\s*[^.]+/gi, "").replace(/\bjurisdictions:\s*[^.]+/gi, "").replace(/\brepo=[^\s]+/gi, "").replace(/\bpath=[^\s]+/gi, "").replace(/\bvoid\.face_[A-Za-z0-9_.-]+/gi, "").replace(/\s{2,}/g, " ").trim().replace(/[.;:,]+$/g, ""); }
function sentence(value: string | undefined): string { const cleaned = cleanSentence(value); return !cleaned ? "" : /[.!?]$/.test(cleaned) ? cleaned : `${cleaned}.`; }
function joinNarrative(items: string[]): string { return items.length <= 1 ? items[0] ?? "" : items.length === 2 ? `${items[0]}, and ${items[1]}` : `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`; }
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }