Split Persona curiosity source and projection

This commit is contained in:
Meta Vens 2026-07-16 02:21:15 +01:00
commit 5149e35431
9 changed files with 293 additions and 551 deletions

View file

@ -0,0 +1,97 @@
import type { RepoDiscordIdentity, VoidSelfStateTypedProjection } from "@voidbot/core";
import type { SourceMessage } from "@voidbot/shared";
import type { ChannelSnapshot } from "./turn-context-source.js";
import { collapsePersonaText, significantPersonaTopicTerms } from "./persona-curiosity-terms.js";
export interface PersonaCuriositySearchResult {
chunkId: string;
sourceId: string;
sourceKind: "discord_message" | "source_document" | "persona_memory";
text: string;
score: number;
metadata: Record<string, string>;
}
export interface PersonaCuriosityRetrievalPort {
searchHistory(query: string, limit: number): Promise<PersonaCuriositySearchResult[]>;
searchSources(query: string, limit: number, repoName?: string): Promise<PersonaCuriositySearchResult[]>;
}
export interface PersonaCuriosityNode extends PersonaCuriositySearchResult {
id: string;
terms: string[];
seedLabels: string[];
}
export type PersonaCuriosityEvidenceObservation =
| { status: "ok"; nodes: PersonaCuriosityNode[]; seedCount: number }
| { status: "empty"; reason: string }
| { status: "unavailable"; reason: string };
export async function readPersonaCuriosityEvidence(input: {
identity: RepoDiscordIdentity;
state: VoidSelfStateTypedProjection;
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
sourceRepoName?: string;
retrieval: PersonaCuriosityRetrievalPort | (() => PersonaCuriosityRetrievalPort);
}): Promise<PersonaCuriosityEvidenceObservation> {
const seeds = buildPersonaCuriositySeedQueries(input);
if (seeds.length === 0) return { status: "empty", reason: "No supplied context produced a viable semantic seed." };
try {
const retrieval = typeof input.retrieval === "function" ? input.retrieval() : input.retrieval;
const nodesById = new Map<string, PersonaCuriosityNode>();
for (const seed of seeds) {
const [history, sources, homeSources] = await Promise.all([
retrieval.searchHistory(seed.query, 8),
retrieval.searchSources(seed.query, 8),
input.sourceRepoName ? retrieval.searchSources(seed.query, 6, input.sourceRepoName) : Promise.resolve([]),
]);
for (const result of [...history, ...sources, ...homeSources]) {
const id = `${result.sourceKind}:${result.chunkId}`;
const terms = significantPersonaTopicTerms(result.text);
if (terms.length < 3) continue;
const existing = nodesById.get(id);
if (existing) {
existing.score = Math.max(existing.score, result.score);
existing.seedLabels = [...new Set([...existing.seedLabels, seed.label])];
} else {
nodesById.set(id, { ...result, id, terms, seedLabels: [seed.label] });
}
}
}
const nodes = [...nodesById.values()].sort((left, right) => right.score - left.score).slice(0, 32);
return nodes.length < 3
? { status: "empty", reason: "Semantic retrieval returned fewer than three usable evidence nodes." }
: { status: "ok", nodes, seedCount: seeds.length };
} catch (error) {
return { status: "unavailable", reason: error instanceof Error ? error.message : String(error) };
}
}
export function buildPersonaCuriositySeedQueries(input: {
identity: RepoDiscordIdentity;
state: VoidSelfStateTypedProjection;
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
}): Array<{ label: string; query: string }> {
const room = input.recentMessages.filter(hasContent).slice(-8).map((message) => `${message.authorName}: ${collapsePersonaText(message.content, 500)}`).join("\n");
const nearby = input.channelSnapshots.flatMap((snapshot) => snapshot.messages).filter(hasContent).slice(-8).map((message) => `${message.authorName}: ${collapsePersonaText(message.content, 320)}`).join("\n");
const state = input.state;
const privateState = [
...state.selfProfile.privateNotes.slice(-8),
...[...state.selfProfile.values].sort((left, right) => right.priority - left.priority).slice(0, 8).map((value) => `${value.label}: ${value.summary}`),
...state.thoughtMemory.memories.filter((memory) => !memory.retiredAt).slice(-8).map(memorySeed),
...state.thoughtMemory.shortTerm.filter((memory) => !memory.retiredAt).slice(-8).map(memorySeed),
...state.thoughtMemory.incubation.filter((thread) => thread.status !== "retired").sort((left, right) => right.maturation - left.maturation).slice(0, 8).map((thread) => `${thread.topic}: ${thread.summary}`),
].join("\n");
const identity = [input.identity.displayName, input.identity.repoName, input.identity.description ?? "", ...input.identity.channelPermissions.flatMap((permission) => [permission.label ?? "", permission.topic ?? "", permission.posture ?? ""])].join("\n");
return [{ label: "current room", query: room }, { label: "nearby rooms", query: nearby }, { label: "private state", query: privateState }, { label: "home territory", query: identity }]
.map((seed) => ({ ...seed, query: collapsePersonaText(seed.query, 2800) }))
.filter((seed) => significantPersonaTopicTerms(seed.query).length >= 3)
.slice(0, 4);
}
function hasContent(message: SourceMessage): boolean { return collapsePersonaText(message.content).length > 0; }
function memorySeed(memory: VoidSelfStateTypedProjection["thoughtMemory"]["memories"][number]): string { return `${memory.kind} ${targetLabel(memory.target)} ${memory.summary} ${memory.claim ?? memory.question ?? ""}`; }
function targetLabel(target: { label?: string; id?: string; kind?: string } | undefined): string { return target?.label ?? target?.id ?? target?.kind ?? "an unnamed target"; }

View file

