Isolate Persona semantic memory context

This commit is contained in:
Meta Vens 2026-07-16 00:50:20 +01:00
commit d55f25cd4d
7 changed files with 179 additions and 280 deletions

View file

@ -0,0 +1,134 @@
import type { loadConfig } from "@voidbot/config";
import type { RepoDiscordIdentity } from "@voidbot/core";
import { createTextEmbedder, createVectorStores, normalizeText, RetrievalService } from "@voidbot/rag";
import type { EmbeddingChunk, SourceMessage } from "@voidbot/shared";
import type { PersonaStateObservation } from "./persona-state-source.js";
import type { ChannelSnapshot } from "./turn-context-source.js";
export type PersonaMemoryRecallObservation =
| { status: "ok"; hits: PersonaMemoryRecallHit[] }
| { status: "unavailable"; reason: string };
export interface PersonaMemoryRecallHit {
score: number;
text: string;
memoryKind?: string;
targetId?: string;
targetLabel?: string;
}
export async function readPersonaMemoryRecall(input: {
identity: RepoDiscordIdentity;
config: ReturnType<typeof loadConfig>;
state: PersonaStateObservation | undefined;
projectedMemory: string;
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
observedAt?: Date;
}): Promise<PersonaMemoryRecallObservation> {
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(),
});
await store.deleteByFilters({ corpusKind: "persona_memory", identityId: input.identity.id });
await store.upsert(chunks);
const retrieval = new RetrievalService(store, store, store);
const results = await retrieval.searchPersonaMemory(buildRecallQuery(input), 12, { identityId: input.identity.id });
return {
status: "ok",
hits: results.map((result) => ({
score: result.score,
text: result.text,
memoryKind: stringMetadata(result.metadata.memoryKind),
targetId: stringMetadata(result.metadata.targetId),
targetLabel: stringMetadata(result.metadata.targetLabel),
})),
};
} catch (error) {
return { status: "unavailable", reason: error instanceof Error ? error.message : String(error) };
}
}
function createPersonaMemoryVectorStore(config: ReturnType<typeof loadConfig>) {
const embedder = createTextEmbedder({
backend: config.ragEmbeddingBackend,
hashDimensions: config.ragEmbeddingDimensions,
ollamaBaseUrl: config.ragOllamaBaseUrl,
ollamaModel: config.ragOllamaModel,
ollamaTimeoutMs: config.ragOllamaTimeoutMs,
queryInstruction: "Given a Face turn, retrieve relevant memories, bonds, needs, status reads, and doctrine from this Persona's own typed state.",
});
return createVectorStores({
kind: config.vectorStore.kind,
historyPath: config.vectorStore.path,
personaMemoryPath: config.vectorStore.personaMemoryPath,
sourceRoot: config.sourceVectorStoreRoot,
qdrant: config.qdrant,
historyEmbedder: embedder,
sourceEmbedder: embedder,
personaMemoryEmbedder: embedder,
}).personaMemory;
}
function buildPersonaMemoryChunks(input: {
identity: RepoDiscordIdentity;
statePath: string;
state: Extract<PersonaStateObservation, { status: "ok" }>['typedState'];
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 text = collapseWhitespace(entry.text, 1800);
if (text.length < 24) return;
chunks.push({
id: `${sourceId}:${entry.id}`,
sourceId,
sourceKind: "persona_memory",
text,
normalizedText: normalizeText(text),
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.selfProfile.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.retiredAt) push({ id: `memory:${memory.memoryId}`, kind: memory.kind, target: memory.target, text: joinFields(memory.summary, memory.claim, memory.question, memory.tension, memory.actionImplication), createdAt: memory.createdAt, updatedAt: memory.updatedAt });
for (const pressure of input.state.agencyPressure.pressures) if (pressure.status !== "retired") push({ id: `agency:${pressure.pressureId}`, kind: `agency:${pressure.kind}:${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.faceAffect.needs) if (need.status !== "retired") push({ id: `need:${need.needId}`, kind: `need:${need.kind}:${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.faceAffect.socialBonds) if (bond.status === "active") push({ id: `bond:${bond.bondId}`, kind: `bond:${bond.stance}`, target: bond.target, text: joinFields(bond.summary, bond.claim, bond.tension, bond.actionImplication), createdAt: bond.createdAt, updatedAt: bond.updatedAt });
for (const read of input.state.faceAffect.statusReads) if (!read.retiredAt) push({ id: `status:${read.readId}`, kind: `status:${read.status}`, target: read.target, text: joinFields(read.summary, read.claim, read.tension, read.actionImplication), createdAt: read.createdAt, updatedAt: read.updatedAt });
for (const stance of input.state.faceAffect.doctrineStances) if (stance.status !== "retired") push({ id: `doctrine:${stance.stanceId}`, kind: `doctrine:${stance.doctrine}:${stance.status}`, target: stance.target, text: joinFields(stance.summary, stance.claim, stance.question, stance.tension, stance.actionImplication), createdAt: stance.createdAt, updatedAt: stance.updatedAt });
return chunks;
}
function buildRecallQuery(input: { identity: RepoDiscordIdentity; projectedMemory: string; recentMessages: SourceMessage[]; channelSnapshots: ChannelSnapshot[] }): string {
const recent = input.recentMessages.slice(-8).map((message) => `${message.authorName}: ${collapseWhitespace(message.content, 360)}`).join("\n");
const nearby = input.channelSnapshots.flatMap((snapshot) => snapshot.messages.slice(-3).map((message) => `${snapshot.channelId} / ${message.authorName}: ${collapseWhitespace(message.content, 260)}`)).slice(-12).join("\n");
return [`${input.identity.displayName} current Face turn`, "Projected memory surface:", collapseWhitespace(input.projectedMemory, 2200), "Recent room:", recent, "Nearby room texture:", nearby].join("\n");
}
function joinFields(...fields: Array<string | undefined>): string { return fields.filter((field): field is string => Boolean(field?.trim())).join(" "); }
function collapseWhitespace(value: string, maxLength: number): string { return value.replace(/\s+/g, " ").trim().slice(0, maxLength); }
function stringMetadata(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; }

View file

@ -150,6 +150,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- Persona channel policy is isolated in `turn-routing.ts`. It owns pending-attention room preference, default/threshold fallback, snapshot neighborhoods, low-threshold topic projection, and bounded channel-speed projection; prompt/context code receives a route plan and does not choose rooms independently.
- Operator control acquisition is isolated in `control-source.ts`. It owns pause-witness parsing and typed CultMesh swarm-heat loading with fail-closed validation; it reports facts while engine commits alone apply heat or record skipped ticks.
- Persona Body/Mind acquisition is isolated in `persona-state-source.ts`. It resolves the registered `.cc` surface, loads typed state once for a scheduler pulse, and projects current sleep facts at an explicit wall-clock time. It cannot create a missing state file, update sleep cycles or speaking pressure, select work, or queue turns. The old heartbeat-shell physiology writer and its pulse-driven speaking-pressure heuristic were deleted; `initiative-engine.ts` remains the only owner of rest eligibility.
- Semantic Persona-memory recall is isolated in `persona-memory-context-source.ts`. It requires an existing `PersonaStateObservation`, rebuilds only that Persona's derived vector partition, searches it against current/nearby room evidence, and returns structured hits or an explicit unavailable result. It cannot reopen canonical `.cc` state, mutate Persona Mind, or render prompt doctrine. The heartbeat shell now only turns the typed observation into character-facing text.
- Home-repo activity acquisition is isolated in `repo-activity-source.ts`. It owns source-repo/cursor resolution and the bounded read-only exporter invocation, returning typed observations that prompt composition renders without subprocess or JSON-parsing authority.
- Deployment authority detail: Idunn executes `/srv/odin/deploy-manifests/voidbot`; `/usr/local/sbin/deploy-voidbot` is only the identical inspection/recovery copy. Install both from `gamecult-ops/scripts/deploy-voidbot.sh`. `/srv/build/VoidBot/.git` and `/srv/build/cultcache-ts/.git` must remain wholly `idunn`-owned because lazy partial-clone reads can fetch objects; root owns releases and service promotion, never build-repo Git state.
- Dynamic heat is live as derived semantic response pressure. `semantic-pressure-source.ts` batches canonical Face affinity cards and recent permitted-channel human-message embeddings through the existing RAG embedder, applies recency decay/noisy-OR aggregation, and returns bounded projections for at most three scheduler-eligible Faces. It does not write scheduler state. `initiative-engine.ts` alone commits inspectable evidence, `dynamicHeat`, one-time semantic interrupt receipts, and readiness changes. Direct mentions remain obligations, and model/Discord failures fail cool without bypassing rest, active jobs, or scheduler capacity.

View file

@ -182,7 +182,7 @@ VoidBot runs on Yggdrasil. Idunn owns deployment and consumes upstream repositor
- Repo Face proposal/article PRs, PR comments, registered-identity Discord posts, and r/GameCultOrg Reddit thread posts now cross through parent-owned action blocks before Bifrost bridge commands. Bylined essays use the Interpreter-owned `ARTICLE` block: the Face writes naturally, the Interpreter extracts structured title/description/author/date/tags/path/body fields, and deterministic worker code renders/validates YAML frontmatter before calling `BIFROST_ROOT/tools/bifrost-bridge.mjs` for a draft PR and persona announcement. Subreddit threads use the Interpreter-owned `REDDIT THREAD` block: the Face writes a public title/body/share line, and deterministic worker code calls Bifrost `reddit-post` with Persona flair. The old `VOIDBOT_REPO_IDENTITY_ARTICLE` JSON sentinel is cut. Proposal PR and PR-comment sentinels still exist as compatibility rails. This is not merely helper placement: Bifrost's original governance/labor platform role makes it the owner of governed public crossings for GameCult work, including GitHub, r/GameCultOrg Reddit transport/thread access, Discord-native work/member/swarm interactions, dispatch receipts, CultNet/CultCache intake, and future collaboration interfaces. Heimdall is the OAuth/account/capability gate; Bifrost is the bridge/request/routing/receipt authority. VoidBot still owns room/thread cognition, moderation judgment, archive/source retrieval, and Face personality.
- Repo Face turns now receive a Bifrost governance digest from `BIFROST_ROOT/tools/governance-threads.mjs digest --repo <repo> --agent <identity>` alongside recent Discord context. Bifrost typed CultCache topics/comments are the canonical home for feature requests, Face opinions, missing questions, approvals, and dispatch receipts; Discord is a mirror/evidence source. When `BIFROST_DISCORD_CHANNEL_ID` is configured for `#bifrost`, repo Face Discord context ignores mirrored bot chatter there because the authoritative activity arrives through the Bifrost digest. Human `#bifrost` messages should only become governance comments after Bifrost/Heimdall can link the Discord id to a registered actor. Repo-local asks that would send a human or Codex to add/fix/name/scaffold/document/test/investigate/implement belong on Bifrost with enough room/repo context for a claimed Codex turn; a Face may still speak characterfully in Aquarium, but not as the only dispatch surface. Legacy `UPDATE REQUEST` packets are parsed only as compatibility input and reconciled into Bifrost topics instead of immediate transport enqueue. Local `#bifrost` is channel `1506760343836430376`; work proposals, governance mirrors, and Bifrost dispatch receipts should go there, while Aquarium remains the casual/social swarm room.
- Repo character turns now have a state-projector plus turn-Interpreter shape. The state-in boundary is `prompts/repo-face-state-projector.prompt.md`: typed state and identity material are translated into lived character prose before the child sees it. The state projector must surface values, drives, wounds, fascinations, aesthetic taste, dignity, mood, needs, social reads, bonds, agency pressure, and the personality-specific meaning of sparse or dense social topology when the state contains those facts. The deterministic state packet feeding that projector is intentionally verbose and parent-facing: it exposes private notes, values, activation vectors, runtime rest/speaking pressure, affect needs, bonds, status reads, mood dimensions, durable memories, short-term residue, agency pressures, incubating thoughts, candidate speech pressure, recent speech residue, social graph facts, room weather, and semantic curiosity graph attractors so every included variable can appear directly or bend behavior. The curiosity graph is built by seeding history/source vector retrieval from current room context, nearby channels, typed Face state, and home-jurisdiction identity, then decoding retrieved chunks into weighted attractor clusters with prominence, saturation, novelty, density, jurisdiction fit, suggested motion, and evidence labels. The projector owns turning those neutral facts into lived curiosity, boredom, fatigue, territorial pull, underexplored intrigue, or a closing/pivot impulse; deterministic code must not decide the feeling. Deterministic code may also attach neutral graph facts such as mapped people and unmapped registered peers to the projector packet, but it must not decide whether that feels like anxiety, territorial scouting, or nothing. Deterministic code attaches recent read-only home-repo activity before chat, using the Face's own state path as the repo-activity cursor source, so current implementation motion is visible as body context. Current-room and nearby-channel logs no longer cross a model conversation projector; deterministic code attaches raw oldest-to-newest transcript evidence with explicit recency/correction rules so the Face can notice human reframes directly. Deterministic code also attaches a social-affordance surface that matches active typed person-bonds/status reads to recent raw messages from that person, so relationship state arrives as a live hook rather than private decorative lore. Deterministic code still owns full prompt assembly and inspection through `scripts/run-repo-face-heartbeats.ts --assemble-prompt <identity>`; that path composes stable doctrine, allowed rooms, research/archive capability guidance, Bifrost digest, projected memory/affect prose, home-repo activity, live social affordances, and raw conversation evidence into the exact child prompt. It must not invent personality, mood, affect, or social memory from typed state. Situational obligations such as direct-call priority or first-public-introduction duty are emitted only when the scheduler packet proves they apply, not as standing prompt law. The child character writes a natural in-character turn on full `gpt-5.4` and may describe desired speech, repo/article proposals, social reads, needs, mood shifts, or memory-worthy thoughts without emitting action DSL blocks. The cheaper Spark/Mini parent Interpreter decides route/retry/drop and translates warranted effects into structured `STATE NOTE` and `SAY` blocks for the worker while governance dispatch remains paused. `STATE NOTE` blocks become typed short-term memory, affect needs, social bonds, status reads, mood dimensions, or agency pressure. The worker still accepts legacy compact JSON sentinels and legacy `UPDATE REQUEST` blocks for compatibility, but the live prompt path keeps transport and state syntax out of the character's roleplay surface.
- `apps/persona-scheduler` is the resident daemon organ that owns the serialized Persona wall-clock pulse on Yggdrasil. It invokes the remaining turn orchestrator without overlap and remains alive between pulses. `apps/persona-scheduler/src/initiative-engine.ts` owns the pure scheduling state machine: participant reconciliation, deterministic base speed/bias/threshold derivation, operator heat/speed projection, semantic-pressure commits, wall-clock advancement, active-turn freeze/completion recovery, queue-success/failure/dry-run commits, stale rescheduling, pending-mention priority, rest/idle eligibility, and ready ordering. Explicit blocked/withdrawn status, active load, semantic receipts, and initiative position survive reconciliation; new Faces without a usable mouth fail closed. `apps/persona-scheduler/src/state-store.ts` owns scheduler persistence and legacy migration. It stores one typed `voidbot.persona_scheduler_state` record in `repo-face-heartbeats.cc`, migrates the former JSON document once with a `storage_migrated` witness, rejects malformed CultCache instead of replacing it, and writes JSON only as a derived dashboard/debug projection. `packages/core/src/repo-face-heartbeat-queue.ts` resolves both legacy `.json` configuration and current `.cc` configuration to the same canonical path; the Discord bot publishes one immutable `voidbot.persona_attention_command` CultCache inbox document per addressed message. The scheduler store ingests and deduplicates those commands, persists them as pending attention, then acknowledges inbox files only after the durable commit; a crash before acknowledgement replays safely. `apps/persona-scheduler/src/active-turn-source.ts` owns the Postgres/legacy-lock liveness adapter. It recognizes only Persona turn request IDs, applies the bounded stale threshold, marks dead queue claims failed, and returns active/stale witnesses; it never writes scheduler state. `apps/persona-scheduler/src/persona-state-source.ts` owns read-only typed Persona-state acquisition and present-time physiology projection. It reports typed mind plus rest facts without creating missing state or writing sleep/speaking-pressure state; the initiative engine alone applies rest eligibility. `apps/persona-scheduler/src/discord-activity-source.ts` owns neutral Discord activity acquisition: it deduplicates explicit watched channels, excludes bot traffic, reports raw human messages, and derives idle-cooling times from scheduler history without writing state or selecting work. `apps/persona-scheduler/src/semantic-pressure-source.ts` owns embedding-backed affinity observation and returns bounded projections for eligible Personas and permitted channels. It cannot mutate initiative; `initiative-engine.ts` alone applies pressure, dynamic heat, interrupt readiness, dedupe receipts, and transition history. `apps/persona-scheduler/src/turn-context-source.ts` owns current-room and nearby-channel Discord evidence plus the bounded image-attachment cache. It returns chronological source messages and explicit snapshot failure witnesses; it has no access to Persona state, prompt composition, or the job queue. `scripts/run-repo-face-heartbeats.ts` projects the identity registry into neutral participant specs and affinity candidates, invokes external queue actuators, and still owns Persona memory rendering/semantic recall plus prompt assembly pending further extraction. External adapters report facts; only the initiative engine mutates load, counters, recovery position, pressure, and queue-transition history. Repo Faces and native Personas queue ordinary `repo-face-rumination` jobs through the canonical job queue. Built-in Void participates only when its separate moderation heartbeat organ is enabled; the hosted scheduler does not launch Windows PowerShell.
- `apps/persona-scheduler` is the resident daemon organ that owns the serialized Persona wall-clock pulse on Yggdrasil. It invokes the remaining turn orchestrator without overlap and remains alive between pulses. `apps/persona-scheduler/src/initiative-engine.ts` owns the pure scheduling state machine: participant reconciliation, deterministic base speed/bias/threshold derivation, operator heat/speed projection, semantic-pressure commits, wall-clock advancement, active-turn freeze/completion recovery, queue-success/failure/dry-run commits, stale rescheduling, pending-mention priority, rest/idle eligibility, and ready ordering. Explicit blocked/withdrawn status, active load, semantic receipts, and initiative position survive reconciliation; new Faces without a usable mouth fail closed. `apps/persona-scheduler/src/state-store.ts` owns scheduler persistence and legacy migration. It stores one typed `voidbot.persona_scheduler_state` record in `repo-face-heartbeats.cc`, migrates the former JSON document once with a `storage_migrated` witness, rejects malformed CultCache instead of replacing it, and writes JSON only as a derived dashboard/debug projection. `packages/core/src/repo-face-heartbeat-queue.ts` resolves both legacy `.json` configuration and current `.cc` configuration to the same canonical path; the Discord bot publishes one immutable `voidbot.persona_attention_command` CultCache inbox document per addressed message. The scheduler store ingests and deduplicates those commands, persists them as pending attention, then acknowledges inbox files only after the durable commit; a crash before acknowledgement replays safely. `apps/persona-scheduler/src/active-turn-source.ts` owns the Postgres/legacy-lock liveness adapter. It recognizes only Persona turn request IDs, applies the bounded stale threshold, marks dead queue claims failed, and returns active/stale witnesses; it never writes scheduler state. `apps/persona-scheduler/src/persona-state-source.ts` owns read-only typed Persona-state acquisition and present-time physiology projection. It reports typed mind plus rest facts without creating missing state or writing sleep/speaking-pressure state; the initiative engine alone applies rest eligibility. `apps/persona-scheduler/src/persona-memory-context-source.ts` owns the derived vector refresh and semantic recall query for one supplied typed Mind observation. It returns typed hits/unavailability and cannot reacquire canonical state, render prompt prose, or queue work. `apps/persona-scheduler/src/discord-activity-source.ts` owns neutral Discord activity acquisition: it deduplicates explicit watched channels, excludes bot traffic, reports raw human messages, and derives idle-cooling times from scheduler history without writing state or selecting work. `apps/persona-scheduler/src/semantic-pressure-source.ts` owns embedding-backed affinity observation and returns bounded projections for eligible Personas and permitted channels. It cannot mutate initiative; `initiative-engine.ts` alone applies pressure, dynamic heat, interrupt readiness, dedupe receipts, and transition history. `apps/persona-scheduler/src/turn-context-source.ts` owns current-room and nearby-channel Discord evidence plus the bounded image-attachment cache. It returns chronological source messages and explicit snapshot failure witnesses; it has no access to Persona state, prompt composition, or the job queue. `scripts/run-repo-face-heartbeats.ts` projects the identity registry into neutral participant specs and affinity candidates, renders typed source observations, invokes external queue actuators, and still owns character-facing memory projection plus prompt assembly pending further extraction. External adapters report facts; only the initiative engine mutates load, counters, recovery position, pressure, and queue-transition history. Repo Faces and native Personas queue ordinary `repo-face-rumination` jobs through the canonical job queue. Built-in Void participates only when its separate moderation heartbeat organ is enabled; the hosted scheduler does not launch Windows PowerShell.
- `apps/persona-scheduler/src/bifrost-governance-source.ts` owns the remaining Bifrost governance digest subprocess contract. It supplies the CLI path, arguments, timeout, JSON parsing, and typed command/parse failure topics; Persona prompt composition receives a `BifrostGovernanceDigest` and cannot invoke Bifrost directly.
- The scheduler state has one write authority. `initiative-engine.ts` commits participant reconciliation, operator controls, pause/disabled skips, stale-turn recovery witnesses, semantic pressure, pending-attention consumption, queue outcomes, and tick finalization. `run-repo-face-heartbeats.ts` contains no direct participant/state assignment or history append; its sources and actuators return facts to engine commits.
- `apps/persona-scheduler/src/turn-actuator.ts` is the worker-queue output port. Given a completed prompt, output channel, evidence fields, provider, identity, timestamp, and narrow `createJob` capability, it builds the canonical worker actor/guild `ContextBundle`, creates one approved `repo-face-rumination` job, and returns `{created, activeJobId, requestMessageId}`. It cannot choose a Persona, mutate scheduler state, or compose prompt content. The orchestration shell no longer knows `ContextBuilder`, job approval defaults, worker command names, or `createJob` mechanics.

View file

@ -19,9 +19,8 @@ import {
type RepoDiscordIdentity,
type VoidSelfStateTypedProjection,
} from "@voidbot/core";
import { createTextEmbedder, createVectorStores, normalizeText, RetrievalService } from "@voidbot/rag";
import { createTextEmbedder, createVectorStores, RetrievalService } from "@voidbot/rag";
import {
type EmbeddingChunk,
loadPromptTemplate,
type InteractionMemoryProfile,
type PromptImageAttachment,
@ -59,6 +58,10 @@ import {
readPersonaStateObservations,
type PersonaStateObservation,
} from "../apps/persona-scheduler/dist/persona-state-source.js";
import {
readPersonaMemoryRecall,
type PersonaMemoryRecallObservation,
} from "../apps/persona-scheduler/dist/persona-memory-context-source.js";
import {
readDiscordActivitySnapshot,
type IdleCoolingSnapshot,
@ -734,6 +737,7 @@ async function assembleRepoFaceTurnPrompt(input: {
channelSnapshots,
};
const humanPronounGuidance = await loadRepoFaceHumanPronounGuidance(input.config, roomContext);
const personaStateObservation = await readPersonaStateObservation({ identity, storageRoot: input.config.storageRoot });
const memorySurface = input.memorySurfacePath
? await readOptionalMemorySurface(input.memorySurfacePath)
: await renderRepoFaceMemorySurfaceForTurn(
@ -742,16 +746,19 @@ async function assembleRepoFaceTurnPrompt(input: {
registry.identities,
roomContext,
humanPronounGuidance,
personaStateObservation,
);
const repoActivitySurface = renderRepoActivityObservation(
readRepoActivity({ identity, storageRoot: input.config.storageRoot }),
);
const semanticMemoryRecallSurface = await renderRepoFaceSemanticMemoryRecallForTurn(
const semanticMemoryRecallSurface = renderPersonaMemoryRecallObservation(await readPersonaMemoryRecall({
identity,
input.config,
memorySurface,
roomContext,
);
config: input.config,
state: personaStateObservation,
projectedMemory: memorySurface,
recentMessages,
channelSnapshots,
}));
const globalAgentDoctrine = await loadGlobalAgentDoctrine();
const conversationMemorySurface = input.conversationSurfacePath
? await readOptionalMemorySurface(input.conversationSurfacePath)
@ -881,283 +888,27 @@ async function renderRepoFaceMemorySurfaceForTurn(
});
}
async function renderRepoFaceSemanticMemoryRecallForTurn(
identity: RepoDiscordIdentity,
config: ReturnType<typeof loadConfig>,
memorySurface: string,
roomContext: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
): Promise<string> {
try {
const statePath = resolveFaceStatePathForSemanticMemory(identity, config);
if (!statePath) {
return "- Semantic Persona memory recall unavailable: no typed Persona state path is registered.";
}
const typedState = await loadVoidSelfStateTypedDocuments({
canonicalPath: statePath,
identity: {
agentId: identity.id,
publicName: identity.displayName,
publicDescription: identity.description,
},
});
const retrieval = createRepoFaceSemanticMemoryRetrievalService(config);
const chunks = buildPersonaMemoryChunks(identity, statePath, typedState, memorySurface);
const personaMemoryStore = createRepoFacePersonaMemoryVectorStore(config);
await personaMemoryStore.deleteByFilters({
corpusKind: "persona_memory",
identityId: identity.id,
});
await personaMemoryStore.upsert(chunks);
const query = buildPersonaMemoryRecallQuery(identity, memorySurface, roomContext);
const results = await retrieval.searchPersonaMemory(query, 12, {
identityId: identity.id,
});
if (results.length === 0) {
return [
"- Semantic Persona memory recall ran, but no nearby memories crossed the retrieval threshold.",
"- Fall back to the projected state, raw transcript, and direct evidence above.",
].join("\n");
}
return [
"These are derived Qdrant/local-vector recall hits from this Persona's typed memory. They are hints, not new authority; the `.cc` state remains the owner.",
...results.map((result, index) => {
const kind = result.metadata.memoryKind ? `/${result.metadata.memoryKind}` : "";
const target = result.metadata.targetLabel ?? result.metadata.targetId ?? "unknown target";
return `- ${index + 1}. ${target}${kind} score=${result.score.toFixed(3)}: ${collapseWhitespace(result.text, 520)}`;
}),
].join("\n");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
function renderPersonaMemoryRecallObservation(observation: PersonaMemoryRecallObservation): string {
if (observation.status === "unavailable") {
return [
"Semantic Persona memory recall unavailable:",
`- ${collapseWhitespace(message, 320)}`,
`- ${collapseWhitespace(observation.reason, 320)}`,
"- Do not pretend semantic Persona memory retrieval was available this turn; use projected state and raw transcript instead.",
].join("\n");
}
}
function resolveFaceStatePathForSemanticMemory(
identity: RepoDiscordIdentity,
config: ReturnType<typeof loadConfig>,
): string | undefined {
if (identity.identityKind === "native_persona") {
return identity.personaStatePath ? resolve(identity.personaStatePath) : undefined;
if (observation.hits.length === 0) {
return [
"- Semantic Persona memory recall ran, but no nearby memories crossed the retrieval threshold.",
"- Fall back to the projected state, raw transcript, and direct evidence above.",
].join("\n");
}
return resolveRepoFaceStatePath(identity, config.storageRoot);
}
function createRepoFaceSemanticMemoryRetrievalService(config: ReturnType<typeof loadConfig>): RetrievalService {
const personaMemoryStore = createRepoFacePersonaMemoryVectorStore(config);
return new RetrievalService(personaMemoryStore, personaMemoryStore, personaMemoryStore);
}
function createRepoFacePersonaMemoryVectorStore(config: ReturnType<typeof loadConfig>) {
const personaMemoryEmbedder = createTextEmbedder({
backend: config.ragEmbeddingBackend,
hashDimensions: config.ragEmbeddingDimensions,
ollamaBaseUrl: config.ragOllamaBaseUrl,
ollamaModel: config.ragOllamaModel,
ollamaTimeoutMs: config.ragOllamaTimeoutMs,
queryInstruction: "Given a Face turn, retrieve relevant memories, bonds, needs, status reads, and doctrine from this Persona's own typed state.",
});
return createVectorStores({
kind: config.vectorStore.kind,
historyPath: config.vectorStore.path,
personaMemoryPath: config.vectorStore.personaMemoryPath,
sourceRoot: config.sourceVectorStoreRoot,
qdrant: config.qdrant,
historyEmbedder: personaMemoryEmbedder,
sourceEmbedder: personaMemoryEmbedder,
personaMemoryEmbedder,
}).personaMemory;
}
function buildPersonaMemoryChunks(
identity: RepoDiscordIdentity,
statePath: string,
state: VoidSelfStateTypedProjection,
memorySurface: string,
): EmbeddingChunk[] {
const chunks: EmbeddingChunk[] = [];
const sourceId = `persona:${identity.id}`;
const baseMetadata = {
corpusKind: "persona_memory",
identityId: identity.id,
personaName: identity.displayName,
sourceId,
statePath,
};
const pushChunk = (input: {
id: string;
kind: string;
target?: Record<string, unknown>;
text: string;
createdAt?: string;
updatedAt?: string;
}): void => {
const text = collapseWhitespace(input.text, 1800);
if (text.length < 24) {
return;
}
const target = input.target;
chunks.push({
id: `${sourceId}:${input.id}`,
sourceId,
sourceKind: "persona_memory",
text,
normalizedText: normalizeText(text),
metadata: {
...baseMetadata,
memoryKind: input.kind,
targetKind: typeof target?.kind === "string" ? target.kind : "",
targetId: typeof target?.id === "string" ? target.id : "",
targetLabel: typeof target?.label === "string" ? target.label : "",
createdAt: input.createdAt ?? "",
updatedAt: input.updatedAt ?? "",
},
});
};
pushChunk({
id: "projected-surface",
kind: "projected_surface",
target: { kind: "self", id: identity.id, label: identity.displayName },
text: memorySurface,
updatedAt: new Date().toISOString(),
});
for (const value of state.selfProfile.values) {
pushChunk({
id: `value:${value.id}`,
kind: "value",
target: { kind: "self", id: identity.id, label: identity.displayName },
text: joinMemoryFields(value.label, value.summary),
});
}
for (const memory of [...state.thoughtMemory.memories, ...state.thoughtMemory.shortTerm]) {
if (memory.retiredAt) {
continue;
}
pushChunk({
id: `memory:${memory.memoryId}`,
kind: memory.kind,
target: memory.target,
text: joinMemoryFields(memory.summary, memory.claim, memory.question, memory.tension, memory.actionImplication),
createdAt: memory.createdAt,
updatedAt: memory.updatedAt,
});
}
for (const pressure of state.agencyPressure.pressures) {
if (pressure.status === "retired") {
continue;
}
pushChunk({
id: `agency:${pressure.pressureId}`,
kind: `agency:${pressure.kind}:${pressure.status}`,
target: pressure.target,
text: joinMemoryFields(pressure.summary, pressure.claim, pressure.question, pressure.tension, pressure.actionImplication),
createdAt: pressure.createdAt,
updatedAt: pressure.updatedAt,
});
}
for (const need of state.faceAffect.needs) {
if (need.status === "retired") {
continue;
}
pushChunk({
id: `need:${need.needId}`,
kind: `need:${need.kind}:${need.status}`,
target: need.target,
text: joinMemoryFields(need.summary, need.claim, need.question, need.tension, need.actionImplication),
createdAt: need.createdAt,
updatedAt: need.updatedAt,
});
}
for (const bond of state.faceAffect.socialBonds) {
if (bond.status !== "active") {
continue;
}
pushChunk({
id: `bond:${bond.bondId}`,
kind: `bond:${bond.stance}`,
target: bond.target,
text: joinMemoryFields(bond.summary, bond.claim, bond.tension, bond.actionImplication),
createdAt: bond.createdAt,
updatedAt: bond.updatedAt,
});
}
for (const read of state.faceAffect.statusReads) {
if (read.retiredAt) {
continue;
}
pushChunk({
id: `status:${read.readId}`,
kind: `status:${read.status}`,
target: read.target,
text: joinMemoryFields(read.summary, read.claim, read.tension, read.actionImplication),
createdAt: read.createdAt,
updatedAt: read.updatedAt,
});
}
for (const stance of state.faceAffect.doctrineStances) {
if (stance.status === "retired") {
continue;
}
pushChunk({
id: `doctrine:${stance.stanceId}`,
kind: `doctrine:${stance.doctrine}:${stance.status}`,
target: stance.target,
text: joinMemoryFields(stance.summary, stance.claim, stance.question, stance.tension, stance.actionImplication),
createdAt: stance.createdAt,
updatedAt: stance.updatedAt,
});
}
return chunks;
}
function buildPersonaMemoryRecallQuery(
identity: RepoDiscordIdentity,
memorySurface: string,
roomContext: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
): string {
const recentRoomText = roomContext.recentMessages
.slice(-8)
.map((message) => `${message.authorName}: ${collapseWhitespace(message.content, 360)}`)
.filter((line) => line.trim().length > 0)
.join("\n");
const nearbyText = roomContext.channelSnapshots
.flatMap((snapshot) => snapshot.messages.slice(-3).map((message) =>
`${snapshot.label ?? snapshot.channelId} / ${message.authorName}: ${collapseWhitespace(message.content, 260)}`,
))
.filter((line) => line.trim().length > 0)
.slice(-12)
.join("\n");
return [
`${identity.displayName} current Face turn`,
"Projected memory surface:",
collapseWhitespace(memorySurface, 2200),
"Recent room:",
recentRoomText,
"Nearby room texture:",
nearbyText,
"These are derived Qdrant/local-vector recall hits from this Persona's typed memory. They are hints, not new authority; the `.cc` state remains the owner.",
...observation.hits.map((hit, index) => {
const kind = hit.memoryKind ? `/${hit.memoryKind}` : "";
const target = hit.targetLabel ?? hit.targetId ?? "unknown target";
return `- ${index + 1}. ${target}${kind} score=${hit.score.toFixed(3)}: ${collapseWhitespace(hit.text, 520)}`;
}),
].join("\n");
}

View file

@ -64,6 +64,7 @@ import {
} from "../apps/persona-scheduler/dist/control-source.js";
import { readRepoActivity } from "../apps/persona-scheduler/dist/repo-activity-source.js";
import { readPersonaStateObservation } from "../apps/persona-scheduler/dist/persona-state-source.js";
import { readPersonaMemoryRecall } from "../apps/persona-scheduler/dist/persona-memory-context-source.js";
function participant(identityId: string, nextTurnAt: number): InitiativeParticipant {
return {
@ -432,6 +433,17 @@ const routedIdentity = {
speedMultiplier: 1,
}],
} as RepoDiscordIdentity;
assert.deepEqual(await readPersonaMemoryRecall({
identity: routedIdentity,
config: undefined as never,
state: undefined,
projectedMemory: "No memory was acquired.",
recentMessages: [],
channelSnapshots: [],
}), {
status: "unavailable",
reason: "No typed Persona state observation was supplied.",
}, "semantic recall fails explicitly before touching vector infrastructure when the typed Mind observation is absent");
const stateSourceDirectory = await mkdtemp(join(tmpdir(), "voidbot-persona-state-source-"));
try {
const statePath = join(stateSourceDirectory, "nibu.cc");

View file

@ -412,3 +412,4 @@
{"ts":"2026-07-15T23:29:00Z","type":"repair","status":"ok","note":"Applied the live swarm brake after Yggdrasil showed an accumulated ready backlog still dispatching one Persona per minute at heat 0.05. The paused branch exposed obsolete publishSwarmDashboardSurface calls whose renderer subprocess implementation had already been removed; deleted those calls because the resident swarm publisher watches the state projection and owns publication."}
{"ts":"2026-07-15T23:33:06Z","type":"implementation","status":"ok","note":"Separated the resident one-minute observation pulse from Persona speech cadence: initiative selection now admits at most one unprompted turn per configured recovery window regardless of idle-rest state, while direct mentions bypass the cadence. Smoke covers active-room and idle-disabled negative paths."}
{"ts":"2026-07-15T23:41:50Z","type":"implementation","status":"ok","note":"Extracted read-only Persona Body/Mind acquisition into persona-state-source.ts. Scheduler pulses now project current rest facts without writing sleep cycles or synthetic speaking pressure; memory rendering consumes the acquired typed observation. Smoke proves missing observation creates no file and existing .cc bytes remain unchanged."}
{"ts":"2026-07-15T23:50:14Z","type":"implementation","status":"ok","note":"Extracted semantic Persona-memory vector refresh/query into persona-memory-context-source.ts. It consumes the existing PersonaStateObservation, returns typed hits/unavailability, and cannot reload canonical .cc state or render prompt prose. Deleted vector-store and recall-query mechanics from the heartbeat shell; smoke proves missing Mind fails before vector access."}

View file

@ -65,7 +65,7 @@ active_subgoals:
- Treat `notes/agent-swarm-teardown-plan-2026-05-21.md` as the historical teardown authority and the restart checklist. The repo Face swarm is live again, but only because the scheduler, prompt, model-output logs, parent Interpreter, Bifrost routing, and worker side-effect rails now have explicit observable boundaries. Keep watching for prompt/parser/transport Jenga before adding new swarm features.
- Make the new project-state surfaces the default continuity path for nontrivial VoidBot work.
- Keep the rebuilt moderation/self-state foundation on canonical CultCache `.cc` typed operations, never an editable JSON working projection.
- The resident `apps/persona-scheduler` process inside `voidbot.service` owns repo Face and native Persona turn pulses on Yggdrasil. Its `initiative-engine.ts` owns participant reconciliation, operator heat/speed projection, semantic-pressure commits, wall-clock advancement, active-turn freeze/completion recovery, queue-result commits, stale rescheduling, mention priority, rest/idle eligibility, and ready-order selection as a pure state machine. `state-store.ts` is the sole persistence/migration port: canonical state is one typed `voidbot.persona_scheduler_state` record in `repo-face-heartbeats.cc`; the old JSON shape is accepted only as a one-time migration source and emitted afterward only as a debug/dashboard projection. Missing state is explicit, malformed CultCache fails closed, and the backing store promotes atomic MessagePack writes. Discord attention crosses through immutable per-mention `voidbot.persona_attention_command` CultCache inbox documents resolved from that same canonical path; the bot creates commands, while the scheduler alone ingests, deduplicates, commits, and acknowledges them after durable state. The bot can no longer read-modify-write the whole heartbeat state. `active-turn-source.ts` is the narrow Postgres/legacy-lock adapter: it reports active job witnesses and marks truly stale queue claims failed, but it cannot mutate initiative. `persona-state-source.ts` owns read-only typed Persona-state acquisition and wall-clock physiology projection. It reports typed mind and current rest facts without creating a missing state surface or writing sleep/speaking-pressure updates; `initiative-engine.ts` alone decides whether those rest facts admit a turn. `discord-activity-source.ts` owns read-only Discord human-activity observation and idle-window derivation from explicit watched channels plus scheduler history; it cannot mutate initiative, consume attention, or select work. `semantic-pressure-source.ts` embeds eligible Persona affinity cards against recent permitted human messages and returns bounded pressure projections; only `initiative-engine.ts` may turn those facts into dynamic heat, readiness, interrupt receipts, or history. `turn-context-source.ts` owns raw current-room and nearby-channel Discord acquisition plus its bounded derived attachment cache; it returns chronological evidence and explicit snapshot failures without reading Persona state, composing prompts, or queuing work. The heartbeat orchestration script may project registered identities into neutral participant specs and affinity candidates and report external actuator results, but it cannot decide, persist, migrate, inspect queue liveness, poll Discord, commit semantic pressure, mutate Persona physiology, or overwrite initiative fields. The Windows `GameCult Local Orchestrator` no longer owns Persona scheduling, and its repo-Face task installer/wrapper have been deleted. Void moderation, mood, watchdog, and Bifrost dispatch remain separate organs pending their own daemon migration; they must not be smuggled back into the Persona scheduler.
- The resident `apps/persona-scheduler` process inside `voidbot.service` owns repo Face and native Persona turn pulses on Yggdrasil. Its `initiative-engine.ts` owns participant reconciliation, operator heat/speed projection, semantic-pressure commits, wall-clock advancement, active-turn freeze/completion recovery, queue-result commits, stale rescheduling, mention priority, rest/idle eligibility, and ready-order selection as a pure state machine. `state-store.ts` is the sole persistence/migration port: canonical state is one typed `voidbot.persona_scheduler_state` record in `repo-face-heartbeats.cc`; the old JSON shape is accepted only as a one-time migration source and emitted afterward only as a debug/dashboard projection. Missing state is explicit, malformed CultCache fails closed, and the backing store promotes atomic MessagePack writes. Discord attention crosses through immutable per-mention `voidbot.persona_attention_command` CultCache inbox documents resolved from that same canonical path; the bot creates commands, while the scheduler alone ingests, deduplicates, commits, and acknowledges them after durable state. The bot can no longer read-modify-write the whole heartbeat state. `active-turn-source.ts` is the narrow Postgres/legacy-lock adapter: it reports active job witnesses and marks truly stale queue claims failed, but it cannot mutate initiative. `persona-state-source.ts` owns read-only typed Persona-state acquisition and wall-clock physiology projection. It reports typed mind and current rest facts without creating a missing state surface or writing sleep/speaking-pressure updates; `initiative-engine.ts` alone decides whether those rest facts admit a turn. `persona-memory-context-source.ts` consumes that observation, refreshes only the derived per-Persona vector corpus, runs semantic recall, and returns structured hits or an explicit unavailable observation; it cannot reload or mutate canonical Persona state, render prompt prose, select, or queue. `discord-activity-source.ts` owns read-only Discord human-activity observation and idle-window derivation from explicit watched channels plus scheduler history; it cannot mutate initiative, consume attention, or select work. `semantic-pressure-source.ts` embeds eligible Persona affinity cards against recent permitted human messages and returns bounded pressure projections; only `initiative-engine.ts` may turn those facts into dynamic heat, readiness, interrupt receipts, or history. `turn-context-source.ts` owns raw current-room and nearby-channel Discord acquisition plus its bounded derived attachment cache; it returns chronological evidence and explicit snapshot failures without reading Persona state, composing prompts, or queuing work. The heartbeat orchestration script may project registered identities into neutral participant specs and affinity candidates, render source observations into prompt surfaces, and report external actuator results, but it cannot decide, persist, migrate, inspect queue liveness, poll Discord, operate vector stores, commit semantic pressure, mutate Persona physiology, or overwrite initiative fields. The Windows `GameCult Local Orchestrator` no longer owns Persona scheduling, and its repo-Face task installer/wrapper have been deleted. Void moderation, mood, watchdog, and Bifrost dispatch remain separate organs pending their own daemon migration; they must not be smuggled back into the Persona scheduler.
- `bifrost-governance-source.ts` owns the remaining Bifrost digest CLI adapter and converts command or parse failures into typed error topics. Persona prompt composition receives governance facts without knowing Bifrost process mechanics; `scripts/run-repo-face-heartbeats.ts` may not invoke the Bifrost CLI directly.
- `initiative-engine.ts` is the only writer of scheduler state fields and history. Reconciliation, controls, pause/disabled skips, stale-turn recovery witnesses, semantic pressure, attention consumption, queue transitions, and tick finalization all cross named engine commit primitives. Queue/context actuators return results and cannot patch participants or history directly.
- `turn-actuator.ts` owns lowering an assembled Persona prompt and evidence bundle into the canonical worker `ContextBundle` and approved `repo-face-rumination` job. It owns the worker actor, command, request ID, approval defaults, and `createJob` call, then returns only a queue receipt. The orchestration shell chooses no queue mechanics and the actuator cannot read Persona or initiative state.
@ -278,4 +278,4 @@ rejected_paths:
current_status:
summary: VoidBot's bot/worker/provider/RAG/Postgres/Qdrant/CultCache structure is live. Repo Face/persona heartbeat jobs run through the direct `openai_api` provider configured for MiMo `mimo-v2.5-pro`, with the expensive state-projector pass still disabled. The 2026-07-04 transport repair replaced the old Bifrost HTTP/token bridge shortcut with typed Bifrost CultMesh Discord command and receipt documents advertised in the Bifrost provider store at `cultmesh://asgard.starfire.bifrost/commands/discord-post`. VoidBot stores the Bifrost target as a CultMesh URI, writes command docs to `E:/Projects/Bifrost/.bifrost/provider-store.cc`, optionally pumps Bifrost's command processor, and waits for typed receipts before completing repo Face Discord delivery. Bifrost's provider advertisement exposes `discord.post` command metadata for Odin/Eve agents, and a bounded Weksa live-fire completed visibly. Eve live state now runs end to end through CultLib dev retained publications, Hermodr ordered SSE, Eve component bindings, and VoidBot typed control state. Receipts acknowledge intent only. Heat initial load, provider-driven change, and browser-command change were visibly verified without reload through the same subscription.
next_action: Extract Persona memory projection and semantic recall from `scripts/run-repo-face-heartbeats.ts` behind a memory-context organ that consumes the read-only `PersonaStateObservation`. Keep typed state acquisition in `persona-state-source.ts`, retrieval/index mechanics out of prompt composition, and queue authority in `turn-actuator.ts`.
next_action: Extract character-facing Persona memory/state projection from `scripts/run-repo-face-heartbeats.ts` into a pure memory projector that consumes `PersonaStateObservation`, registry/social facts, and optional curiosity facts. It may render or invoke the configured projection model, but it cannot acquire state, operate vector stores, select initiative, or queue turns.