Index canonical Persona memory semantically

This commit is contained in:
Meta Vens 2026-07-16 03:30:09 +01:00
commit a967005dca
2 changed files with 48 additions and 11 deletions

View file

@ -1,5 +1,5 @@
import type { loadConfig } from "@voidbot/config";
import type { RepoDiscordIdentity, VoidSelfStateTypedProjection } from "@voidbot/core";
import type { GamecultPersonaState, RepoDiscordIdentity, VoidSelfStateTypedProjection } from "@voidbot/core";
import { createTextEmbedder, createVectorStores, normalizeText, RetrievalService } from "@voidbot/rag";
import type { EmbeddingChunk, SourceMessage } from "@voidbot/shared";
@ -27,21 +27,15 @@ export async function readPersonaMemoryRecall(input: {
channelSnapshots: ChannelSnapshot[];
observedAt?: Date;
}): Promise<PersonaMemoryRecallObservation> {
if (input.state?.status === "ok" && input.state.stateKind === "gamecult_persona") {
return { status: "unavailable", reason: "Semantic recall indexing for canonical gamecult.persona_state.v0 CultCache documents is not yet implemented." };
}
if (!input.state || input.state.status !== "ok") {
return { status: "unavailable", reason: input.state?.reason ?? "No typed Persona state observation was supplied." };
}
try {
const store = createPersonaMemoryVectorStore(input.config);
const chunks = buildPersonaMemoryChunks({
identity: input.identity,
statePath: input.state.statePath,
state: input.state.typedState,
projectedMemory: input.projectedMemory,
observedAt: input.observedAt ?? new Date(),
});
const common = { identity: input.identity, statePath: input.state.statePath, projectedMemory: input.projectedMemory, observedAt: input.observedAt ?? new Date() };
const chunks = input.state.stateKind === "gamecult_persona"
? buildGamecultPersonaMemoryChunks({ ...common, state: input.state.personaState })
: buildPersonaMemoryChunks({ ...common, state: input.state.typedState });
await store.deleteByFilters({ corpusKind: "persona_memory", identityId: input.identity.id });
await store.upsert(chunks);
const retrieval = new RetrievalService(store, store, store);
@ -61,6 +55,45 @@ export async function readPersonaMemoryRecall(input: {
}
}
export function buildGamecultPersonaMemoryChunks(input: {
identity: RepoDiscordIdentity;
statePath: string;
state: GamecultPersonaState;
projectedMemory: string;
observedAt: Date;
}): EmbeddingChunk[] {
const chunks: EmbeddingChunk[] = [];
const sourceId = `persona:${input.identity.id}`;
const push = (entry: { id: string; kind: string; target?: Record<string, unknown>; text: string; createdAt?: string; updatedAt?: string }): void => {
const body = collapseWhitespace(entry.text, 1800);
if (body.length < 24) return;
chunks.push({
id: `${sourceId}:${entry.id}`,
sourceId,
sourceKind: "persona_memory",
text: body,
normalizedText: normalizeText(body),
metadata: {
corpusKind: "persona_memory", identityId: input.identity.id, personaName: input.identity.displayName,
sourceId, statePath: input.statePath, memoryKind: entry.kind,
targetKind: stringMetadata(entry.target?.kind) ?? "", targetId: stringMetadata(entry.target?.id) ?? "", targetLabel: stringMetadata(entry.target?.label) ?? "",
createdAt: entry.createdAt ?? "", updatedAt: entry.updatedAt ?? "",
},
});
};
push({ id: "projected-surface", kind: "projected_surface", target: { kind: "self", id: input.identity.id, label: input.identity.displayName }, text: input.projectedMemory, updatedAt: input.observedAt.toISOString() });
for (const value of input.state.values) push({ id: `value:${value.id}`, kind: "value", target: { kind: "self", id: input.identity.id, label: input.identity.displayName }, text: joinFields(value.label, value.summary) });
for (const memory of [...input.state.thoughtMemory.memories, ...input.state.thoughtMemory.shortTerm]) if (memory.status !== "retired") push({ id: `memory:${memory.id}`, kind: `memory:${memory.status}`, target: memory.target, text: joinFields(memory.summary, memory.claim, memory.question, memory.tension, memory.actionImplication), createdAt: memory.createdAt, updatedAt: memory.updatedAt });
for (const thread of input.state.thoughtMemory.incubation) if (thread.status !== "retired") push({ id: `incubation:${thread.id}`, kind: `incubation:${thread.status}`, target: thread.target, text: joinFields(thread.summary, thread.claim, thread.question, thread.tension, thread.actionImplication), createdAt: thread.createdAt, updatedAt: thread.updatedAt });
for (const pressure of input.state.agencyPressure.pressures) if (pressure.status !== "retired") push({ id: `agency:${pressure.id}`, kind: `agency:${pressure.status}`, target: pressure.target, text: joinFields(pressure.summary, pressure.claim, pressure.question, pressure.tension, pressure.actionImplication), createdAt: pressure.createdAt, updatedAt: pressure.updatedAt });
for (const need of input.state.affect.needs) if (need.status !== "retired") push({ id: `need:${need.id}`, kind: `need:${need.status}`, target: need.target, text: joinFields(need.summary, need.claim, need.question, need.tension, need.actionImplication), createdAt: need.createdAt, updatedAt: need.updatedAt });
for (const bond of input.state.affect.socialBonds) if (bond.status !== "retired") push({ id: `bond:${bond.id}`, kind: `bond:${bond.relationshipKind}`, target: bond.object, text: joinFields(bond.summary, `Trust ${bond.trust}; tension ${bond.tension}.`), updatedAt: bond.updatedAt });
for (const read of input.state.affect.statusReads) if (read.status !== "retired") push({ id: `status:${read.id}`, kind: `status:${read.statusKind}`, target: read.target, text: joinFields(read.summary, `Confidence ${read.confidence}.`), updatedAt: read.updatedAt });
for (const stance of input.state.affect.doctrineStances) if (stance.status !== "retired") push({ id: `doctrine:${stance.id}`, kind: `doctrine:${stance.stanceKind}`, target: stance.target, text: joinFields(stance.principle, stance.summary, stance.actionImplication), updatedAt: stance.updatedAt });
for (const action of input.state.candidateActions?.actions ?? []) if (action.status !== "retired") push({ id: `action:${action.id}`, kind: `action:${action.actionType}:${action.readiness}`, target: action.target, text: joinFields(action.summary, action.rationale), createdAt: action.createdAt, updatedAt: action.updatedAt });
return chunks;
}
function createPersonaMemoryVectorStore(config: ReturnType<typeof loadConfig>) {
const embedder = createTextEmbedder({
backend: config.ragEmbeddingBackend,

View file

@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
import { loadGamecultPersonaState, migrateCanonicalPortablePersonaState } from "@voidbot/core";
import { projectPortablePersonaState } from "../apps/persona-scheduler/dist/persona-portable-state-projector.js";
import { buildGamecultPersonaMemoryChunks } from "../apps/persona-scheduler/dist/persona-memory-context-source.js";
import { readPersonaStateObservation } from "../apps/persona-scheduler/dist/persona-state-source.js";
void main().catch((error) => { console.error(error); process.exitCode = 1; });
@ -25,6 +26,9 @@ async function main(): Promise<void> {
if (observation.status !== "ok" || observation.stateKind !== "gamecult_persona") throw new Error("Expected canonical standard Persona state.");
const projection = projectPortablePersonaState(identity, { status: "ok", statePath: targetPath, state: observation.personaState, schemaVersion: "gamecult.persona_state.v0" });
assert.match(projection, /canonical gamecult\.persona_state\.v0 state loaded from typed CultCache/);
const chunks = buildGamecultPersonaMemoryChunks({ identity, statePath: targetPath, state: observation.personaState, projectedMemory: projection, observedAt: new Date("2026-07-16T00:00:00Z") });
assert.ok(chunks.some((chunk) => chunk.id.endsWith(":memory:tengu-stress-wrathful-stillness-test")), "canonical standard memories enter semantic recall indexing");
assert.ok(chunks.some((chunk) => chunk.id.endsWith(":doctrine:tengu-stance-user-agency")), "canonical doctrine enters semantic recall indexing");
await assert.rejects(migrateCanonicalPortablePersonaState({ sourcePath: resolve(".voidbot/private/personas/muninn/muninn.persona_state.v0.json"), targetPath: join(directory, "muninn.cc") }), /Refusing to promote projection/);
await assert.rejects(migrateCanonicalPortablePersonaState({ sourcePath, targetPath }), /Refusing to overwrite existing Persona state target/);
console.log("Portable Persona migration smoke passed.");