@ -0,0 +1,119 @@
import type { RepoDiscordIdentity, VoidSelfStateTypedProjection } from "@voidbot/core";
import type { SourceMessage } from "@voidbot/shared";
import type { ChannelSnapshot } from "./turn-context-source.js";
import type { PersonaCuriosityEvidenceObservation, PersonaCuriosityNode } from "./persona-curiosity-context-source.js";
import { collapsePersonaText, significantPersonaTopicTerms } from "./persona-curiosity-terms.js";
interface PersonaCuriosityCluster {
label: string;
nodes: PersonaCuriosityNode[];
prominence: number;
saturation: number;
novelty: number;
clusterDensity: number;
jurisdictionFit: number;
evidence: string[];
}
export function projectPersonaCuriosityContext(input: {
identity: RepoDiscordIdentity;
state: VoidSelfStateTypedProjection;
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
observation: PersonaCuriosityEvidenceObservation;
backendDescription: string;
}): string | undefined {
if (input.observation.status === "empty") return undefined;
if (input.observation.status === "unavailable") return [
"Curiosity graph unavailable:",
`- Semantic retrieval failed while decoding topic attractors: ${collapsePersonaText(input.observation.reason, 260)}`,
"- Do not pretend a semantic curiosity map was available this turn. Fall back to the raw transcript, home-repo activity, and typed memory instead of inventing ranked attractors.",
].join("\n");
const clusters = decodePersonaCuriosityGraph(input, input.observation.nodes);
if (clusters.length === 0) return undefined;
return [
"Curiosity graph attractors:",
`- Source: semantic retrieval from ${input.backendDescription}; local graph decoding over retrieved chunks. This is curiosity weather, not orders or consensus.`,
...clusters.map((cluster) => [
`- ${cluster.label || "unnamed cluster"}: prominence ${formatSignal(cluster.prominence)}, saturation ${formatSignal(cluster.saturation)}, novelty ${formatSignal(cluster.novelty)}, cluster density ${formatSignal(cluster.clusterDensity)}, jurisdiction fit ${formatSignal(cluster.jurisdictionFit)}.`,
` Suggested motion: ${suggestMotion(input.identity, cluster)}`,
` Evidence: ${cluster.evidence.join("; ")}.`,
].join("\n")),
"- The living use is intelligent curiosity: follow underexplored neighboring clusters, deepen home-territory clusters only with fresh anchors, and let saturated clusters create boredom, fatigue, or a deliberate closing move instead of another repetition.",
].join("\n");
}
export function decodePersonaCuriosityGraph(input: {
identity: RepoDiscordIdentity;
state: VoidSelfStateTypedProjection;
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
}, nodes: PersonaCuriosityNode[]): PersonaCuriosityCluster[] {
const edgeWeights = new Map<string, number>();
const adjacency = new Map(nodes.map((node) => [node.id, new Set<string>()]));
for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) for (let rightIndex = leftIndex + 1; rightIndex < nodes.length; rightIndex += 1) {
const left = nodes[leftIndex];
const right = nodes[rightIndex];
const weight = edgeWeight(left, right);
if (weight < 0.16) continue;
edgeWeights.set(edgeKey(left.id, right.id), weight);
adjacency.get(left.id)?.add(right.id);
adjacency.get(right.id)?.add(left.id);
}
const visited = new Set<string>();
const nodesById = new Map(nodes.map((node) => [node.id, node]));
const connected: PersonaCuriosityNode[][] = [];
for (const node of nodes) {
if (visited.has(node.id)) continue;
const stack = [node.id];
const cluster: PersonaCuriosityNode[] = [];
visited.add(node.id);
while (stack.length > 0) {
const id = stack.pop();
if (!id) continue;
const current = nodesById.get(id);
if (current) cluster.push(current);
for (const neighbor of adjacency.get(id) ?? []) if (!visited.has(neighbor)) { visited.add(neighbor); stack.push(neighbor); }
}
connected.push(cluster);
}
const recentTerms = termCounts([...input.recentMessages, ...input.channelSnapshots.flatMap((snapshot) => snapshot.messages)].slice(-36).map((message) => message.content));
const stateTerms = termCounts([
...input.state.selfProfile.privateNotes,
...input.state.selfProfile.values.map((value) => `${value.label} ${value.summary}`),
...input.state.thoughtMemory.memories.map(memorySurface),
...input.state.thoughtMemory.shortTerm.map(memorySurface),
...input.state.thoughtMemory.incubation.map((thread) => `${thread.topic} ${thread.summary}`),
...input.state.agencyPressure.pressures.map((pressure) => `${pressure.summary} ${pressure.claim ?? ""} ${pressure.question ?? ""} ${pressure.tension ?? ""}`),
].slice(-64));
const identityTerms = new Set(significantPersonaTopicTerms([input.identity.id, input.identity.displayName, input.identity.repoName, input.identity.description ?? "", ...input.identity.channelPermissions.flatMap((permission) => [permission.label ?? "", permission.topic ?? "", permission.posture ?? ""])].join(" ")));
return connected.filter((cluster) => cluster.length >= 2).map((cluster): PersonaCuriosityCluster => {
const terms = rankedTerms(cluster).slice(0, 7);
const density = clusterDensity(cluster, edgeWeights);
const stateOverlap = weightedOverlap(terms, stateTerms);
const saturation = clamp((weightedOverlap(terms, recentTerms) * 0.72) + (stateOverlap * 0.42));
const jurisdictionFit = clamp(terms.filter((term) => identityTerms.has(term)).length / Math.max(1, Math.min(terms.length, 4)) + cluster.filter((node) => normalize(node.metadata.repoName ?? "") === normalize(input.identity.repoName)).length / Math.max(1, cluster.length) * 0.55);
return {
label: terms.slice(0, 4).join(" / "), nodes: cluster, saturation, jurisdictionFit, clusterDensity: density,
novelty: clamp(1 - saturation + Math.max(0, 0.4 - stateOverlap)),
prominence: clamp((average(cluster.map((node) => node.score)) * 0.58) + (density * 0.28) + (Math.min(cluster.length, 8) / 8 * 0.18)),
evidence: [...cluster].sort((left, right) => right.score - left.score).slice(0, 3).map(evidenceLabel),
};
}).sort((left, right) => attractorRank(right) - attractorRank(left) || right.prominence - left.prominence).slice(0, 5);
}
function edgeWeight(left: PersonaCuriosityNode, right: PersonaCuriosityNode): number { const sameRepo = left.metadata.repoName && right.metadata.repoName && normalize(left.metadata.repoName) === normalize(right.metadata.repoName) ? 1 : 0; const sameCorpus = left.sourceKind === right.sourceKind ? 1 : 0; const sharedSeed = left.seedLabels.some((label) => right.seedLabels.includes(label)) ? 1 : 0; return clamp(jaccard(left.terms, right.terms) * 0.58 + sameRepo * 0.16 + sameCorpus * 0.1 + sharedSeed * 0.08 + (1 - Math.min(1, Math.abs(left.score - right.score))) * 0.08); }
function rankedTerms(cluster: PersonaCuriosityNode[]): string[] { const counts = new Map<string, number>(); for (const node of cluster) for (const term of node.terms) counts.set(term, (counts.get(term) ?? 0) + 1); return [...counts].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])).map(([term]) => term); }
function termCounts(surfaces: string[]): Map<string, number> { const counts = new Map<string, number>(); for (const surface of surfaces) for (const term of significantPersonaTopicTerms(surface)) counts.set(term, (counts.get(term) ?? 0) + 1); return counts; }
function weightedOverlap(terms: string[], counts: Map<string, number>): number { if (terms.length === 0 || counts.size === 0) return 0; const max = Math.max(...counts.values(), 1); return clamp(terms.reduce((sum, term) => sum + (counts.get(term) ?? 0) / max, 0) / Math.min(terms.length, 6)); }
function clusterDensity(cluster: PersonaCuriosityNode[], weights: Map<string, number>): number { let sum = 0; let pairs = 0; for (let left = 0; left < cluster.length; left += 1) for (let right = left + 1; right < cluster.length; right += 1) { sum += weights.get(edgeKey(cluster[left].id, cluster[right].id)) ?? 0; pairs += 1; } return pairs > 0 ? clamp(sum / pairs) : 0; }
function attractorRank(cluster: PersonaCuriosityCluster): number { return clamp(cluster.prominence * 0.42 + cluster.novelty * 0.28 + cluster.clusterDensity * 0.16 + cluster.jurisdictionFit * 0.18 - cluster.saturation * 0.2); }
function suggestMotion(identity: RepoDiscordIdentity, cluster: PersonaCuriosityCluster): string { if (cluster.saturation >= 0.68 && cluster.novelty <= 0.42) return `treat this as over-chewed; ${identity.displayName} should close, defer, or pivot to a neighboring question unless a new concrete anchor appears.`; if (cluster.jurisdictionFit >= 0.55 && cluster.novelty >= 0.45) return "this is home-territory curiosity with room to grow; read, ask, draft, or make a fresh anchored distinction."; if (cluster.novelty >= 0.62) return `this is an underexplored neighboring trail; curiosity may pull ${identity.displayName} sideways instead of repeating the room's dominant topic.`; if (cluster.jurisdictionFit < 0.3) return "this likely belongs to another steward; use it as social weather, consultation, or rivalry pressure rather than absorbing the work."; return "stay interested only if the turn adds a new anchor, concrete question, or relationship move."; }
function evidenceLabel(node: PersonaCuriosityNode): string { const source = node.sourceKind === "source_document" ? [node.metadata.repoName, node.metadata.path].filter(Boolean).join(":") || node.sourceId : [node.metadata.channelId ? `channel ${node.metadata.channelId}` : undefined, node.sourceId].filter(Boolean).join(":") || node.sourceId; return `${source} (${node.score.toFixed(2)}, ${node.seedLabels.join("/")})`; }
function memorySurface(memory: VoidSelfStateTypedProjection["thoughtMemory"]["memories"][number]): string { return `${memory.summary} ${memory.claim ?? ""} ${memory.question ?? ""} ${memory.tension ?? ""}`; }
function edgeKey(left: string, right: string): string { return left < right ? `${left}::${right}` : `${right}::${left}`; }
function jaccard(left: string[], right: string[]): number { const a = new Set(left); const b = new Set(right); const union = new Set([...a, ...b]).size; return union === 0 ? 0 : [...a].filter((term) => b.has(term)).length / union; }
function average(values: number[]): number { return values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0; }
function formatSignal(value: number): string { return `${value >= 0.68 ? "high" : value >= 0.38 ? "medium" : "low"} ${value.toFixed(2)}`; }
function normalize(value: string): string { return value.trim().toLowerCase(); }
function clamp(value: number): number { return Math.min(1, Math.max(0, Number(value.toFixed(3)))); }

