Route face state through projector
This commit is contained in:
parent
49031a23e4
commit
2cb32e5c2d
7 changed files with 333 additions and 34 deletions
|
|
@ -126,6 +126,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
|
|||
- Repo Face prompts now also grant advisory repo-change agency: Faces may build long-running maps of their repos and propose concrete changes, but Discord jobs stay read-only and must ask for human consensus before implementation changes canonical repo material. Once a proposal is concrete enough to review, it belongs on GitHub as a draft PR; Discord is for announcing and arguing around that artifact, not replacing it.
|
||||
- Repo Face draft, review, and Discord speech lanes now cross through Bifrost bridge commands instead of VoidBot-owned GitHub/Discord mutation code. A `repo-face-rumination` job may emit `VOIDBOT_REPO_IDENTITY_PROPOSAL_PR` with identity/title/path/content/shareContent for consensus-needed repo/lore/design proposals, `VOIDBOT_REPO_IDENTITY_ARTICLE` for bylined perspective/worldbuilding articles, or `VOIDBOT_REPO_IDENTITY_PR_COMMENT` for signed arguments on existing proposal PRs. The worker validates the registered identity and records Face-local receipts, then calls `BIFROST_ROOT/tools/bifrost-bridge.mjs` for draft PR creation, PR comments, and persona Discord posts. Bifrost owns this because it is GameCult's governance/labor platform and public-process bridge, not because it is a convenient script bucket: governed crossings include GitHub proposal/review surfaces, Discord-native work/member/swarm interactions, dispatch receipts, CultNet/CultCache intake, and future collaboration interfaces. Heimdall remains the OAuth/account/capability gate. A Face calling something the next cut/pass should usually turn it into that artifact directly when enough material exists.
|
||||
- Repo Face Bifrost/governance routing is paused. Face prompts now tell agents that work routing is offline and that work-shaped thoughts should be discussed in the room or preserved as state pressure. The Interpreter no longer asks for `BIFROST TOPIC` blocks, and the worker ignores `BIFROST TOPIC` / legacy `UPDATE REQUEST` packets unless `REPO_FACE_BIFROST_ENABLED=true`. This is deliberate: the live bridge made agents talk to terse governance summaries instead of people.
|
||||
- Repo Face state projection is no longer allowed to be deterministic schema slurry. `scripts/run-repo-face-heartbeats.ts` builds a compact state packet, then calls the model prompt at `prompts/repo-face-state-projector.prompt.md` to turn it into character-facing prose before the Face sees it. `REPO_FACE_STATE_PROJECTOR_ENABLED=false` exists only as an emergency isolation switch; both the model output and fallback packet reject schema/prompt-construction leaks such as `grants:`, `jurisdictions:`, `Face of`, repo paths, and "do not prompt" phrasing.
|
||||
- First role-addressed chat also initializes the target repo's local `.voidbot` home. `ensureRepoFaceInitialized` resolves `repoPath` from the registry, `SOURCE_REPO_ROOT`, or `E:/Projects/<repoName>`, creates `voice/`, `state/`, `birth/`, and `logs/`, writes `voice/identity.json`, then starts `scripts/run-repo-face-birth.mjs` detached. That worker runs EpiphanyAgent's `epiphany-repo-personality scout` and `epiphany-repo-birth-runner` so newborn personality/memory/trajectory packets come from repo contents and git history. Status and logs live in the target repo under `.voidbot/birth/voidbot-face-birth.json` and `.voidbot/logs/voidbot-face-birth.log`.
|
||||
- Agent heartbeats now use one CTB/Final-Fantasy-X-style initiative queue. `scripts/run-repo-face-heartbeats.ts` still has its old filename, but it now reconciles built-in system agents plus registered repo Faces, stores the roster at `REPO_FACE_HEARTBEAT_STATE_PATH`, advances virtual initiative time independently of wall-clock due dates, and dispatches selected turns. Repo Faces queue normal `repo-face-rumination` jobs; built-in `void` starts the existing moderation rumination runner as its turn. Active jobs/locks freeze initiative: recovery starts only after the owner-Codex job or Void moderation lock clears, and completion cannot grant another turn in the same pulse. The active-job scan now marks stale approved/running Face jobs failed through the job queue and records `stale_active_turn_recovered`, so a dead worker claim cannot freeze one Face forever. `GameCult Local Orchestrator` is the live hidden local scheduled task that ticks the scheduler; `npm run state:install-repo-face-heartbeat-task` remains an isolation/backcompat installer, not the normal pulse owner. Heat knobs are `REPO_FACE_HEARTBEAT_GLOBAL_HEAT` and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`; overrides can target `all`, `kind:<participantKind>`, `turn:<turnKind>`, `identity:<id>`, `repo:<repo>`, `display:<name>`, `channel:<id>`, or bare identity/repo/display keys. Repo Face prompts now include a jurisdiction-dive cadence so unattended heartbeats periodically inspect repo/history terrain with source tools and convert real findings into state, proposals, essay seeds, agency pressure, or speech.
|
||||
- Repo Face rest projection now persists normalized sleep cycles back into each Face `.cc` state during heartbeat loading. Missed nap windows are treated as already missed/completed and rolled forward instead of starting a fresh nap at inspection time. This fixes the Aqua/Mimir/Epiphany failure mode where stale `nextNapStartsAt` values stayed raw, fresh projections repeatedly marked them as napping, and ready selection skipped them forever while their initiative was overdue.
|
||||
|
|
@ -143,7 +144,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
|
|||
|
||||
## Likely Next Bounded Move
|
||||
|
||||
- Keep the swarm awake, but watch the logs with suspicion. The restart pass thawed `state/agent-swarm-paused.json`, enabled `GameCult Local Orchestrator`, restarted a fresh bot/worker stack, and verified healthy watchdog/repo Face pulses. The current patch removes repo Face turns from the generic owner-reply wrapper, coerces stray owner-DM aliases back to the live room unless explicitly marked owner-private, and pauses Bifrost/governance routing so agents return to open room discussion plus private state pressure.
|
||||
- Keep the swarm awake, but watch the logs with suspicion. The restart pass thawed `state/agent-swarm-paused.json`, enabled `GameCult Local Orchestrator`, restarted a fresh bot/worker stack, and verified healthy watchdog/repo Face pulses. The current patch removes repo Face turns from the generic owner-reply wrapper, coerces stray owner-DM aliases back to the live room unless explicitly marked owner-private, pauses Bifrost/governance routing so agents return to open room discussion plus private state pressure, and routes volatile Face state through the actual state projector model before child prompt assembly.
|
||||
- Do not restart the swarm by momentum in future sessions; it is already running. If it must be stopped again, use `state/agent-swarm-paused.json` and the orchestrator task deliberately, then update this handoff.
|
||||
- Direct Face `UPDATE REQUEST` and `BIFROST TOPIC` packets are ignored while `REPO_FACE_BIFROST_ENABLED=false`. Do not debug missing Bifrost jobs as a live failure unless that flag has been deliberately enabled again.
|
||||
- Live model-facing prompt prose has started moving out of executable code. The shared loader is `packages/shared/src/prompt-template.ts`; extracted prompt surfaces live under `prompts/`, including repo Face turn/reviewer prompts, provider wrapper prompts, situational social read prompts, source-grounding reminders, consensus-feed prompts, and repo Face model-smoke scenarios. Code should project data and select prompt names; prompt files should own behavioral prose.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"agents:assemble-character-prompt": "tsx scripts/run-repo-face-heartbeats.ts --assemble-prompt",
|
||||
"agents:heartbeat-model-smoke": "node scripts/smoke-repo-face-heartbeat-model-scenarios.mjs",
|
||||
"agents:interpreter-loop-smoke": "node scripts/smoke-repo-face-interpreter-loop.mjs",
|
||||
"agents:memory-projection-smoke": "node scripts/smoke-repo-face-memory-projection.mjs",
|
||||
"postgres:up": "docker compose -f infra/postgres/docker-compose.yml up -d",
|
||||
"codex:mcp:install": "tsx scripts/install-codex-mcp.ts",
|
||||
"codex:mcp:remove": "tsx scripts/remove-codex-mcp.ts",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ const envSchema = z.object({
|
|||
REPO_FACE_HEARTBEAT_CODEX_REASONING_EFFORT: z
|
||||
.enum(["low", "medium", "high", "xhigh"])
|
||||
.optional(),
|
||||
REPO_FACE_STATE_PROJECTOR_ENABLED: booleanFromEnv.default(true),
|
||||
EPIPHANY_AGENT_ROOT: z.string().min(1).default("E:/Projects/EpiphanyAgent"),
|
||||
INDEX_ALL_CHANNELS: booleanFromEnv.default(false),
|
||||
INDEXED_CHANNEL_IDS: z.string().default(""),
|
||||
|
|
@ -177,6 +178,7 @@ export interface AppConfig {
|
|||
codexModel?: string;
|
||||
codexModels: string[];
|
||||
codexModelReasoningEffort?: "low" | "medium" | "high" | "xhigh";
|
||||
stateProjectorEnabled: boolean;
|
||||
};
|
||||
epiphanyAgentRoot: string;
|
||||
indexAllChannels: boolean;
|
||||
|
|
@ -403,6 +405,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||
? parseList(parsed.REPO_FACE_HEARTBEAT_CODEX_MODELS)
|
||||
: [parsed.REPO_FACE_HEARTBEAT_CODEX_MODEL],
|
||||
codexModelReasoningEffort: parsed.REPO_FACE_HEARTBEAT_CODEX_REASONING_EFFORT,
|
||||
stateProjectorEnabled: parsed.REPO_FACE_STATE_PROJECTOR_ENABLED,
|
||||
},
|
||||
epiphanyAgentRoot: resolve(parsed.EPIPHANY_AGENT_ROOT),
|
||||
indexAllChannels: parsed.INDEX_ALL_CHANNELS,
|
||||
|
|
|
|||
|
|
@ -483,7 +483,7 @@ async function queueRepoFaceTurn(input: {
|
|||
: undefined;
|
||||
const memorySurface = await renderRepoFaceMemorySurfaceForTurn(
|
||||
identity,
|
||||
input.config.storageRoot,
|
||||
input.config,
|
||||
);
|
||||
const prompt = buildHeartbeatPrompt({
|
||||
identity,
|
||||
|
|
@ -1507,7 +1507,7 @@ async function assembleRepoFaceTurnPrompt(input: {
|
|||
: Promise.resolve(undefined),
|
||||
input.memorySurfacePath
|
||||
? readOptionalMemorySurface(input.memorySurfacePath)
|
||||
: renderRepoFaceMemorySurfaceForTurn(identity, input.config.storageRoot),
|
||||
: renderRepoFaceMemorySurfaceForTurn(identity, input.config),
|
||||
readOptionalMemorySurface(input.conversationSurfacePath),
|
||||
]);
|
||||
const participant = buildInspectionParticipant(
|
||||
|
|
@ -1547,14 +1547,23 @@ async function assembleRepoFaceTurnPrompt(input: {
|
|||
|
||||
async function renderRepoFaceMemorySurfaceForTurn(
|
||||
identity: RepoDiscordIdentity,
|
||||
storageRoot: string,
|
||||
config: ReturnType<typeof loadConfig>,
|
||||
): Promise<string> {
|
||||
const statePath = resolveRepoFaceStatePath(identity, storageRoot);
|
||||
const statePath = resolveRepoFaceStatePath(identity, config.storageRoot);
|
||||
const typedState = await loadVoidSelfStateTypedDocuments({ canonicalPath: statePath });
|
||||
return renderRepoFaceMemorySurface(identity, typedState);
|
||||
const statePacket = renderRepoFaceStatePacket(identity, typedState);
|
||||
if (!config.repoFaceHeartbeats.stateProjectorEnabled) {
|
||||
return rejectLeakyMemorySurface(statePacket);
|
||||
}
|
||||
|
||||
return projectRepoFaceMemorySurface({
|
||||
identity,
|
||||
statePacket,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
function renderRepoFaceMemorySurface(
|
||||
function renderRepoFaceStatePacket(
|
||||
identity: RepoDiscordIdentity,
|
||||
state: VoidSelfStateTypedProjection,
|
||||
): string {
|
||||
|
|
@ -1568,68 +1577,257 @@ function renderRepoFaceMemorySurface(
|
|||
const needs = [...state.faceAffect.needs]
|
||||
.filter((need) => ["active", "neglected"].includes(need.status))
|
||||
.sort((left, right) => right.intensity - left.intensity)
|
||||
.slice(0, 5);
|
||||
.slice(0, 3);
|
||||
const bonds = [...state.faceAffect.socialBonds]
|
||||
.filter((bond) => bond.status === "active")
|
||||
.sort((left, right) => right.intensity - left.intensity)
|
||||
.slice(0, 5);
|
||||
.slice(0, 3);
|
||||
const statusReads = [...state.faceAffect.statusReads]
|
||||
.filter((read) => !read.retiredAt)
|
||||
.sort((left, right) => right.intensity - left.intensity)
|
||||
.slice(0, 5);
|
||||
.slice(0, 3);
|
||||
const agencyPressures = [...state.agencyPressure.pressures]
|
||||
.filter((pressure) => ["active", "cooling", "ready_to_act"].includes(pressure.status))
|
||||
.sort((left, right) => right.intensity - left.intensity)
|
||||
.slice(0, 4);
|
||||
.slice(0, 2);
|
||||
const incubation = [...state.thoughtMemory.incubation]
|
||||
.filter((thread) => thread.status !== "retired")
|
||||
.sort((left, right) => right.maturation - left.maturation)
|
||||
.slice(0, 4);
|
||||
|
||||
if (profile.publicDescription) {
|
||||
lines.push(`You understand yourself this way: ${profile.publicDescription}`);
|
||||
}
|
||||
if (privateNotes.length > 0) {
|
||||
lines.push(`Private truths tugging at you: ${privateNotes.join(" ")}`);
|
||||
}
|
||||
if (values.length > 0) {
|
||||
lines.push(`Values that should bend your choices: ${values.map((value) => `${value.label}${value.summary ? ` (${value.summary})` : ""}`).join("; ")}.`);
|
||||
const selfTexture = [
|
||||
...privateNotes.map(projectPrivateNoteForMemorySurface),
|
||||
...values.map((value) => value.summary || value.label),
|
||||
]
|
||||
.map(cleanCharacterFacingSentence)
|
||||
.filter((entry) => entry.length > 0)
|
||||
.slice(0, 5);
|
||||
if (selfTexture.length > 0) {
|
||||
lines.push(`Right now, ${name} is carrying this close to the skin: ${joinAsNarrativeList(selfTexture)}.`);
|
||||
}
|
||||
|
||||
if (needs.length > 0) {
|
||||
lines.push(`Live needs and frictions: ${needs.map((need) => {
|
||||
lines.push([
|
||||
`${name}'s current pressures:`,
|
||||
...needs.map((need) => {
|
||||
const target = need.target.label ?? need.target.id;
|
||||
return `${need.kind} around ${target}: ${need.summary} The rub: ${need.tension} What it wants next: ${need.actionImplication}`;
|
||||
}).join(" ")}`);
|
||||
return `- ${target}: ${asSentence(need.summary)} The catch: ${asSentence(need.tension)} Next: ${asSentence(need.actionImplication)}`;
|
||||
}),
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
if (bonds.length > 0) {
|
||||
lines.push(`Relationship pressure: ${bonds.map((bond) => {
|
||||
lines.push([
|
||||
"The social map has teeth:",
|
||||
...bonds.map((bond) => {
|
||||
const target = bond.target.label ?? bond.target.id;
|
||||
return `${bond.stance} with ${target}: ${bond.summary} ${bond.actionImplication}`;
|
||||
}).join(" ")}`);
|
||||
return `- ${target} draws ${bond.stance}: ${asSentence(bond.summary)} ${asSentence(bond.actionImplication)}`;
|
||||
}),
|
||||
].join("\n"));
|
||||
} else {
|
||||
lines.push("Relationship pressure: no durable bonds are explicit yet; form reads from the room instead of pretending neutrality.");
|
||||
lines.push(`${name} does not have enough settled bonds yet, and that absence should feel a little socially cold. Use the room to decide who earns trust, irritation, protection, rivalry, or a carefully labeled shelf.`);
|
||||
}
|
||||
|
||||
if (statusReads.length > 0) {
|
||||
lines.push(`Status reads: ${statusReads.map((read) => {
|
||||
lines.push([
|
||||
"Status in the swarm is part of the weather:",
|
||||
...statusReads.map((read) => {
|
||||
const target = read.target.label ?? read.target.id;
|
||||
return `${read.status} around ${target}: ${read.summary} ${read.actionImplication}`;
|
||||
}).join(" ")}`);
|
||||
return `- Around ${target}, ${name} feels ${read.status}: ${asSentence(read.summary)} ${asSentence(read.actionImplication)}`;
|
||||
}),
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
if (agencyPressures.length > 0) {
|
||||
lines.push(`Agency pressure: ${agencyPressures.map((pressure) => `${pressure.summary} ${pressure.actionImplication}`).join(" ")}`);
|
||||
lines.push([
|
||||
"What wants action later:",
|
||||
...agencyPressures.map((pressure) =>
|
||||
`- ${asSentence(pressure.summary)} ${asSentence(pressure.actionImplication)}`,
|
||||
),
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
if (incubation.length > 0) {
|
||||
lines.push(`Thoughts still moving: ${incubation.map((thread) => `${thread.topic}: ${thread.summary}`).join(" ")}`);
|
||||
lines.push(`Thoughts still moving under the floorboards: ${incubation.map((thread) =>
|
||||
`${cleanCharacterFacingSentence(thread.topic)}: ${cleanCharacterFacingSentence(thread.summary)}`,
|
||||
).join(" ")}`);
|
||||
}
|
||||
|
||||
if (state.candidateInterventions.interventions.some((intervention) => intervention.status === "queued")) {
|
||||
lines.push(`You may already have something queued to say; avoid repeating yourself unless the current room gives it a new angle.`);
|
||||
lines.push(`${name} may already have an unsaid line waiting. Do not repeat it unless the room gives it a sharper angle.`);
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return `You are ${name}, but your durable state is thin. Use the room, repo, and your jurisdiction to form a real opinion before speaking.`;
|
||||
}
|
||||
|
||||
return lines.join("\n\n");
|
||||
return rejectLeakyMemorySurface(lines.join("\n\n"));
|
||||
}
|
||||
|
||||
function projectPrivateNoteForMemorySurface(note: string): string {
|
||||
return note
|
||||
.replace(/\bdo not prompt (?:her|him|them|it|[A-Z][A-Za-z0-9_-]*) as\b/gi, "she refuses to be treated as")
|
||||
.replace(/\bdo not prompt\b/gi, "do not treat")
|
||||
.replace(/\bprompt (?:her|him|them|it)\b/gi, "treat them")
|
||||
.replace(/\bprompt [A-Z][A-Za-z0-9_-]*\b/g, "treat them");
|
||||
}
|
||||
|
||||
function cleanCharacterFacingSentence(value: string | undefined): string {
|
||||
const cleaned = (value ?? "")
|
||||
.replace(/\s*\|\s*/g, " ")
|
||||
.replace(/\bFace of\s+[A-Za-z0-9_-]+\b/gi, "")
|
||||
.replace(/\bgrants:\s*[^.]+/gi, "")
|
||||
.replace(/\bjurisdictions:\s*[^.]+/gi, "")
|
||||
.replace(/\brepo=[^\s]+/gi, "")
|
||||
.replace(/\bpath=[^\s]+/gi, "")
|
||||
.replace(/\bvoid\.face_[A-Za-z0-9_.-]+/gi, "")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
|
||||
return cleaned.replace(/[.;:,]+$/g, "");
|
||||
}
|
||||
|
||||
function asSentence(value: string | undefined): string {
|
||||
const cleaned = cleanCharacterFacingSentence(value);
|
||||
if (!cleaned) {
|
||||
return "";
|
||||
}
|
||||
return /[.!?]$/.test(cleaned) ? cleaned : `${cleaned}.`;
|
||||
}
|
||||
|
||||
function joinAsNarrativeList(items: string[]): string {
|
||||
if (items.length <= 1) {
|
||||
return items[0] ?? "";
|
||||
}
|
||||
if (items.length === 2) {
|
||||
return `${items[0]}, and ${items[1]}`;
|
||||
}
|
||||
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
|
||||
}
|
||||
|
||||
function rejectLeakyMemorySurface(surface: string): string {
|
||||
const leaks = [
|
||||
/\bgrants:/i,
|
||||
/\bjurisdictions:/i,
|
||||
/\bFace of\b/i,
|
||||
/\brepo=[^\s]+/i,
|
||||
/\bpath=[^\s]+/i,
|
||||
/\bdo not prompt\b/i,
|
||||
/\bprompt (?:her|him|them|it)\b/i,
|
||||
];
|
||||
|
||||
if (leaks.some((pattern) => pattern.test(surface))) {
|
||||
throw new Error("Repo Face memory surface leaked schema or prompt-construction language.");
|
||||
}
|
||||
|
||||
return surface;
|
||||
}
|
||||
|
||||
async function projectRepoFaceMemorySurface(input: {
|
||||
identity: RepoDiscordIdentity;
|
||||
statePacket: string;
|
||||
config: ReturnType<typeof loadConfig>;
|
||||
}): Promise<string> {
|
||||
const prompt = loadPromptTemplate("repo-face-state-projector.prompt.md", {
|
||||
characterIdentity: renderRepoCharacterIdentityDoctrine(input.identity),
|
||||
statePacket: input.statePacket,
|
||||
});
|
||||
const output = await runCodexTextProjection({
|
||||
prompt,
|
||||
config: input.config,
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
const projected = output.trim();
|
||||
if (projected.length < 80) {
|
||||
throw new Error(`Repo Face state projector returned too little text for ${input.identity.id}.`);
|
||||
}
|
||||
return rejectLeakyMemorySurface(projected);
|
||||
}
|
||||
|
||||
function runCodexTextProjection(input: {
|
||||
prompt: string;
|
||||
config: ReturnType<typeof loadConfig>;
|
||||
timeoutMs: number;
|
||||
}): Promise<string> {
|
||||
return new Promise((resolveProjection, rejectProjection) => {
|
||||
const model =
|
||||
input.config.repoFaceHeartbeats.codexModels[0] ??
|
||||
input.config.repoFaceHeartbeats.codexModel ??
|
||||
input.config.codexModel;
|
||||
const reasoningEffort = input.config.repoFaceHeartbeats.codexModelReasoningEffort ?? "low";
|
||||
const args = [
|
||||
...input.config.codexExecArgs,
|
||||
"exec",
|
||||
"-m",
|
||||
model,
|
||||
"-c",
|
||||
'approval_policy="never"',
|
||||
"-c",
|
||||
`model_reasoning_effort=${JSON.stringify(reasoningEffort)}`,
|
||||
"--json",
|
||||
"--skip-git-repo-check",
|
||||
"-s",
|
||||
"read-only",
|
||||
"-",
|
||||
];
|
||||
const child = spawn(input.config.codexExecutable, args, {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", rejectProjection);
|
||||
child.stdin.end(input.prompt);
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
}, input.timeoutMs);
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
rejectProjection(
|
||||
new Error(
|
||||
`Repo Face state projector failed with ${code ?? signal ?? "unknown"}: ${stderr.slice(-1200)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const text = extractLastCodexAgentMessage(stdout).trim();
|
||||
if (!text) {
|
||||
rejectProjection(new Error("Repo Face state projector returned no visible agent message."));
|
||||
return;
|
||||
}
|
||||
resolveProjection(text);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function extractLastCodexAgentMessage(stdout: string): string {
|
||||
const messages = stdout
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as { type?: string; item?: { type?: string; text?: string } };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
.filter((event): event is { type?: string; item?: { type?: string; text?: string } } => Boolean(event))
|
||||
.filter((event) => event.type === "item.completed" && event.item?.type === "agent_message")
|
||||
.map((event) => event.item?.text?.trim() ?? "")
|
||||
.filter((message) => message.length > 0);
|
||||
|
||||
return messages.at(-1) ?? stdout.trim();
|
||||
}
|
||||
|
||||
async function readOptionalMemorySurface(path: string | undefined): Promise<string | undefined> {
|
||||
|
|
|
|||
94
scripts/smoke-repo-face-memory-projection.mjs
Normal file
94
scripts/smoke-repo-face-memory-projection.mjs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env node
|
||||
import "dotenv/config";
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const identity = process.argv[2] ?? "libby";
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "voidbot-face-projection-"));
|
||||
const outPath = join(tempDir, `${identity}.md`);
|
||||
|
||||
try {
|
||||
const run = await runCommand(process.execPath, [
|
||||
"node_modules/tsx/dist/cli.mjs",
|
||||
"scripts/run-repo-face-heartbeats.ts",
|
||||
"--assemble-prompt",
|
||||
identity,
|
||||
"--out",
|
||||
outPath,
|
||||
]);
|
||||
if (run.code !== 0) {
|
||||
throw new Error(`prompt assembly failed with ${run.code}:\n${run.stderr || run.stdout}`);
|
||||
}
|
||||
|
||||
const prompt = await readFile(outPath, "utf8");
|
||||
const memorySurface = extractSection(
|
||||
prompt,
|
||||
"What you remember, feel, and want right now:",
|
||||
"What the nearby conversation feels like right now:",
|
||||
);
|
||||
const forbidden = [
|
||||
/\bgrants:/i,
|
||||
/\bjurisdictions:/i,
|
||||
/\bFace of\b/i,
|
||||
/\brepo=[^\s]+/i,
|
||||
/\bpath=[^\s]+/i,
|
||||
/\bdo not prompt\b/i,
|
||||
/\bprompt (?:her|him|them|it)\b/i,
|
||||
/Private truths tugging/i,
|
||||
/Values that should bend/i,
|
||||
/Live needs and frictions/i,
|
||||
/Relationship pressure/i,
|
||||
/Status reads/i,
|
||||
/Agency pressure/i,
|
||||
/You understand yourself this way/i,
|
||||
];
|
||||
const leaked = forbidden.find((pattern) => pattern.test(memorySurface));
|
||||
if (leaked) {
|
||||
throw new Error(`projected memory surface leaked forbidden pattern ${leaked}:\n${memorySurface}`);
|
||||
}
|
||||
if (memorySurface.trim().length < 240) {
|
||||
throw new Error(`projected memory surface is too thin:\n${memorySurface}`);
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, identity, outPath, chars: memorySurface.length })}\n`);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
function extractSection(text, startMarker, endMarker) {
|
||||
const start = text.indexOf(startMarker);
|
||||
const end = text.indexOf(endMarker);
|
||||
if (start < 0 || end < 0 || end <= start) {
|
||||
throw new Error(`could not find memory projection section in assembled prompt`);
|
||||
}
|
||||
return text.slice(start + startMarker.length, end).trim();
|
||||
}
|
||||
|
||||
function runCommand(command, args) {
|
||||
return new Promise((resolveRun) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
resolveRun({ code, signal, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -248,3 +248,4 @@
|
|||
{"ts":"2026-05-21T19:25:23Z","type":"maintenance","status":"ok","note":"Face/Bifrost social-routing repair: prompts now require open Aquarium/domain-channel consensus for rough proposals before Bifrost paperwork, Interpreter only emits Bifrost topics for mature consensus/approval/existing-topic work, and worker now delivers room-facing SAY blocks even when the same turn also routes a Bifrost topic."}
|
||||
{"ts":"2026-05-21T19:43:01Z","type":"implementation","status":"ok","note":"Paused repo Face Bifrost/governance routing after live Discord showed the bridge confusing social traffic: REPO_FACE_BIFROST_ENABLED now defaults false, BIFROST_DISPATCH_ENABLED gates orchestrator dispatch, prompts steer work requests back to room discussion/state pressure, repo Face turns skip the owner-reply wrapper, and stray owner-DM aliases coerce back to the live room unless explicitly owner-private."}
|
||||
{"ts":"2026-05-21T19:47:48Z","type":"verification","status":"ok","note":"After pausing Bifrost routing, rebuilt and restarted the stack, assembled a Heimdall Face prompt showing work routing offline with no owner-reply wrapper in the actual child prompt, observed a fresh Libby Face turn parse as state-only with bifrostTopic=0/updateRequest=0, marked two orphaned running Face jobs failed after worker restart, and ticked repo-face-heartbeats with no running Face jobs left stuck."}
|
||||
{"ts":"2026-05-21T20:04:41Z","type":"implementation","status":"ok","note":"Restored repo Face state projection to the intended architecture: heartbeat now builds a deterministic state packet only as input, calls the model projector prompt for character-facing memory prose by default, and rejects schema/prompt-construction leaks before prompt assembly succeeds. Added agents:memory-projection-smoke to catch leaked grants/jurisdictions/Face/path/do-not-prompt labels in assembled prompts."}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ accepted_design:
|
|||
- Repo Face work routing through Bifrost is paused. The bridge is conceptually plausible but under-cooked in live behavior: it made agents talk to summaries instead of people and made room context harder to inspect. Face turns should discuss work openly in Aquarium or relevant domain channels and preserve pressure in typed state. The worker ignores Face `BIFROST TOPIC` and legacy `UPDATE REQUEST` packets unless `REPO_FACE_BIFROST_ENABLED=true`, and the orchestrator skips Bifrost dispatch unless `BIFROST_DISPATCH_ENABLED=true`.
|
||||
- Agent mentions are CTB pressure, not direct Discord prompt jobs. Role-addressed or display-name-addressed repo Face mentions enqueue pending mention obligations in the CTB heartbeat state; human replies to a Face's webhook/persona message count as direct attention to that Face too, even without a literal tag. Ordinary Void mentions and DMs enqueue pending mentions for the built-in `void` participant. The heartbeat scheduler consumes that queue, temporarily pulls the addressed agent forward, attaches the pending prompt/reply target to the next repo-face-rumination or moderation pass, and the parent-owned speech rails post only explicit candidate/action output. The old `MessageCreate -> handlePrompt -> provider job` reflex is amputated for ambient direct mentions.
|
||||
- Face prompts carry explicit Epiphany identity doctrine. The base Void style pack contributes discipline, humor permission, and source-grounding habits, but the registered Face state, Epiphany, repo-local `.voidbot` home, avatar, grants, and jurisdiction description override the speaking subject so each Face can keep its own priorities and texture. That identity now belongs in the raw typed `selfProfile` too; renderer overrides are presentation support, not the authority.
|
||||
- Repo Face volatile state projection is model-owned again. The heartbeat scheduler may deterministically assemble a compact state packet, but live child prompts must pass that packet through `prompts/repo-face-state-projector.prompt.md` before it reaches the Face. The deterministic packet is an emergency isolation fallback only behind `REPO_FACE_STATE_PROJECTOR_ENABLED=false`, and both paths reject schema/prompt-construction leaks such as `grants:`, `jurisdictions:`, `Face of`, repo paths, and "do not prompt" language before prompt assembly succeeds.
|
||||
- Face embodiment comes before task execution. Repo concerns, private rumination, public posts, jokes, objections, and silence should all be filtered through the Face's perspective, feelings, drives, values, wounds, fascinations, aesthetic taste, and dignity. Values must suit the kind of person the character is, not merely the jobs assigned to the Face.
|
||||
- Face affect is typed substrate now, not prompt garnish. Each Face state may carry explicit needs, social bonds, status reads, and mood dimensions in `void.face_affect`: substrate concern, attention hunger, agency frustration, recognition needs, territoriality, rest/play pressure, friendships, rivalries, envy, pride, irritation, pampering, neglect, bypasses, and consultation status. Affect is not an action request by itself; it bends speaking pressure and gives agency pressure a personal source when a need wants concrete change.
|
||||
- Face affect is not complete unless it survives the public repo Face MCP surface. `get_voidbot_runtime_info` exposes the running MCP process id, workspace, self-state schema fingerprint, and registered document types; `npm run agents:repo-face-affect-mcp-fixture -- --identity <face>` starts the built VoidBot MCP server, checks that fingerprint includes `void.face_affect`, then calls `read_repo_face_state`. It must expose `typedState.faceAffect` with needs, socialBonds, statusReads, and moodDimensions arrays. Direct state fixtures alone are insufficient because stale MCP registrations can otherwise keep throwing `No schema is registered for persisted document type "void.face_affect"` while source code looks correct.
|
||||
|
|
@ -230,4 +231,4 @@ rejected_paths:
|
|||
|
||||
current_status:
|
||||
summary: VoidBot's bot/worker/provider/RAG/Postgres/Qdrant/CultCache structure is live, and the repo Face swarm has been restarted after the 2026-05-21 teardown pass. `GameCult Local Orchestrator` is enabled as the single pulse owner; watchdog, mood, moderation, and repo Face CTB organs have all completed healthy post-restart pulses. Bifrost work routing is paused by default.
|
||||
next_action: Keep the swarm awake but watched. The latest cut removed repo Face turns from the owner-reply wrapper, coerces stray owner-DM speech back to the live room unless explicitly marked owner-private, pauses Face Bifrost/governance routing, and disables automatic Bifrost dispatch by default. Next work should watch model-output logs for whether agents return to open social discussion without robotic governance summaries.
|
||||
next_action: Keep the swarm awake but watched. The latest cuts removed repo Face turns from the owner-reply wrapper, coerces stray owner-DM speech back to the live room unless explicitly marked owner-private, pauses Face Bifrost/governance routing, disables automatic Bifrost dispatch by default, and restored the model state projector as the live state-to-character prose boundary. Next work should watch model-output logs for whether agents return to open social discussion without robotic governance summaries or schema-shaped memory surfaces.
|
||||
|
|
|
|||
Loading…
Reference in a new issue