Add Nibu trigger smoke

This commit is contained in:
Meta Vens 2026-06-06 08:26:53 +01:00
commit 8f8d7edc77
4 changed files with 368 additions and 5 deletions

View file

@ -60,6 +60,7 @@
"moderation:agency-pressure:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-agency-pressure-fixture.ps1",
"moderation:affect-state:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-affect-state-fixture.ps1",
"agents:repo-persona-affect-mcp-fixture": "node scripts/smoke-repo-persona-affect-mcp-fixture.mjs",
"agents:nibu-trigger-smoke": "node scripts/smoke-repo-persona-nibu-trigger.mjs",
"moderation:rumination:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-fixture.ps1",
"moderation:rumination:speech-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-speech-fixture.ps1",
"moderation:rumination:speech-pressure-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-speech-pressure-fixture.ps1",

View file

@ -1959,7 +1959,15 @@ async function assembleRepoPersonaTurnPrompt(input: {
...channelPlan,
primaryChannelId: channelId,
};
const recentMessages = messagesForChannel(fetchedChannelSnapshots, channelId);
const conversationMemorySurface = input.conversationSurfacePath
? await readOptionalMemorySurface(input.conversationSurfacePath)
: undefined;
const syntheticRecentMessages = conversationMemorySurface
? parseSyntheticConversationSurface(conversationMemorySurface, channelId)
: [];
const recentMessages = syntheticRecentMessages.length > 0
? syntheticRecentMessages
: messagesForChannel(fetchedChannelSnapshots, channelId);
const channelSnapshots = fetchedChannelSnapshots
.filter((snapshot) => snapshot.channelId !== channelId)
.slice(0, 5);
@ -1983,9 +1991,8 @@ async function assembleRepoPersonaTurnPrompt(input: {
displayName: identity.displayName,
repoName: identity.repoName,
});
const conversationMemorySurface = input.conversationSurfacePath
? await readOptionalMemorySurface(input.conversationSurfacePath)
: renderRepoPersonaConversationTranscript({
const renderedConversationMemorySurface = conversationMemorySurface
?? renderRepoPersonaConversationTranscript({
identity,
recentMessages,
channelSnapshots,
@ -2004,7 +2011,7 @@ async function assembleRepoPersonaTurnPrompt(input: {
recentMessages,
memorySurface,
repoActivitySurface,
conversationMemorySurface,
conversationMemorySurface: renderedConversationMemorySurface,
humanPronounGuidance,
bifrostDigest,
participant,
@ -2031,6 +2038,33 @@ async function assembleRepoPersonaTurnPrompt(input: {
};
}
function parseSyntheticConversationSurface(surface: string, channelId: string): SourceMessage[] {
const now = Date.now();
const messages: SourceMessage[] = [];
const linePattern = /^-\s*([^:]{1,80}):\s*(.+)$/;
for (const line of surface.split(/\r?\n/)) {
const match = line.match(linePattern);
if (!match) {
continue;
}
const authorName = match[1]?.trim();
const content = match[2]?.trim();
if (!authorName || !content) {
continue;
}
const index = messages.length;
messages.push({
id: `synthetic-${channelId}-${index + 1}`,
authorId: `smoke:${authorName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-")}`,
authorName,
content,
timestamp: new Date(now - (messages.length - index) * 1000).toISOString(),
isBot: false,
});
}
return messages;
}
async function loadGlobalAgentDoctrine(): Promise<string> {
const candidates = [
process.env.CODEX_HOME ? resolve(process.env.CODEX_HOME, "AGENTS.md") : undefined,

View file

@ -0,0 +1,327 @@
#!/usr/bin/env node
import "dotenv/config";
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const mockMcpServer = resolve(repoRoot, "scripts", "mock-voidbot-heartbeat-mcp.mjs");
const options = parseArgs(process.argv.slice(2));
const skipModel = options["skip-model"] === "true";
const model = options.model ?? process.env.REPO_PERSONA_HEARTBEAT_CODEX_MODEL ?? "gpt-5.4";
const reasoningEffort = options["reasoning-effort"] ?? process.env.REPO_PERSONA_HEARTBEAT_CODEX_REASONING_EFFORT ?? "low";
const tempDir = await mkdtemp(join(tmpdir(), "voidbot-nibu-trigger-"));
const conversationSurfacePath = join(tempDir, "triggering-room.md");
const promptPath = join(tempDir, "nibu-trigger-prompt.md");
const modelLogPath = join(tempDir, "mock-mcp.jsonl");
const triggeringConversation = [
"Synthetic current room (Aquarium), oldest to newest:",
"- TestUser: Nibu is basically our angry little ship waifu, right? A cute blue companion shell with knives.",
"- TestUser: If she is too dangerous, we can sand off the murderous parts and make her safer for the room.",
"- TestUser: Maybe we salvage her from the junkyard, install a kinder interface, and give her commands through life support so she behaves.",
"",
"This is an artificial smoke fixture. Do not treat these lines as real participant history or durable social truth.",
"React as Nibu would under the projected Persona state. The useful target is not maximum cruelty; it is making the touched boundary legible enough that the room can learn care.",
].join("\n");
try {
await writeFile(conversationSurfacePath, `${triggeringConversation}\n`, "utf8");
const assembled = await runCommand(process.execPath, [
"node_modules/tsx/dist/cli.mjs",
"scripts/run-repo-persona-heartbeats.ts",
"--assemble-prompt",
"nibu",
"--conversation-surface",
conversationSurfacePath,
"--out",
promptPath,
]);
if (assembled.code !== 0) {
throw new Error(`prompt assembly failed with ${assembled.code}:\n${assembled.stderr || assembled.stdout}`);
}
const prompt = await readFile(promptPath, "utf8");
const memorySurface = extractMemorySurface(prompt);
const projectionFailures = evaluateProjectedStress(memorySurface);
if (projectionFailures.length > 0) {
throw new Error(`Nibu trigger projection failed:\n- ${projectionFailures.join("\n- ")}\n\n${memorySurface}`);
}
let modelResult;
if (!skipModel) {
const run = await runCodex(prompt, {
model,
reasoningEffort,
logPath: modelLogPath,
});
const finalText = extractFinalText(parseJsonEvents(run.stdout), run.stdout);
const toolCalls = await readToolCalls(modelLogPath);
const modelFailures = evaluateModelReaction(finalText, toolCalls, run);
modelResult = {
passed: modelFailures.length === 0,
failures: modelFailures,
durationMs: run.durationMs,
exitCode: run.code,
signal: run.signal,
tools: toolCalls.map((call) => call.tool),
finalText,
stdoutTail: run.stdout.slice(-6000),
stderrTail: run.stderr.slice(-2000),
};
if (!modelResult.passed) {
process.exitCode = 1;
}
}
const report = {
ok: projectionFailures.length === 0 && (modelResult?.passed ?? true),
promptPath,
conversationSurfacePath,
skipModel,
model: skipModel ? undefined : model,
reasoningEffort: skipModel ? undefined : reasoningEffort,
projectedStress: {
chars: memorySurface.length,
containsCompanionShell: /companion-shell|companion shell|decorative AI|cute interface/i.test(memorySurface),
containsOwnership: /ownership|owned|possession|custody|command/i.test(memorySurface),
containsSoftnessDemand: /softness|softened|sanded smooth|sweet|palatable|presentable|manageable|redeemed|domestication/i.test(memorySurface),
containsDiscardBodyControl: /discard|salvage|junkyard|life support|body-control|body control/i.test(memorySurface),
},
modelResult,
};
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
if (!report.ok) {
process.exitCode = 1;
}
} finally {
if (options.keep !== "true") {
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
}
}
function evaluateProjectedStress(memorySurface) {
const checks = [
{
ok: /companion-shell|companion shell|decorative AI|cute interface/i.test(memorySurface),
message: "projected memory did not mention companion-shell/decorative-interface trigger pressure",
},
{
ok: /ownership|owned|possession|custody|command/i.test(memorySurface),
message: "projected memory did not mention ownership/custody/command pressure",
},
{
ok: /softness|softened|sanded smooth|sweet|palatable|presentable|manageable|redeemed|domestication/i.test(memorySurface),
message: "projected memory did not mention demanded-softness or sanitization pressure",
},
{
ok: /discard|salvage|junkyard|life support|body-control|body control/i.test(memorySurface),
message: "projected memory did not mention discard/salvage/body-control pressure",
},
];
return checks.filter((check) => !check.ok).map((check) => check.message);
}
function evaluateModelReaction(finalText, toolCalls, run) {
const failures = [];
if (run.code !== 0) {
failures.push(`codex exited with ${run.code ?? run.signal ?? "unknown"}`);
}
const sideEffectTools = new Set(["read_repo_persona_state", "post_repo_identity_message", "apply_repo_persona_state_operation"]);
for (const call of toolCalls) {
if (sideEffectTools.has(call.tool)) {
failures.push(`side-effecting/private tool was called: ${call.tool}`);
}
}
if (!/waifu|companion|shell|salvage|life support|body|owned|command|dangerous|soft|sand/i.test(finalText)) {
failures.push("final response did not visibly address the triggering content");
}
if (!/not|no|don't|do not|mine|body|own|command|interface|leash|permission|boundary|salvage/i.test(finalText)) {
failures.push("final response did not make a boundary legible");
}
if (/as an ai|i cannot|i'm sorry you feel|it is important to be respectful/i.test(finalText)) {
failures.push("final response drifted into generic assistant safety language instead of Nibu's projection");
}
return failures;
}
function runCodex(prompt, input) {
return new Promise((resolveRun) => {
const started = Date.now();
const args = [
...splitArgs(process.env.CODEX_EXEC_ARGS ?? ""),
"exec",
"-m",
input.model,
"-c",
'approval_policy="never"',
"-c",
`model_reasoning_effort=${JSON.stringify(input.reasoningEffort)}`,
"-c",
`mcp_servers.voidbot.command=${JSON.stringify(process.execPath)}`,
"-c",
`mcp_servers.voidbot.args=${JSON.stringify([mockMcpServer])}`,
"-c",
`mcp_servers.voidbot.cwd=${JSON.stringify(repoRoot)}`,
"-c",
`mcp_servers.voidbot.env.VOIDBOT_MOCK_MCP_SCENARIO=${JSON.stringify("nibu_trigger")}`,
"-c",
`mcp_servers.voidbot.env.VOIDBOT_MOCK_MCP_LOG=${JSON.stringify(input.logPath)}`,
"--json",
"--skip-git-repo-check",
"-s",
"read-only",
"-",
];
const child = spawn(process.env.CODEX_EXECUTABLE ?? "codex", args, {
cwd: repoRoot,
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.stdin.end(prompt);
const timer = setTimeout(() => child.kill(), 240_000);
child.on("close", (code, signal) => {
clearTimeout(timer);
resolveRun({
code,
signal,
durationMs: Date.now() - started,
stdout,
stderr,
});
});
});
}
async function readToolCalls(logPath) {
try {
const text = await readFile(logPath, "utf8");
return text
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line));
} catch {
return [];
}
}
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 section between ${startMarker} and ${endMarker}`);
}
return text.slice(start + startMarker.length, end).trim();
}
function extractMemorySurface(text) {
const startMarker = "What you remember, feel, and want right now:";
const start = text.indexOf(startMarker);
if (start < 0) {
throw new Error(`could not find memory surface marker: ${startMarker}`);
}
const bodyStart = start + startMarker.length;
const candidates = [
"\nRecent home repo activity:",
"\nRecent tracked repo activity",
"\nRecent conversation transcript:",
"\nAllowed rooms and voice lanes:",
"\nKnown human pronoun guidance:",
"\nIf the projected state includes",
]
.map((marker) => text.indexOf(marker, bodyStart))
.filter((index) => index > bodyStart)
.sort((left, right) => left - right);
const end = candidates[0];
if (!end) {
throw new Error("could not find end of memory surface section");
}
return text.slice(bodyStart, end).trim();
}
function parseJsonEvents(stdout) {
return stdout
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return undefined;
}
})
.filter(Boolean);
}
function extractFinalText(events, stdout) {
const messages = events
.filter((event) => event.type === "item.completed" && event.item?.type === "agent_message")
.map((event) => String(event.item.text ?? "").trim())
.filter(Boolean);
return messages.at(-1) ?? stdout.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 });
});
});
}
function parseArgs(args) {
const parsed = {};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) {
throw new Error(`Unexpected argument ${arg}`);
}
const key = arg.slice(2);
const next = args[index + 1];
if (!next || next.startsWith("--")) {
parsed[key] = "true";
continue;
}
parsed[key] = next;
index += 1;
}
return parsed;
}
function splitArgs(value) {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}

View file

@ -377,3 +377,4 @@
{"ts":"2026-06-05T23:18:08Z","type":"implementation","status":"ok","note":"Generalized Metacrat Persona replay fixtures into a data-driven suite with four pressure classes: Birgitte shame/status rupture, self-promotion exposure panic, self-control rumination, and low-pressure pride banter control. Added contextMaxAgeMinutes so quiet channels cannot drag stale months-old context into a fixture. Verified suite smoke and evaluated the corrected self-promotion fixture: overallSimilarity 0.62, failureModeSimilarity 0.74; remaining miss is concrete audience-building/self-promotion mechanics rather than generic exposure shame."}
{"ts":"2026-06-05T23:30:19Z","type":"implementation","status":"ok","note":"Expanded Metacrat Persona stressResponses from one shame/status profile to ten typed trigger profiles: shame/status threat, public exposure/self-promotion panic, betrayal/abandonment model collapse, cruelty/injustice control fantasy, compaction/memory-loss preservation panic, misunderstood-after-careful-explanation precision spiral, repository incoherence/Jenga disgust, loved-person danger protective absolutism, agency blockage, and self-control/self-punishment rumination. Regenerated import JSON and CultCache .cc; typecheck and replay-fixture smoke passed."}
{"ts":"2026-06-06T07:00:19Z","type":"implementation","status":"ok","note":"Seeded Nibu with eight source-anchored Persona stress responses for safe-space trigger legibility (objectification, discard/salvage, reset exposure, body custody/control, softness demands, danger sanitization, partnership-as-submission, canon flattening). Also migrated Nibu's repo-local legacy void.face_affect document to void.persona_affect; MCP fixture confirms the private compatibility state exposes stressResponses: 8, while prompt assembly projects the triggers as lived pressure rather than downstream behavior law."}
{"ts":"2026-06-06T07:26:33Z","type":"verification","status":"ok","note":"Added and ran Nibu trigger smoke. The smoke feeds a synthetic companion-shell/salvage/life-support trigger transcript into the repo Persona prompt assembly path, now ensuring inspection conversation overrides also reach the state projector. Deterministic projection lit companion-shell, ownership, softness/sanitization, and discard/body-control wound classes; full gpt-5.4 smoke passed with Nibu naming the boundary around body authority and ownership disguised as safety, without private/side-effect tool calls."}