View file

@ -0,0 +1,20 @@
const STOP_WORDS = new Set([
"about", "actually", "after", "again", "agent", "agents", "already", "another", "around", "because", "before", "being", "between", "channel", "could", "does", "doing", "don", "even", "every", "exactly", "face", "faces", "first", "from", "give", "going", "good", "have", "here", "into", "just", "kind", "know", "latest", "like", "line", "little", "made", "make", "maybe", "more", "need", "needs", "only", "other", "point", "post", "really", "recent", "right", "rights", "room", "same", "should", "something", "still", "take", "talk", "than", "that", "their", "them", "there", "these", "they", "thing", "things", "think", "this", "those", "through", "turn", "want", "what", "when", "where", "which", "while", "with", "work", "would", "write", "you", "your",
]);
export function significantPersonaTopicTerms(content: string): string[] {
const normalized = collapsePersonaText(content)
.toLowerCase()
.replace(/[`*_~]/g, "")
.replace(/<:[^>]+>/g, "")
.replace(/https?:\/\/\S+/g, "url")
.replace(/[^\p{L}\p{N}\s.'-]/gu, " ")
.replace(/\b\d{5,}\b/g, " ")
.replace(/\b[a-z]*\d+[a-z0-9]*\b/g, " ");
return [...new Set(normalized.split(/\s+/).map((term) => term.replace(/^['.-]+|['.-]+$/g, "")).filter((term) => term.length >= 4 && !STOP_WORDS.has(term)))];
}
export function collapsePersonaText(value: string, maxLength?: number): string {
const normalized = value.replace(/\s+/g, " ").trim();
return maxLength && normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized;
}

View file

@ -155,6 +155,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- The pure typed-state core of that packet now also lives in `persona-memory-projector.ts`: private identity texture, activation vectors, runtime/rest/speaking pressure, needs, bonds, status reads, mood dimensions, and durable/short-term memories. The shell still owns agency/incubation/candidate/receipt rendering and the ordering of externally acquired social, room, clarity, maintenance, and curiosity facts; those are the remaining packet-builder debris.
- `persona-memory-projector.ts` owns the full typed-state packet contract, including clarity-conditioned agency pressure, incubation, deferred speech, recent receipts, self-maintenance advocacy, final ordering of named external fact sections, final vocabulary transformation, and leak validation. Prompt assembly parity is covered by `--assemble-prompt nibu` producing the same 153,917-character prompt across these ownership cuts.
- Self-maintenance classification, rendering, clarity suppression, and ordering now live inside `persona-memory-projector.ts` with the other typed-state pressure sections. The heartbeat shell supplies typed state and a clarity witness; it cannot select, reorder, or separately thread self-advocacy policy. Exact Nibu prompt assembly remains 153,917 characters.
- Persona curiosity now has two authorities. `persona-curiosity-context-source.ts` owns semantic seed planning, injected retrieval calls, deduplication, and bounded neutral evidence nodes. `persona-curiosity-projector.ts` owns graph edges, connected clusters, saturation/novelty/prominence/jurisdiction scoring, suggested motion, evidence labels, and failure/empty projection. `persona-curiosity-terms.ts` owns the shared topic vocabulary used by curiosity and shell-level repetition analysis. The heartbeat shell only constructs the existing retrieval adapter and wires source output into the pure projector; exact Nibu prompt assembly remains 153,917 characters.
- `persona-social-context-projector.ts` owns one aggregate social-context projection from supplied identity, registry, typed state, messages, pronoun guidance, and observation time. That result includes relationship freshness, mapped/unmapped topology, peer openings, relationship-pressure scoring, pronouns, room texture, and human-clarity pressure. Clarity evidence sorts on the real `SourceMessage.timestamp`; the heartbeat shell no longer owns social tokenization or scoring helpers.
- 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.

View file

@ -185,6 +185,7 @@ VoidBot runs on Yggdrasil. Idunn owns deployment and consumes upstream repositor
- `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/persona-memory-projector.ts` owns deterministic/model projection policy, its prompt template, output sufficiency, leak rejection, all typed-state sections including clarity-conditioned agency/incubation/candidate/receipt pressure, and final ordering of named external fact sections. `apps/persona-scheduler/src/persona-social-context-projector.ts` owns time-explicit relationship freshness, room-texture observation, memory-facing room facts, turn-facing room weather, and pronoun-guidance rendering from supplied facts. Neither projector may acquire Discord, reopen Persona state, retrieve vectors, 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, still computes social-graph/peer-opening/relationship-pressure/human-clarity/maintenance facts, passes all facts by name to the projector, and invokes external queue actuators 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.
- Human-clarity chronology, social topology, peer openings, and relationship-pressure scoring now share the same social-context owner. The shell consumes one named projection result and contains no duplicate social scorer.
- Self-maintenance advocacy is a typed-state pressure projection owned by `persona-memory-projector.ts`. That organ classifies qualifying needs, agency pressures, and must-share candidates, suppresses them under live human-clarity pressure, and fixes their order alongside the other pressure sections; the heartbeat shell no longer carries a parallel maintenance policy.
- Curiosity is split at the evidence boundary. `persona-curiosity-context-source.ts` plans semantic seeds from supplied identity, typed Mind, and room evidence, then acquires and deduplicates bounded neutral nodes through an injected retrieval port. `persona-curiosity-projector.ts` constructs and scores the local attractor graph and renders curiosity weather without vector access. `persona-curiosity-terms.ts` owns their shared topic-term vocabulary. The heartbeat shell wires the existing RAG adapter into these organs and owns neither query policy nor graph interpretation.
- `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

@ -25,7 +25,6 @@ import {
type InteractionMemoryProfile,
type PromptImageAttachment,
type RepoFaceConversationFocus,
type RetrievalResult,
type SourceMessage,
} from "@voidbot/shared";
import {
@ -63,6 +62,9 @@ import {
type PersonaMemoryRecallObservation,
} from "../apps/persona-scheduler/dist/persona-memory-context-source.js";
import { composePersonaMemoryPacket, projectPersonaMemorySurface, renderPersonaPressureSections, renderPersonaTypedStateSections } from "../apps/persona-scheduler/dist/persona-memory-projector.js";
import { readPersonaCuriosityEvidence } from "../apps/persona-scheduler/dist/persona-curiosity-context-source.js";
import { projectPersonaCuriosityContext } from "../apps/persona-scheduler/dist/persona-curiosity-projector.js";
import { significantPersonaTopicTerms } from "../apps/persona-scheduler/dist/persona-curiosity-terms.js";
import { observePersonaRoomTexture, projectPersonaSocialContext, renderPersonaHumanPronounFacts, renderPersonaRoomWeather, type PersonaHumanPronounGuidance as RepoFaceHumanPronounGuidance } from "../apps/persona-scheduler/dist/persona-social-context-projector.js";
import {
readDiscordActivitySnapshot,
@ -1183,27 +1185,6 @@ function targetLabel(target: { label?: string; id?: string; kind?: string } | un
return target.label ?? target.id ?? target.kind ?? "an unnamed target";
}
interface RepoFaceCuriosityNode {
id: string;
text: string;
sourceKind: RetrievalResult["sourceKind"];
score: number;
terms: string[];
metadata: Record<string, string>;
seedLabels: string[];
}
interface RepoFaceCuriosityCluster {
label: string;
nodes: RepoFaceCuriosityNode[];
prominence: number;
saturation: number;
novelty: number;
clusterDensity: number;
jurisdictionFit: number;
evidence: string[];
}
async function renderRepoFaceCuriosityGraphFacts(
identity: RepoDiscordIdentity,
config: ReturnType<typeof loadConfig>,
@ -1213,69 +1194,21 @@ async function renderRepoFaceCuriosityGraphFacts(
channelSnapshots: ChannelSnapshot[];
},
): Promise<string | undefined> {
const seedQueries = buildRepoFaceCuriositySeedQueries(identity, state, roomContext);
if (seedQueries.length === 0) {
return undefined;
}
try {
const retrieval = createRepoFaceCuriosityRetrievalService(config);
const nodesById = new Map<string, RepoFaceCuriosityNode>();
const sourceRepoName = getRepoFaceSourceRepoName(identity);
for (const seed of seedQueries) {
const [historyResults, sourceResults, homeSourceResults] = await Promise.all([
retrieval.searchHistory(seed.query, 8),
retrieval.searchRepositorySources(seed.query, 8),
sourceRepoName
? retrieval.searchRepositorySources(seed.query, 6, { repoName: sourceRepoName })
: Promise.resolve([]),
]);
for (const result of [...historyResults, ...sourceResults, ...homeSourceResults]) {
const id = `${result.sourceKind}:${result.chunkId}`;
const terms = significantTopicTerms(result.text);
if (terms.length < 3) {
continue;
}
const existing = nodesById.get(id);
if (existing) {
existing.score = Math.max(existing.score, result.score);
existing.seedLabels = mergeStrings(existing.seedLabels, seed.label);
continue;
}
nodesById.set(id, {
id,
text: result.text,
sourceKind: result.sourceKind,
score: result.score,
terms,
metadata: result.metadata,
seedLabels: [seed.label],
});
}
}
const nodes = [...nodesById.values()]
.sort((left, right) => right.score - left.score)
.slice(0, 32);
if (nodes.length < 3) {
return undefined;
}
const clusters = decodeRepoFaceCuriosityGraph(identity, state, roomContext, nodes);
if (clusters.length === 0) {
return undefined;
}
return renderRepoFaceCuriosityClusters(identity, config, clusters);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return [
"Curiosity graph unavailable:",
`- Semantic retrieval failed while decoding topic attractors: ${collapseWhitespace(message, 260)}`,
"- Do not pretend a semantic curiosity map was available this turn. Fall back to the raw transcript, home-repo activity, and typed memory instead of inventing ranked attractors.",
].join("\n");
}
const observation = await readPersonaCuriosityEvidence({
identity, state, ...roomContext,
sourceRepoName: getRepoFaceSourceRepoName(identity),
retrieval: () => {
const retrieval = createRepoFaceCuriosityRetrievalService(config);
return {
searchHistory: (query, limit) => retrieval.searchHistory(query, limit),
searchSources: (query, limit, repoName) => retrieval.searchRepositorySources(query, limit, repoName ? { repoName } : undefined),
};
},
});
const backendDescription = config.vectorStore.kind === "qdrant"
? `Qdrant collections ${config.qdrant.historyCollection} + ${config.qdrant.sourceCollection}`
: "local vector shards";
return projectPersonaCuriosityContext({ identity, state, ...roomContext, observation, backendDescription });
}
function createRepoFaceCuriosityRetrievalService(config: ReturnType<typeof loadConfig>): RetrievalService {
@ -1308,365 +1241,6 @@ function createRepoFaceCuriosityRetrievalService(config: ReturnType<typeof loadC
return new RetrievalService(stores.history, stores.source, stores.personaMemory);
}
function buildRepoFaceCuriositySeedQueries(
identity: RepoDiscordIdentity,
state: VoidSelfStateTypedProjection,
roomContext: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
): Array<{ label: string; query: string }> {
const room = roomContext.recentMessages
.filter((message) => collapseWhitespace(message.content).length > 0)
.slice(-8)
.map((message) => `${message.authorName}: ${collapseWhitespace(message.content, 500)}`)
.join("\n");
const nearby = roomContext.channelSnapshots
.flatMap((snapshot) => snapshot.messages)
.filter((message) => collapseWhitespace(message.content).length > 0)
.slice(-8)
.map((message) => `${message.authorName}: ${collapseWhitespace(message.content, 320)}`)
.join("\n");
const privateThoughts = [
...state.selfProfile.privateNotes.slice(-8),
...state.selfProfile.values
.slice()
.sort((left, right) => right.priority - left.priority)
.slice(0, 8)
.map((value) => `${value.label}: ${value.summary}`),
...state.thoughtMemory.memories
.filter((memory) => !memory.retiredAt)
.slice(-8)
.map((memory) => `${memory.kind} ${targetLabel(memory.target)} ${memory.summary} ${memory.claim ?? memory.question ?? ""}`),
...state.thoughtMemory.shortTerm
.filter((memory) => !memory.retiredAt)
.slice(-8)
.map((memory) => `${memory.kind} ${targetLabel(memory.target)} ${memory.summary} ${memory.claim ?? memory.question ?? ""}`),
...state.thoughtMemory.incubation
.filter((thread) => thread.status !== "retired")
.sort((left, right) => right.maturation - left.maturation)
.slice(0, 8)
.map((thread) => `${thread.topic}: ${thread.summary}`),
].join("\n");
const identityQuery = [
identity.displayName,
identity.repoName,
identity.description ?? "",
...identity.channelPermissions.flatMap((permission) => [
permission.label ?? "",
permission.topic ?? "",
permission.posture ?? "",
]),
].join("\n");
return [
{ label: "current room", query: room },
{ label: "nearby rooms", query: nearby },
{ label: "private state", query: privateThoughts },
{ label: "home territory", query: identityQuery },
]
.map((seed) => ({ ...seed, query: collapseWhitespace(seed.query, 2800) }))
.filter((seed) => significantTopicTerms(seed.query).length >= 3)
.slice(0, 4);
}
function decodeRepoFaceCuriosityGraph(
identity: RepoDiscordIdentity,
state: VoidSelfStateTypedProjection,
roomContext: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
nodes: RepoFaceCuriosityNode[],
): RepoFaceCuriosityCluster[] {
const edgeWeights = new Map<string, number>();
const adjacency = new Map<string, Set<string>>();
for (const node of nodes) {
adjacency.set(node.id, new Set());
}
for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < nodes.length; rightIndex += 1) {
const left = nodes[leftIndex];
const right = nodes[rightIndex];
const weight = repoFaceCuriosityEdgeWeight(left, right);
if (weight < 0.16) {
continue;
}
const key = curiosityEdgeKey(left.id, right.id);
edgeWeights.set(key, weight);
adjacency.get(left.id)?.add(right.id);
adjacency.get(right.id)?.add(left.id);
}
}
const visited = new Set<string>();
const clusters: RepoFaceCuriosityNode[][] = [];
const nodesById = new Map(nodes.map((node) => [node.id, node]));
for (const node of nodes) {
if (visited.has(node.id)) {
continue;
}
const stack = [node.id];
const cluster: RepoFaceCuriosityNode[] = [];
visited.add(node.id);
while (stack.length > 0) {
const id = stack.pop();
if (!id) {
continue;
}
const current = nodesById.get(id);
if (current) {
cluster.push(current);
}
for (const neighbor of adjacency.get(id) ?? []) {
if (!visited.has(neighbor)) {
visited.add(neighbor);
stack.push(neighbor);
}
}
}
clusters.push(cluster);
}
const recentTerms = collectRecentRoomTopicTermCounts(roomContext);
const stateTerms = collectRepoFaceStateTopicTermCounts(state);
const identityTerms = new Set(significantTopicTerms([
identity.id,
identity.displayName,
identity.repoName,
identity.description ?? "",
...identity.channelPermissions.flatMap((permission) => [
permission.label ?? "",
permission.topic ?? "",
permission.posture ?? "",
]),
].join(" ")));
return clusters
.filter((cluster) => cluster.length >= 2)
.map((cluster): RepoFaceCuriosityCluster => {
const clusterTerms = rankedClusterTerms(cluster).slice(0, 7);
const density = clusterDensity(cluster, edgeWeights);
const averageScore = average(cluster.map((node) => node.score));
const recentOverlap = weightedTermOverlap(clusterTerms, recentTerms);
const stateOverlap = weightedTermOverlap(clusterTerms, stateTerms);
const saturation = clamp((recentOverlap * 0.72) + (stateOverlap * 0.42), 0, 1);
const jurisdictionFit = clamp(
clusterTerms.filter((term) => identityTerms.has(term)).length / Math.max(1, Math.min(clusterTerms.length, 4))
+ cluster.filter((node) => normalizeKey(node.metadata.repoName ?? "") === normalizeKey(identity.repoName)).length / Math.max(1, cluster.length) * 0.55,
0,
1,
);
const novelty = clamp(1 - saturation + Math.max(0, 0.4 - stateOverlap), 0, 1);
const prominence = clamp((averageScore * 0.58) + (density * 0.28) + (Math.min(cluster.length, 8) / 8 * 0.18), 0, 1);
return {
label: clusterTerms.slice(0, 4).join(" / "),
nodes: cluster,
prominence,
saturation,
novelty,
clusterDensity: density,
jurisdictionFit,
evidence: cluster
.slice()
.sort((left, right) => right.score - left.score)
.slice(0, 3)
.map((node) => curiosityEvidenceLabel(node)),
};
})
.sort((left, right) => {
const leftRank = curiosityAttractorRank(left);
const rightRank = curiosityAttractorRank(right);
if (rightRank !== leftRank) {
return rightRank - leftRank;
}
return right.prominence - left.prominence;
})
.slice(0, 5);
}
function renderRepoFaceCuriosityClusters(
identity: RepoDiscordIdentity,
config: ReturnType<typeof loadConfig>,
clusters: RepoFaceCuriosityCluster[],
): string {
const backend = config.vectorStore.kind === "qdrant"
? `Qdrant collections ${config.qdrant.historyCollection} + ${config.qdrant.sourceCollection}`
: "local vector shards";
return [
"Curiosity graph attractors:",
`- Source: semantic retrieval from ${backend}; local graph decoding over retrieved chunks. This is curiosity weather, not orders or consensus.`,
...clusters.map((cluster) => {
const suggestedMotion = suggestCuriosityMotion(identity, cluster);
return [
`- ${cluster.label || "unnamed cluster"}: prominence ${formatSignal(cluster.prominence)}, saturation ${formatSignal(cluster.saturation)}, novelty ${formatSignal(cluster.novelty)}, cluster density ${formatSignal(cluster.clusterDensity)}, jurisdiction fit ${formatSignal(cluster.jurisdictionFit)}.`,
` Suggested motion: ${suggestedMotion}`,
` Evidence: ${cluster.evidence.join("; ")}.`,
].join("\n");
}),
"- The living use is intelligent curiosity: follow underexplored neighboring clusters, deepen home-territory clusters only with fresh anchors, and let saturated clusters create boredom, fatigue, or a deliberate closing move instead of another repetition.",
].join("\n");
}
function repoFaceCuriosityEdgeWeight(left: RepoFaceCuriosityNode, right: RepoFaceCuriosityNode): number {
const termSimilarity = jaccard(left.terms, right.terms);
const sameRepo = left.metadata.repoName && right.metadata.repoName && normalizeKey(left.metadata.repoName) === normalizeKey(right.metadata.repoName)
? 1
: 0;
const sameCorpus = left.sourceKind === right.sourceKind ? 1 : 0;
const sharedSeeds = left.seedLabels.filter((label) => right.seedLabels.includes(label)).length > 0 ? 1 : 0;
const scoreProximity = 1 - Math.min(1, Math.abs(left.score - right.score));
return clamp((termSimilarity * 0.58) + (sameRepo * 0.16) + (sameCorpus * 0.1) + (sharedSeeds * 0.08) + (scoreProximity * 0.08), 0, 1);
}
function rankedClusterTerms(cluster: RepoFaceCuriosityNode[]): string[] {
const counts = new Map<string, number>();
for (const node of cluster) {
for (const term of node.terms) {
counts.set(term, (counts.get(term) ?? 0) + 1);
}
}
return [...counts.entries()]
.sort((left, right) => {
if (right[1] !== left[1]) {
return right[1] - left[1];
}
return left[0].localeCompare(right[0]);
})
.map(([term]) => term);
}
function collectRecentRoomTopicTermCounts(input: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
}): Map<string, number> {
const counts = new Map<string, number>();
const messages = [
...input.recentMessages,
...input.channelSnapshots.flatMap((snapshot) => snapshot.messages),
].slice(-36);
for (const message of messages) {
for (const term of significantTopicTerms(message.content)) {
counts.set(term, (counts.get(term) ?? 0) + 1);
}
}
return counts;
}
function collectRepoFaceStateTopicTermCounts(state: VoidSelfStateTypedProjection): Map<string, number> {
const counts = new Map<string, number>();
const surfaces = [
...state.selfProfile.privateNotes,
...state.selfProfile.values.map((value) => `${value.label} ${value.summary}`),
...state.thoughtMemory.memories.map((memory) => `${memory.summary} ${memory.claim ?? ""} ${memory.question ?? ""} ${memory.tension ?? ""}`),
...state.thoughtMemory.shortTerm.map((memory) => `${memory.summary} ${memory.claim ?? ""} ${memory.question ?? ""} ${memory.tension ?? ""}`),
...state.thoughtMemory.incubation.map((thread) => `${thread.topic} ${thread.summary}`),
...state.agencyPressure.pressures.map((pressure) => `${pressure.summary} ${pressure.claim ?? ""} ${pressure.question ?? ""} ${pressure.tension ?? ""}`),
].slice(-64);
for (const surface of surfaces) {
for (const term of significantTopicTerms(surface)) {
counts.set(term, (counts.get(term) ?? 0) + 1);
}
}
return counts;
}
function weightedTermOverlap(terms: string[], counts: Map<string, number>): number {
if (terms.length === 0 || counts.size === 0) {
return 0;
}
const maxCount = Math.max(...counts.values(), 1);
const overlap = terms.reduce((sum, term) => sum + ((counts.get(term) ?? 0) / maxCount), 0);
return clamp(overlap / Math.min(terms.length, 6), 0, 1);
}
function clusterDensity(cluster: RepoFaceCuriosityNode[], edgeWeights: Map<string, number>): number {
if (cluster.length < 2) {
return 0;
}
let sum = 0;
let pairs = 0;
for (let leftIndex = 0; leftIndex < cluster.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < cluster.length; rightIndex += 1) {
sum += edgeWeights.get(curiosityEdgeKey(cluster[leftIndex].id, cluster[rightIndex].id)) ?? 0;
pairs += 1;
}
}
return pairs > 0 ? clamp(sum / pairs, 0, 1) : 0;
}
function curiosityAttractorRank(cluster: RepoFaceCuriosityCluster): number {
return clamp(
cluster.prominence * 0.42
+ cluster.novelty * 0.28
+ cluster.clusterDensity * 0.16
+ cluster.jurisdictionFit * 0.18
- cluster.saturation * 0.2,
0,
1,
);
}
function suggestCuriosityMotion(identity: RepoDiscordIdentity, cluster: RepoFaceCuriosityCluster): string {
if (cluster.saturation >= 0.68 && cluster.novelty <= 0.42) {
return `treat this as over-chewed; ${identity.displayName} should close, defer, or pivot to a neighboring question unless a new concrete anchor appears.`;
}
if (cluster.jurisdictionFit >= 0.55 && cluster.novelty >= 0.45) {
return `this is home-territory curiosity with room to grow; read, ask, draft, or make a fresh anchored distinction.`;
}
if (cluster.novelty >= 0.62) {
return `this is an underexplored neighboring trail; curiosity may pull ${identity.displayName} sideways instead of repeating the room's dominant topic.`;
}
if (cluster.jurisdictionFit < 0.3) {
return `this likely belongs to another steward; use it as social weather, consultation, or rivalry pressure rather than absorbing the work.`;
}
return "stay interested only if the turn adds a new anchor, concrete question, or relationship move.";
}
function curiosityEvidenceLabel(node: RepoFaceCuriosityNode): string {
const source = node.sourceKind === "source_document"
? [node.metadata.repoName, node.metadata.path].filter(Boolean).join(":") || node.sourceId
: [node.metadata.channelId ? `channel ${node.metadata.channelId}` : undefined, node.sourceId].filter(Boolean).join(":") || node.sourceId;
return `${source} (${node.score.toFixed(2)}, ${node.seedLabels.join("/")})`;
}
function curiosityEdgeKey(left: string, right: string): string {
return left < right ? `${left}::${right}` : `${right}::${left}`;
}
function jaccard(left: string[], right: string[]): number {
const leftSet = new Set(left);
const rightSet = new Set(right);
const union = new Set([...leftSet, ...rightSet]).size;
if (union === 0) {
return 0;
}
let intersection = 0;
for (const term of leftSet) {
if (rightSet.has(term)) {
intersection += 1;
}
}
return intersection / union;
}
function average(values: number[]): number {
return values.length > 0
? values.reduce((sum, value) => sum + value, 0) / values.length
: 0;
}
function formatSignal(value: number): string {
if (value >= 0.68) {
return `high ${value.toFixed(2)}`;
}
if (value >= 0.38) {
return `medium ${value.toFixed(2)}`;
}
return `low ${value.toFixed(2)}`;
}
async function loadRepoFaceHumanPronounGuidance(
config: ReturnType<typeof loadConfig>,
roomContext?: {
@ -2446,7 +2020,7 @@ function estimateTopicRelationToIdentity(
identity: RepoDiscordIdentity,
signal: RoomTopicSaturationSignal,
): TopicRelationToIdentity {
const identityTerms = new Set(significantTopicTerms([
const identityTerms = new Set(significantPersonaTopicTerms([
identity.id,
identity.displayName,
identity.repoName,
@ -2476,7 +2050,7 @@ function detectRoomTopicSaturation(messages: SourceMessage[]): RoomTopicSaturati
}
const termCounts = new Map<string, number>();
const messageTerms = recent.map((message) => new Set(significantTopicTerms(message.content)));
const messageTerms = recent.map((message) => new Set(significantPersonaTopicTerms(message.content)));
for (const terms of messageTerms) {
for (const term of terms) {
termCounts.set(term, (termCounts.get(term) ?? 0) + 1);
@ -2517,106 +2091,6 @@ function detectRoomTopicSaturation(messages: SourceMessage[]): RoomTopicSaturati
};
}
const TOPIC_STOP_WORDS = new Set([
"about",
"actually",
"after",
"again",
"agent",
"agents",
"already",
"another",
"around",
"because",
"before",
"being",
"between",
"channel",
"could",
"does",
"doing",
"don",
"even",
"every",
"exactly",
"face",
"faces",
"from",
"give",
"going",
"good",
"have",
"here",
"into",
"just",
"kind",
"know",
"latest",
"like",
"little",
"line",
"made",
"make",
"maybe",
"more",
"need",
"needs",
"only",
"other",
"point",
"post",
"really",
"recent",
"room",
"same",
"should",
"something",
"still",
"take",
"talk",
"than",
"that",
"their",
"there",
"these",
"they",
"them",
"thing",
"things",
"think",
"this",
"those",
"through",
"turn",
"want",
"what",
"when",
"where",
"which",
"while",
"with",
"work",
"would",
"write",
"you",
"your",
"first",
"right",
"rights",
]);
function significantTopicTerms(content: string): string[] {
const normalized = normalizeForRepetition(content)
.replace(/\b\d{5,}\b/g, " ")
.replace(/\b[a-z]*\d+[a-z0-9]*\b/g, " ");
const terms = normalized
.split(/\s+/)
.map((term) => term.replace(/^['.-]+|['.-]+$/g, ""))
.filter((term) => term.length >= 4 && !TOPIC_STOP_WORDS.has(term));
return Array.from(new Set(terms));
}
function countRepeatedPhrases(messages: SourceMessage[]): Array<{ phrase: string; count: number }> {
const counts = new Map<string, number>();
for (const message of messages) {
@ -2793,10 +2267,6 @@ function projectCharacterDescription(description: string | undefined): string |
.join(" ");
}
function mergeStrings(values: string[], value: string): string[] {
return Array.from(new Set([...values, value]));
}
function normalizeKey(value: string): string {
return value.trim().toLowerCase();
}

View file

@ -66,6 +66,8 @@ import { readRepoActivity } from "../apps/persona-scheduler/dist/repo-activity-s
import { readPersonaStateObservation } from "../apps/persona-scheduler/dist/persona-state-source.js";
import { readPersonaMemoryRecall } from "../apps/persona-scheduler/dist/persona-memory-context-source.js";
import { composePersonaMemoryPacket, projectPersonaMemorySurface, renderPersonaPressureSections, renderPersonaSelfMaintenancePressure, renderPersonaTypedStateSections } from "../apps/persona-scheduler/dist/persona-memory-projector.js";
import { readPersonaCuriosityEvidence } from "../apps/persona-scheduler/dist/persona-curiosity-context-source.js";
import { projectPersonaCuriosityContext } from "../apps/persona-scheduler/dist/persona-curiosity-projector.js";
import { observePersonaRoomTexture, projectPersonaSocialContext, renderPersonaHumanClarityPressure, renderPersonaHumanPronounFacts, renderPersonaRoomWeather, renderPersonaSocialGraph } from "../apps/persona-scheduler/dist/persona-social-context-projector.js";
function participant(identityId: string, nextTurnAt: number): InitiativeParticipant {
@ -547,6 +549,37 @@ try {
} : undefined;
assert.match(maintenanceState ? renderPersonaSelfMaintenancePressure({ identityName: "Nibu", state: maintenanceState, clarityPressureActive: false }) ?? "" : "", /Self-maintenance pressure requiring public advocacy/, "the memory projector owns self-maintenance classification and rendering");
assert.equal(maintenanceState ? renderPersonaSelfMaintenancePressure({ identityName: "Nibu", state: maintenanceState, clarityPressureActive: true }) : undefined, undefined, "live human clarity pressure suppresses self-maintenance advocacy inside the owning projector");
const curiosityObservation = populatedObservation.status === "ok" ? await readPersonaCuriosityEvidence({
identity: routedIdentity,
state: populatedObservation.typedState,
recentMessages: [{ id: "curiosity-room", authorId: "human", authorName: "Operator", content: "memory topology needs inspectable semantic anchors", timestamp: "2026-07-15T21:00:00.000Z", isBot: false, channelId: "aquarium" }],
channelSnapshots: [],
sourceRepoName: "AetheriaLore",
retrieval: {
searchHistory: async () => [{ chunkId: "history-1", sourceId: "message-1", sourceKind: "discord_message", text: "semantic memory topology and inspectable anchors", score: 0.84, metadata: { channelId: "aquarium" } }],
searchSources: async (_query, _limit, repoName) => repoName
? [{ chunkId: "source-home", sourceId: "doc-home", sourceKind: "source_document", text: "semantic memory topology preserves inspectable provenance anchors", score: 0.82, metadata: { repoName, path: "memory.md" } }]
: [{ chunkId: "source-global", sourceId: "doc-global", sourceKind: "source_document", text: "inspectable semantic topology joins memory evidence anchors", score: 0.8, metadata: { repoName: "VoidBot", path: "map.md" } }],
},
}) : undefined;
assert.equal(curiosityObservation?.status, "ok", "the curiosity source plans queries and acquires bounded neutral evidence through an injected retrieval port");
const unavailableCuriosity = populatedObservation.status === "ok" ? await readPersonaCuriosityEvidence({
identity: routedIdentity,
state: populatedObservation.typedState,
recentMessages: [{ id: "curiosity-failure", authorId: "human", authorName: "Operator", content: "semantic memory topology needs inspectable anchors", timestamp: "2026-07-15T21:00:00.000Z", isBot: false, channelId: "aquarium" }],
channelSnapshots: [],
retrieval: () => { throw new Error("vector adapter unavailable"); },
}) : undefined;
assert.deepEqual(unavailableCuriosity, { status: "unavailable", reason: "vector adapter unavailable" }, "retrieval-adapter construction fails inside the curiosity source's explicit unavailable witness");
const curiosityProjection = populatedObservation.status === "ok" && curiosityObservation ? projectPersonaCuriosityContext({
identity: routedIdentity,
state: populatedObservation.typedState,
recentMessages: [{ id: "curiosity-room", authorId: "human", authorName: "Operator", content: "memory topology needs inspectable semantic anchors", timestamp: "2026-07-15T21:00:00.000Z", isBot: false, channelId: "aquarium" }],
channelSnapshots: [],
observation: curiosityObservation,
backendDescription: "injected smoke retrieval",
}) : undefined;
assert.match(curiosityProjection ?? "", /Curiosity graph attractors:[\s\S]*injected smoke retrieval/, "the pure curiosity projector decodes and renders supplied evidence without acquiring it");
const pressureSections = populatedObservation.status === "ok"
? renderPersonaPressureSections({ identityName: "Nibu", state: populatedObservation.typedState, clarityPressureActive: true })
: [];

View file

@ -420,3 +420,4 @@
{"ts":"2026-07-16T00:44:55Z","type":"implementation","status":"ok","note":"Moved human-clarity chronology/repair pressure and mapped/unmapped social topology into persona-social-context-projector.ts. Corrected clarity ordering from nonexistent createdAt to SourceMessage.timestamp. Prompt assembly remains 153917 characters; smoke proves freshest timestamp selection and topology projection from supplied state/registry."}
{"ts":"2026-07-16T00:57:11Z","type":"implementation","status":"ok","note":"Persona social context now has one aggregate projector owning freshness, topology, peer openings, relationship-pressure scoring, pronouns, room texture, and clarity from supplied facts; the heartbeat shell no longer owns duplicate social scorers. TypeScript build, aggregate smoke, exact Nibu prompt assembly (153917 chars), dry-run, and operator surface tests passed."}
{"ts":"2026-07-16T01:07:27Z","type":"implementation","status":"ok","note":"Self-maintenance advocacy ownership moved from the heartbeat shell into persona-memory-projector. The projector now classifies typed needs/agency/candidates, suppresses maintenance under live clarity pressure, and orders it with other pressure sections; duplicate shell selectors and helpers were deleted. Build, targeted smoke, exact 153917-character Nibu prompt assembly, dry-run, and operator surface tests passed."}
{"ts":"2026-07-16T01:20:11Z","type":"implementation","status":"ok","note":"Persona curiosity split into a retrieval source, pure graph projector, and shared topic-term vocabulary. The source owns query planning/deduplication through an injected port; the projector owns graph scoring/rendering and unavailable/empty behavior; the heartbeat shell only wires the existing RAG adapter. Build, injected-port smoke, exact 153917-character Nibu prompt, dry-run, and operator tests passed."}

View file

@ -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: Map the heartbeat shell's curiosity pipeline from seed construction through vector retrieval to cluster rendering. Separate acquisition from pure projection, then move cluster ownership behind the smallest boundary that preserves those authorities.
next_action: Move human-pronoun guidance acquisition and evidence ranking out of `scripts/run-repo-face-heartbeats.ts` into a read-only Persona social-context source. Keep interaction-memory storage acquisition separate from the existing pure social projector.