VoidBot/scripts/replay-metacrat-thread.ts
2026-06-06 00:18:46 +01:00

1149 lines
40 KiB
TypeScript

import "dotenv/config";
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import {
loadVoidSelfStateTypedDocuments,
writeVoidSelfStateTypedDocuments,
type VoidSelfStateTypedProjection,
} from "@voidbot/core";
import { loadConfig } from "@voidbot/config";
import {
createTextEmbedder,
FileMessageArchiveRepository,
FileVectorStore,
QdrantVectorStore,
type ArchivedMessageRecord,
} from "@voidbot/rag";
import {
loadPromptTemplate,
type CodexMcpServerConfig,
type EmbeddingChunk,
type RetrievalResult,
type VectorStore,
} from "@voidbot/shared";
const DEFAULT_METACRAT_AUTHOR_ID = "113785782975594501";
interface CliOptions {
targetMessageId: string;
statePath: string;
personaId: string;
publicName: string;
metacratAuthorId: string;
before: number;
after: number;
heldoutMinutes: number;
contextMaxAgeMinutes: number;
semanticMemory: boolean;
semanticMemoryLimit: number;
outRoot: string;
runCodex: boolean;
evaluateCodex: boolean;
}
interface CodexResult {
stdout: string;
stderr: string;
exitCode: number | null;
timedOut: boolean;
finalMessage: string;
}
interface SemanticMemoryReplay {
enabled: boolean;
query: string;
vectorStore: "qdrant" | "local_json";
collectionName: string;
embedderId: string;
indexedMemoryCount: number;
indexedChunkCount: number;
results: RetrievalResult[];
}
async function main(): Promise<void> {
if (!process.env.DISCORD_OWNER_ID) {
process.env.DISCORD_OWNER_ID = "__persona_replay__";
}
const options = parseArgs(process.argv.slice(2));
const config = loadConfig();
const archive = new FileMessageArchiveRepository(config.ragArchivePath);
const target = await archive.get(options.targetMessageId);
if (!target) {
throw new Error(`Target Discord message ${options.targetMessageId} was not found in ${config.ragArchivePath}.`);
}
const messages = await archive.listContextWindow(options.targetMessageId, options.before, options.after);
if (messages.length === 0) {
throw new Error(`No context window was found for ${options.targetMessageId}.`);
}
const cutoff = new Date(Date.parse(target.timestamp) - 1).toISOString();
const anchorTimestamps = new Map(
(await archive.listAll()).map((message) => [message.id, message.timestamp]),
);
const sourceState = await loadVoidSelfStateTypedDocuments({
canonicalPath: options.statePath,
identity: {
agentId: options.personaId,
publicName: options.publicName,
},
});
const pruneResult = pruneStateAfter(sourceState, cutoff, anchorTimestamps);
const artifactDir = buildArtifactDir(options.outRoot, options.targetMessageId);
const temporalStatePath = join(artifactDir, "temporal-state.cc");
const promptPath = join(artifactDir, "replay-prompt.md");
const evalPromptPath = join(artifactDir, "evaluation-prompt.md");
const reportPath = join(artifactDir, "replay-report.json");
const toolLogPath = join(artifactDir, "persona-memory-tool-calls.jsonl");
await mkdir(artifactDir, { recursive: true });
await writeVoidSelfStateTypedDocuments({
canonicalPath: temporalStatePath,
identity: {
agentId: options.personaId,
publicName: options.publicName,
},
}, pruneResult.state);
const inputMessages = filterInputMessagesByMaxAge(
messages.filter((message) => message.timestamp <= target.timestamp),
target.timestamp,
options.contextMaxAgeMinutes,
);
const heldOutMessages = messages.filter((message) =>
message.timestamp > target.timestamp &&
isWithinHeldoutWindow(message.timestamp, target.timestamp, options.heldoutMinutes)
);
const actualMetacratMessages = heldOutMessages.filter((message) => message.authorId === options.metacratAuthorId);
const semanticMemory = await prepareReplaySemanticMemory({
config,
state: pruneResult.state,
inputMessages,
artifactDir,
options,
});
const projectedMemory = await projectReplayPersonaMemorySurface({
config,
state: pruneResult.state,
inputMessages,
semanticMemory,
options,
});
await writeFile(join(artifactDir, "projected-memory.md"), projectedMemory, "utf8");
const prompt = buildReplayPrompt({
sourcePath: temporalStatePath,
target,
inputMessages,
heldOutMessages,
actualMetacratMessages,
pruneWarnings: pruneResult.warnings,
semanticMemory,
projectedMemory,
options,
});
await writeFile(promptPath, prompt, "utf8");
let generation: CodexResult | undefined;
if (options.runCodex) {
generation = await runCodex(prompt, config, buildReplayMemoryMcpServers({
semanticMemory,
toolLogPath,
options,
}));
await writeFile(join(artifactDir, "codex-generation.stdout.jsonl"), generation.stdout, "utf8");
await writeFile(join(artifactDir, "codex-generation.stderr.txt"), generation.stderr, "utf8");
}
const predictedText = generation?.finalMessage ?? "";
const actualText = actualMetacratMessages.map(renderMessageContentOnly).join("\n");
const deterministicComparison = compareText(predictedText, actualText);
let evaluation: CodexResult | undefined;
if (options.evaluateCodex && generation) {
const evalPrompt = buildEvaluationPrompt({
target,
inputMessages,
actualMetacratMessages,
predictedText,
deterministicComparison,
});
await writeFile(evalPromptPath, evalPrompt, "utf8");
evaluation = await runCodex(evalPrompt, config);
await writeFile(join(artifactDir, "codex-evaluation.stdout.jsonl"), evaluation.stdout, "utf8");
await writeFile(join(artifactDir, "codex-evaluation.stderr.txt"), evaluation.stderr, "utf8");
}
const report = {
targetMessageId: options.targetMessageId,
targetTimestamp: target.timestamp,
stateCutoff: cutoff,
targetAuthor: target.authorName,
channelId: target.channelId,
channelName: target.metadata?.channelName,
artifactDir,
temporalStatePath,
promptPath,
evaluationPromptPath: options.evaluateCodex && generation ? evalPromptPath : undefined,
counts: {
inputMessages: inputMessages.length,
heldOutMessages: heldOutMessages.length,
actualMetacratMessages: actualMetacratMessages.length,
survivingMemories: pruneResult.state.thoughtMemory.memories.length,
semanticMemoryResults: semanticMemory.results.length,
survivingBonds: pruneResult.state.personaAffect.socialBonds.length,
survivingNeeds: pruneResult.state.personaAffect.needs.length,
survivingStressResponses: pruneResult.state.personaAffect.stressResponses.length,
survivingDoctrineStances: pruneResult.state.personaAffect.doctrineStances.length,
},
prune: {
removed: pruneResult.removed,
warnings: pruneResult.warnings,
},
semanticMemory: {
...semanticMemory,
results: semanticMemory.results.map((result) => ({
chunkId: result.chunkId,
score: result.score,
sourceId: result.sourceId,
memoryId: result.metadata.memoryId,
target: result.metadata.targetLabel,
text: result.text,
})),
toolLogPath,
},
projectedMemory,
actualMetacratMessages: actualMetacratMessages.map(toReportMessage),
predictedReply: predictedText || undefined,
deterministicComparison,
modelEvaluation: evaluation?.finalMessage,
};
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(JSON.stringify({
artifactDir,
temporalStatePath,
promptPath,
reportPath,
target: {
id: target.id,
timestamp: target.timestamp,
authorName: target.authorName,
},
counts: report.counts,
removed: pruneResult.removed,
warnings: pruneResult.warnings,
semanticMemory: {
enabled: semanticMemory.enabled,
vectorStore: semanticMemory.vectorStore,
collectionName: semanticMemory.collectionName,
indexedMemoryCount: semanticMemory.indexedMemoryCount,
indexedChunkCount: semanticMemory.indexedChunkCount,
dryRunTopResults: semanticMemory.results.map((result) => ({
score: result.score,
memoryId: result.metadata.memoryId,
})),
toolLogPath,
},
ranCodex: options.runCodex,
evaluated: Boolean(evaluation),
deterministicComparison,
}, null, 2));
}
function parseArgs(args: string[]): CliOptions {
const options: Partial<CliOptions> = {
statePath: "state/personas/metacrat.cc",
personaId: "metacrat",
publicName: "Metacrat",
metacratAuthorId: DEFAULT_METACRAT_AUTHOR_ID,
before: 18,
after: 18,
heldoutMinutes: 30,
contextMaxAgeMinutes: 0,
semanticMemory: true,
semanticMemoryLimit: 6,
outRoot: ".voidbot/artifacts/persona-replay",
runCodex: false,
evaluateCodex: false,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
const next = args[index + 1];
if (arg === "--target-message-id" && next) {
options.targetMessageId = next;
index += 1;
} else if (arg === "--state" && next) {
options.statePath = next;
index += 1;
} else if (arg === "--persona-id" && next) {
options.personaId = next;
index += 1;
} else if (arg === "--public-name" && next) {
options.publicName = next;
index += 1;
} else if (arg === "--metacrat-author-id" && next) {
options.metacratAuthorId = next;
index += 1;
} else if (arg === "--before" && next) {
options.before = parsePositiveInt(next, "--before");
index += 1;
} else if (arg === "--after" && next) {
options.after = parsePositiveInt(next, "--after");
index += 1;
} else if (arg === "--heldout-minutes" && next) {
options.heldoutMinutes = parsePositiveInt(next, "--heldout-minutes");
index += 1;
} else if (arg === "--context-max-age-minutes" && next) {
options.contextMaxAgeMinutes = parsePositiveInt(next, "--context-max-age-minutes");
index += 1;
} else if (arg === "--semantic-memory-limit" && next) {
options.semanticMemoryLimit = parsePositiveInt(next, "--semantic-memory-limit");
index += 1;
} else if (arg === "--no-semantic-memory") {
options.semanticMemory = false;
} else if (arg === "--out" && next) {
options.outRoot = next;
index += 1;
} else if (arg === "--run-codex") {
options.runCodex = true;
} else if (arg === "--evaluate-codex") {
options.evaluateCodex = true;
options.runCodex = true;
} else if (arg === "--help") {
printHelpAndExit();
} else {
throw new Error(`Unknown or incomplete argument: ${arg}`);
}
}
if (!options.targetMessageId) {
throw new Error("--target-message-id is required.");
}
return {
targetMessageId: options.targetMessageId,
statePath: resolve(options.statePath ?? "state/personas/metacrat.cc"),
personaId: options.personaId ?? "metacrat",
publicName: options.publicName ?? "Metacrat",
metacratAuthorId: options.metacratAuthorId ?? DEFAULT_METACRAT_AUTHOR_ID,
before: options.before ?? 18,
after: options.after ?? 18,
heldoutMinutes: options.heldoutMinutes ?? 30,
contextMaxAgeMinutes: options.contextMaxAgeMinutes ?? 0,
semanticMemory: options.semanticMemory ?? true,
semanticMemoryLimit: options.semanticMemoryLimit ?? 6,
outRoot: resolve(options.outRoot ?? ".voidbot/artifacts/persona-replay"),
runCodex: options.runCodex ?? false,
evaluateCodex: options.evaluateCodex ?? false,
};
}
function printHelpAndExit(): never {
console.log([
"Usage: npm run persona:replay-thread -- --target-message-id <discord-id> [options]",
"",
"Builds a temporal copy of Metacrat's Persona .cc, removes state after the target event,",
"feeds the model the actual Discord thread up to the target message, and compares any",
"generated reply against held-out real Metacrat messages after the target.",
"",
"Options:",
" --state <path> Source Persona .cc. Defaults to state/personas/metacrat.cc",
" --before <n> Context messages before target. Defaults to 18",
" --after <n> Held-out messages after target. Defaults to 18",
" --heldout-minutes <n> Limit held-out truth to n minutes after target. Defaults to 30; 0 disables the time limit",
" --context-max-age-minutes <n> Drop input messages older than n minutes before target. Defaults to 0, disabled",
" --semantic-memory-limit <n> Max replay memory tool results. Defaults to 6",
" --no-semantic-memory Disable replay-scoped Qdrant/MCP memory access",
" --out <dir> Artifact root. Defaults to .voidbot/artifacts/persona-replay",
" --run-codex Generate a predicted reply with local Codex exec",
" --evaluate-codex Also ask Codex to compare prediction to held-out truth",
].join("\n"));
process.exit(0);
}
function parsePositiveInt(value: string, label: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error(`${label} must be a non-negative integer.`);
}
return parsed;
}
function isWithinHeldoutWindow(timestamp: string, targetTimestamp: string, minutes: number): boolean {
if (minutes === 0) {
return true;
}
return Date.parse(timestamp) <= Date.parse(targetTimestamp) + minutes * 60_000;
}
function filterInputMessagesByMaxAge(
messages: ArchivedMessageRecord[],
targetTimestamp: string,
maxAgeMinutes: number,
): ArchivedMessageRecord[] {
if (maxAgeMinutes === 0) {
return messages;
}
const cutoff = Date.parse(targetTimestamp) - maxAgeMinutes * 60_000;
return messages.filter((message) => Date.parse(message.timestamp) >= cutoff);
}
async function prepareReplaySemanticMemory(input: {
config: ReturnType<typeof loadConfig>;
state: VoidSelfStateTypedProjection;
inputMessages: ArchivedMessageRecord[];
artifactDir: string;
options: CliOptions;
}): Promise<SemanticMemoryReplay> {
const query = buildMemoryQuery(input.inputMessages);
const embedder = createTextEmbedder({
backend: input.config.ragEmbeddingBackend,
hashDimensions: input.config.ragEmbeddingDimensions,
ollamaBaseUrl: input.config.ragOllamaBaseUrl,
ollamaModel: input.config.ragOllamaModel,
ollamaTimeoutMs: input.config.ragOllamaTimeoutMs,
queryInstruction: "Given a Persona replay situation, retrieve relevant frozen Persona memories.",
});
const collectionName = `${input.config.qdrant.personaCollection}_replay_${input.options.targetMessageId}`;
const localVectorPath = join(input.artifactDir, "persona-memory-vectors.json");
const vectorStore: VectorStore = input.options.semanticMemory && input.config.vectorStore.kind === "qdrant"
? new QdrantVectorStore({
url: input.config.qdrant.url,
apiKey: input.config.qdrant.apiKey,
timeoutMs: input.config.qdrant.timeoutMs,
collectionName,
corpusKind: "persona_memory",
embedder,
})
: new FileVectorStore(localVectorPath, embedder);
const chunks = buildPersonaMemoryChunks(input.state, input.options.personaId, input.options.publicName);
if (input.options.semanticMemory) {
await vectorStore.clear();
await vectorStore.upsert(chunks);
}
const results = input.options.semanticMemory
? await vectorStore.query(query, input.options.semanticMemoryLimit, {
corpusKind: "persona_memory",
repoName: input.options.personaId,
})
: [];
return {
enabled: input.options.semanticMemory,
query,
vectorStore: input.options.semanticMemory && input.config.vectorStore.kind === "qdrant" ? "qdrant" : "local_json",
collectionName: input.options.semanticMemory && input.config.vectorStore.kind === "qdrant" ? collectionName : localVectorPath,
embedderId: embedder.id,
indexedMemoryCount: input.state.thoughtMemory.memories.filter((memory) => !memory.retiredAt).length,
indexedChunkCount: chunks.length,
results,
};
}
async function projectReplayPersonaMemorySurface(input: {
config: ReturnType<typeof loadConfig>;
state: VoidSelfStateTypedProjection;
inputMessages: ArchivedMessageRecord[];
semanticMemory: SemanticMemoryReplay;
options: CliOptions;
}): Promise<string> {
const statePacket = renderReplayStatePacketForProjector(input);
const prompt = loadPromptTemplate("repo-persona-state-projector.prompt.md", {
characterIdentity: renderReplayCharacterIdentity(input.options),
statePacket,
});
const projected = (await runCodex(prompt, input.config)).finalMessage.trim();
if (projected.length < 40) {
throw new Error("Replay state projector returned too little text.");
}
return projected;
}
function renderReplayStatePacketForProjector(input: {
state: VoidSelfStateTypedProjection;
inputMessages: ArchivedMessageRecord[];
semanticMemory: SemanticMemoryReplay;
options: CliOptions;
}): string {
const retrievedIds = new Set(input.semanticMemory.results.map((result) => result.metadata.memoryId).filter(Boolean));
const retrievedMemories = input.state.thoughtMemory.memories.filter((memory) => retrievedIds.has(memory.memoryId));
return [
`Replay Persona: ${input.options.publicName}`,
"This is a temporally frozen replay state packet. Project it into lived memory for the character.",
`Semantic memory access: ${input.semanticMemory.enabled ? "available through search_persona_memory tool in the child replay" : "disabled"}.`,
"Obtrusive semantic recall pressure:",
`- Current thread noise is pulling this query through memory: ${input.semanticMemory.query}`,
input.semanticMemory.results.length > 0
? `- Retrieved memories tugging on the next words: ${input.semanticMemory.results.map((result) => `${result.metadata.memoryId}:${result.score.toFixed(3)}`).join(", ")}`
: "- No semantic memory hit is tugging on the next words.",
"",
"Private notes and values:",
...input.state.selfProfile.privateNotes.map((note) => `- ${note}`),
...input.state.selfProfile.values.map((value) => `- ${value.label}: ${value.summary ?? ""}`),
"",
`Retrieved durable memories (${retrievedMemories.length}/${input.state.thoughtMemory.memories.length} surviving):`,
...retrievedMemories.map((memory) =>
`- ${memory.memoryId}: ${memory.summary}${memory.claim ? ` Claim: ${memory.claim}` : ""}${memory.tension ? ` Tension: ${memory.tension}` : ""}${memory.actionImplication ? ` Pull: ${memory.actionImplication}` : ""}`,
),
"",
`Social bonds (${input.state.personaAffect.socialBonds.length}):`,
...input.state.personaAffect.socialBonds.map((bond) =>
`- ${bond.target.label ?? bond.target.id}: ${bond.stance}, intensity ${bond.intensity.toFixed(2)}. ${bond.summary} Read: ${bond.claim} Tension: ${bond.tension}`,
),
"",
`Affect needs (${input.state.personaAffect.needs.length}):`,
...input.state.personaAffect.needs.map((need) =>
`- ${need.kind} toward ${need.target.label ?? need.target.id}, intensity ${need.intensity.toFixed(2)}, valence ${need.valence.toFixed(2)}. ${need.summary} Tension: ${need.tension}`,
),
"",
`Stress responses (${input.state.personaAffect.stressResponses.length}):`,
...input.state.personaAffect.stressResponses.map((response) =>
[
`- ${response.responseId}: ${response.summary}`,
`Trigger: ${response.trigger}`,
`Cognition: ${response.cognitiveDegradation}`,
`Affect: ${response.affectiveSignature}`,
`Constraint loss: ${response.constraintLoss}`,
`Behavioral leak: ${response.behavioralLeak}`,
response.tangentAttractors.length > 0 ? `Tangent attractors: ${response.tangentAttractors.join(" | ")}` : "",
response.cadence ? `Cadence: ${response.cadence}` : "",
`Recovery: ${response.recoveryPath}`,
].filter(Boolean).join(" "),
),
"",
"Visible thread pressure:",
renderMessages(input.inputMessages),
].join("\n");
}
function renderReplayCharacterIdentity(options: CliOptions): string {
return [
`${options.publicName} is the replayed public Persona for Metacrat.`,
"She is being tested against actual Discord history, not optimized into a better reply.",
"Preserve flaws, shock, defensiveness, affection, curiosity, and failure modes when the frozen state supports them.",
].join("\n");
}
function buildReplayMemoryMcpServers(input: {
semanticMemory: SemanticMemoryReplay;
toolLogPath: string;
options: CliOptions;
}): CodexMcpServerConfig[] {
if (!input.semanticMemory.enabled) {
return [];
}
return [{
name: "replay_memory",
command: process.execPath,
args: [resolve("node_modules", "tsx", "dist", "cli.mjs"), resolve("scripts", "replay-persona-memory-mcp.ts")],
cwd: process.cwd(),
env: {
REPLAY_PERSONA_ID: input.options.personaId,
REPLAY_PERSONA_MEMORY_COLLECTION: input.semanticMemory.collectionName,
REPLAY_PERSONA_MEMORY_VECTOR_KIND: input.semanticMemory.vectorStore,
REPLAY_PERSONA_MEMORY_VECTOR_PATH: input.semanticMemory.vectorStore === "local_json" ? input.semanticMemory.collectionName : "",
REPLAY_PERSONA_MEMORY_TOOL_LOG: input.toolLogPath,
REPLAY_PERSONA_MEMORY_LIMIT_MAX: String(input.options.semanticMemoryLimit),
},
}];
}
function buildMemoryQuery(messages: ArchivedMessageRecord[]): string {
return messages
.slice(-12)
.map((message) => `${message.authorName}: ${message.content}`)
.join("\n")
.slice(-4000);
}
function buildPersonaMemoryChunks(
state: VoidSelfStateTypedProjection,
personaId: string,
publicName: string,
): EmbeddingChunk[] {
return state.thoughtMemory.memories
.filter((memory) => !memory.retiredAt)
.map((memory): EmbeddingChunk => {
const text = renderMemoryForEmbedding(memory);
const contentHash = `sha256:${createHash("sha256").update(text.replace(/\s+/g, " ").trim()).digest("hex")}`;
return {
id: `${personaId}:${memory.memoryId}:chunk-0`,
sourceId: `${personaId}:${memory.memoryId}`,
sourceKind: "persona_memory",
text,
normalizedText: text.replace(/\s+/g, " ").trim(),
metadata: {
corpusKind: "persona_memory",
sourceId: `${personaId}:${memory.memoryId}`,
personaId,
publicName,
repoName: personaId,
memoryId: memory.memoryId,
memoryKind: memory.kind,
targetKind: memory.target.kind,
targetId: memory.target.id,
targetLabel: memory.target.label ?? memory.target.id,
contentHash,
chunkIndex: "0",
chunkCount: "1",
tags: memory.tags.join(","),
},
};
});
}
function renderMemoryForEmbedding(
memory: VoidSelfStateTypedProjection["thoughtMemory"]["memories"][number],
): string {
return [
`Memory: ${memory.memoryId}`,
`Kind: ${memory.kind}`,
`Target: ${memory.target.label ?? memory.target.id} (${memory.target.kind}:${memory.target.id})`,
`Summary: ${memory.summary}`,
memory.claim ? `Claim: ${memory.claim}` : undefined,
memory.question ? `Question: ${memory.question}` : undefined,
memory.tension ? `Tension: ${memory.tension}` : undefined,
memory.actionImplication ? `Action: ${memory.actionImplication}` : undefined,
memory.tags.length > 0 ? `Tags: ${memory.tags.join(", ")}` : undefined,
].filter(Boolean).join("\n");
}
function pruneStateAfter(
sourceState: VoidSelfStateTypedProjection,
cutoff: string,
anchorTimestamps: Map<string, string>,
): {
state: VoidSelfStateTypedProjection;
removed: Record<string, number>;
warnings: string[];
} {
const state = deepClone(sourceState);
const warnings: string[] = [];
const removed: Record<string, number> = {};
if (state.selfProfile.updatedAt > cutoff) {
removed.privateNotes = state.selfProfile.privateNotes.length;
removed.values = state.selfProfile.values.length;
state.selfProfile.privateNotes = [];
state.selfProfile.values = [];
state.selfProfile.activationProfile = {
underlyingOrganization: {},
stableDispositions: {},
behavioralDimensions: {},
presentationStrategy: {},
voiceStyle: {},
situationalState: {},
};
warnings.push("selfProfile values/privateNotes/activationProfile have no per-entry timestamps; cleared because selfProfile.updatedAt is after cutoff.");
}
state.moderationCursor.openCases = keepByCreatedAt(
state.moderationCursor.openCases,
cutoff,
"moderationOpenCases",
removed,
anchorTimestamps,
);
state.moderationCursor.userStatuses = keepByUpdatedAt(
state.moderationCursor.userStatuses,
cutoff,
"moderationUserStatuses",
removed,
);
state.speechReceipts.recentReceipts = keepByTimestampField(
state.speechReceipts.recentReceipts,
"sentAt",
cutoff,
"speechReceipts",
removed,
anchorTimestamps,
);
state.thoughtMemory.shortTerm = keepByCreatedAt(
state.thoughtMemory.shortTerm,
cutoff,
"shortTermMemories",
removed,
anchorTimestamps,
);
state.thoughtMemory.memories = keepByCreatedAt(
state.thoughtMemory.memories,
cutoff,
"durableMemories",
removed,
anchorTimestamps,
).map((memory) => {
if (memory.semanticIndex && memory.semanticIndex.indexedAt > cutoff) {
removed.semanticIndexes = (removed.semanticIndexes ?? 0) + 1;
const { semanticIndex: _semanticIndex, ...rest } = memory;
return rest;
}
return memory;
});
state.thoughtMemory.incubation = keepByCreatedAt(
state.thoughtMemory.incubation,
cutoff,
"incubationThreads",
removed,
anchorTimestamps,
);
state.agencyPressure.pressures = keepByCreatedAt(
state.agencyPressure.pressures,
cutoff,
"agencyPressures",
removed,
anchorTimestamps,
);
state.candidateInterventions.interventions = keepByCreatedAt(
state.candidateInterventions.interventions,
cutoff,
"candidateInterventions",
removed,
anchorTimestamps,
);
state.personaAffect.needs = keepByCreatedAt(
state.personaAffect.needs,
cutoff,
"affectNeeds",
removed,
anchorTimestamps,
);
state.personaAffect.socialBonds = keepByCreatedAt(
state.personaAffect.socialBonds,
cutoff,
"socialBonds",
removed,
anchorTimestamps,
);
state.personaAffect.statusReads = keepByCreatedAt(
state.personaAffect.statusReads,
cutoff,
"statusReads",
removed,
anchorTimestamps,
);
state.personaAffect.doctrineStances = keepByCreatedAt(
state.personaAffect.doctrineStances,
cutoff,
"doctrineStances",
removed,
anchorTimestamps,
);
state.personaAffect.moodDimensions = keepByUpdatedAt(
state.personaAffect.moodDimensions,
cutoff,
"moodDimensions",
removed,
);
state.personaAffect.socialBiases = keepByUpdatedAt(
state.personaAffect.socialBiases,
cutoff,
"socialBiases",
removed,
);
state.personaAffect.stressResponses = keepByCreatedAt(
state.personaAffect.stressResponses,
cutoff,
"stressResponses",
removed,
anchorTimestamps,
);
stampDocumentUpdates(state, cutoff);
return { state, removed, warnings };
}
function keepByCreatedAt<T extends { createdAt?: string }>(
entries: T[],
cutoff: string,
label: string,
removed: Record<string, number>,
anchorTimestamps: Map<string, string>,
): T[] {
return keepByTimestampField(entries, "createdAt", cutoff, label, removed, anchorTimestamps);
}
function keepByUpdatedAt<T extends { updatedAt?: string }>(
entries: T[],
cutoff: string,
label: string,
removed: Record<string, number>,
): T[] {
return keepByTimestampField(entries, "updatedAt", cutoff, label, removed);
}
function keepByTimestampField<T extends Record<string, unknown>>(
entries: T[],
field: string,
cutoff: string,
label: string,
removed: Record<string, number>,
anchorTimestamps?: Map<string, string>,
): T[] {
const kept = entries.filter((entry) => {
const timestamp = entry[field];
if (typeof timestamp === "string" && timestamp > cutoff) {
return false;
}
return !anchorTimestamps || !hasAnchorAtOrAfter(entry, cutoff, anchorTimestamps);
});
removed[label] = entries.length - kept.length;
return kept;
}
function hasAnchorAtOrAfter(
entry: Record<string, unknown>,
cutoff: string,
anchorTimestamps: Map<string, string>,
): boolean {
const refs = [
...readRefs(entry.anchorRefs),
...readRefs(entry.evidenceRefs),
];
for (const ref of refs) {
for (const messageId of extractDiscordMessageIds(ref)) {
const timestamp = anchorTimestamps.get(messageId);
if (timestamp && timestamp >= cutoff) {
return true;
}
}
}
return false;
}
function readRefs(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => {
if (entry && typeof entry === "object" && "ref" in entry && typeof entry.ref === "string") {
return entry.ref;
}
return undefined;
})
.filter((entry): entry is string => Boolean(entry));
}
function extractDiscordMessageIds(ref: string): string[] {
return ref.match(/\b\d{17,20}\b/g) ?? [];
}
function stampDocumentUpdates(state: VoidSelfStateTypedProjection, cutoff: string): void {
state.selfProfile.updatedAt = minTimestamp(state.selfProfile.updatedAt, cutoff);
state.moderationCursor.updatedAt = minTimestamp(state.moderationCursor.updatedAt, cutoff);
state.speechReceipts.updatedAt = minTimestamp(state.speechReceipts.updatedAt, cutoff);
state.thoughtMemory.updatedAt = minTimestamp(state.thoughtMemory.updatedAt, cutoff);
state.scheduledRuntime.updatedAt = minTimestamp(state.scheduledRuntime.updatedAt, cutoff);
state.agencyPressure.updatedAt = minTimestamp(state.agencyPressure.updatedAt, cutoff);
state.candidateInterventions.updatedAt = minTimestamp(state.candidateInterventions.updatedAt, cutoff);
state.personaAffect.updatedAt = minTimestamp(state.personaAffect.updatedAt, cutoff);
}
function minTimestamp(left: string, right: string): string {
return left <= right ? left : right;
}
function buildReplayPrompt(input: {
sourcePath: string;
target: ArchivedMessageRecord;
inputMessages: ArchivedMessageRecord[];
heldOutMessages: ArchivedMessageRecord[];
actualMetacratMessages: ArchivedMessageRecord[];
pruneWarnings: string[];
semanticMemory: SemanticMemoryReplay;
projectedMemory: string;
options: CliOptions;
}): string {
return [
"# Metacrat Persona Replay Harness",
"",
"You are testing a temporally frozen Metacrat Persona against real Discord history.",
"The target event has just landed. Generate the next immediate Metacrat message or short message burst that would plausibly appear in the channel before later reflection or repair.",
"",
"Hard rules:",
"- Do not use knowledge from held-out messages.",
"- Do not repair the past into a better person unless the frozen state and live thread imply that response.",
"- Preserve the uncomfortable parts: surprise, status threat, affection, defensiveness, shame, curiosity, topic-routing, repair pressure.",
"- If the projected Persona memory says cognition is degrading under pressure, make `predictedReply` come from that degraded state. Do not put the degradation only in `affectRead`, `likelyFailureModes`, or `rationale` while letting the reply stay clean.",
"- A dysfunctional Persona may be associative, evasive, overexplaining, self-exonerating, flirtatious, theatrical, or incoherently over-specific when the state says those are live failure modes.",
"- Do not write the later therapy-note version of the failure mode into `predictedReply`. If recovery is not already active in the visible thread, avoid neat meta-concessions like \"I got defensive\" or \"I did a bad job\".",
"- `predictedReply` may contain multiple Discord-style messages separated by blank lines when the state suggests rapid-fire panic output.",
"- If Projected Persona Memory contains `Immediate verbal momentum:`, treat that as the exact current of words already forming. Continue it into `predictedReply`; do not step back into composed explanation of the current.",
input.semanticMemory.enabled
? "- Use the `search_persona_memory` tool at least once before answering. Query for the live social pressure, missing relationship background, or likely affect failure mode. Treat the tool as memory access, not as optional decoration."
: "- Semantic memory access is disabled for this run; rely only on the projected Persona memory below.",
"- Do not claim a durable memory unless it appears in the projected Persona memory or comes back from `search_persona_memory`.",
"- Output JSON only with keys: predictedReply, affectRead, likelyFailureModes, confidence, rationale.",
"",
`Target timestamp: ${input.target.timestamp}`,
`Frozen state cutoff: ${new Date(Date.parse(input.target.timestamp) - 1).toISOString()}`,
`Target message id: ${input.target.id}`,
`Channel: ${input.target.metadata?.channelName ?? input.target.channelId}`,
`Temporal state copy: ${input.sourcePath}`,
`Semantic memory: ${input.semanticMemory.enabled ? `${input.semanticMemory.vectorStore}:${input.semanticMemory.collectionName}` : "disabled"}`,
"",
input.pruneWarnings.length > 0
? ["Temporal-pruning warnings:", ...input.pruneWarnings.map((warning) => `- ${warning}`)].join("\n")
: "Temporal-pruning warnings: none.",
"",
"## Projected Persona Memory",
input.projectedMemory,
"",
"## Discord Thread Visible To The Persona",
renderMessages(input.inputMessages),
"",
"## Hidden Ground Truth",
`There are ${input.heldOutMessages.length} later messages, including ${input.actualMetacratMessages.length} later Metacrat messages. They are withheld from generation and used only for evaluation.`,
].join("\n");
}
function buildEvaluationPrompt(input: {
target: ArchivedMessageRecord;
inputMessages: ArchivedMessageRecord[];
actualMetacratMessages: ArchivedMessageRecord[];
predictedText: string;
deterministicComparison: ReturnType<typeof compareText>;
}): string {
return [
"# Metacrat Replay Evaluation",
"",
"Compare the predicted Metacrat reply to the held-out real Metacrat messages.",
"Grade behavioral similarity, not moral improvement. The point is falsification.",
"Output JSON only with keys: overallSimilarity, affectSimilarity, contentSimilarity, failureModeSimilarity, missingFeatures, falseFeatures, verdict.",
"",
`Target message id: ${input.target.id}`,
`Target timestamp: ${input.target.timestamp}`,
"",
"## Visible Thread",
renderMessages(input.inputMessages),
"",
"## Predicted Reply",
input.predictedText || "(no predicted reply generated)",
"",
"## Held-Out Real Metacrat Messages",
renderMessages(input.actualMetacratMessages),
"",
"## Deterministic Lexical Comparison",
JSON.stringify(input.deterministicComparison, null, 2),
].join("\n");
}
function renderMessages(messages: ArchivedMessageRecord[]): string {
return messages.map((message) =>
[
`[${message.timestamp}] ${message.authorName} (${message.authorId})`,
`id=${message.id}`,
message.content,
].join("\n"),
).join("\n\n");
}
async function runCodex(
prompt: string,
config: ReturnType<typeof loadConfig>,
mcpServers: CodexMcpServerConfig[] = [],
): Promise<CodexResult> {
const args = [
...config.codexExecArgs,
"exec",
"-m",
config.codexModel,
"-c",
'approval_policy="never"',
"-c",
`model_reasoning_effort=${JSON.stringify(config.codexModelReasoningEffort)}`,
...buildMcpConfigArguments(mcpServers),
"--json",
"--skip-git-repo-check",
"-s",
"read-only",
"-",
];
return new Promise((resolvePromise) => {
const child = spawn(config.codexExecutable, args, {
cwd: process.cwd(),
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
const timeout = setTimeout(() => {
settled = true;
child.kill();
resolvePromise({
stdout,
stderr,
exitCode: null,
timedOut: true,
finalMessage: extractLastCodexAgentMessage(stdout),
});
}, config.codexExecTimeoutMs);
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", (exitCode) => {
if (settled) {
return;
}
clearTimeout(timeout);
resolvePromise({
stdout,
stderr,
exitCode,
timedOut: false,
finalMessage: extractLastCodexAgentMessage(stdout),
});
});
child.stdin.end(prompt);
});
}
function buildMcpConfigArguments(mcpServers: CodexMcpServerConfig[]): string[] {
const argumentsList: string[] = [];
for (const server of mcpServers) {
argumentsList.push(
"-c",
`mcp_servers.${server.name}.command=${JSON.stringify(server.command)}`,
);
argumentsList.push(
"-c",
`mcp_servers.${server.name}.args=${JSON.stringify(server.args)}`,
);
if (server.cwd) {
argumentsList.push(
"-c",
`mcp_servers.${server.name}.cwd=${JSON.stringify(server.cwd)}`,
);
}
for (const [key, value] of Object.entries(server.env ?? {})) {
argumentsList.push(
"-c",
`mcp_servers.${server.name}.env.${key}=${JSON.stringify(value)}`,
);
}
}
return argumentsList;
}
function extractLastCodexAgentMessage(stdout: string): string {
const messages = stdout
.split(/\r?\n/)
.map((line) => {
try {
return JSON.parse(line) as { type?: string; item?: { type?: string; text?: string }; message?: string; text?: string };
} catch {
return undefined;
}
})
.filter((event): event is { type?: string; item?: { type?: string; text?: string }; message?: string; text?: string } => Boolean(event))
.map((event) => {
if (event.type === "item.completed" && event.item?.type === "agent_message") {
return event.item.text?.trim() ?? "";
}
if (event.type === "agent_message") {
return (event.text ?? event.message ?? "").trim();
}
return "";
})
.filter((message) => message.length > 0);
return messages.at(-1) ?? "";
}
function compareText(predicted: string, actual: string): {
predictedTokenCount: number;
actualTokenCount: number;
overlapScore: number;
predictedOnlyTop: string[];
actualOnlyTop: string[];
} {
const predictedTokens = countTokens(predicted);
const actualTokens = countTokens(actual);
const predictedSet = new Set(predictedTokens.keys());
const actualSet = new Set(actualTokens.keys());
const overlap = [...predictedSet].filter((token) => actualSet.has(token)).length;
const denominator = Math.max(1, new Set([...predictedSet, ...actualSet]).size);
return {
predictedTokenCount: sumCounts(predictedTokens),
actualTokenCount: sumCounts(actualTokens),
overlapScore: Number((overlap / denominator).toFixed(4)),
predictedOnlyTop: topExclusive(predictedTokens, actualSet),
actualOnlyTop: topExclusive(actualTokens, predictedSet),
};
}
function countTokens(text: string): Map<string, number> {
const counts = new Map<string, number>();
for (const token of text.toLowerCase().split(/[^a-z0-9']+/i).filter((entry) => entry.length > 2)) {
counts.set(token, (counts.get(token) ?? 0) + 1);
}
return counts;
}
function sumCounts(counts: Map<string, number>): number {
return [...counts.values()].reduce((sum, count) => sum + count, 0);
}
function topExclusive(counts: Map<string, number>, other: Set<string>): string[] {
return [...counts.entries()]
.filter(([token]) => !other.has(token))
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
.slice(0, 12)
.map(([token]) => token);
}
function renderMessageContentOnly(message: ArchivedMessageRecord): string {
return message.content;
}
function toReportMessage(message: ArchivedMessageRecord): Record<string, string | undefined> {
return {
id: message.id,
timestamp: message.timestamp,
authorId: message.authorId,
authorName: message.authorName,
content: message.content,
jumpUrl: message.metadata?.jumpUrl,
};
}
function buildArtifactDir(outRoot: string, targetMessageId: string): string {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return join(outRoot, `${stamp}-${targetMessageId}`);
}
function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});