From 8f8d7edc77924d76a58ffe3596e7d18ef71fd7e5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 6 Jun 2026 08:26:53 +0100 Subject: [PATCH] Add Nibu trigger smoke --- package.json | 1 + scripts/run-repo-persona-heartbeats.ts | 44 ++- scripts/smoke-repo-persona-nibu-trigger.mjs | 327 ++++++++++++++++++++ state/evidence.jsonl | 1 + 4 files changed, 368 insertions(+), 5 deletions(-) create mode 100644 scripts/smoke-repo-persona-nibu-trigger.mjs diff --git a/package.json b/package.json index bd4d128..b0f33f0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/run-repo-persona-heartbeats.ts b/scripts/run-repo-persona-heartbeats.ts index 82f6b5b..6ccfe66 100644 --- a/scripts/run-repo-persona-heartbeats.ts +++ b/scripts/run-repo-persona-heartbeats.ts @@ -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 { const candidates = [ process.env.CODEX_HOME ? resolve(process.env.CODEX_HOME, "AGENTS.md") : undefined, diff --git a/scripts/smoke-repo-persona-nibu-trigger.mjs b/scripts/smoke-repo-persona-nibu-trigger.mjs new file mode 100644 index 0000000..b084c3c --- /dev/null +++ b/scripts/smoke-repo-persona-nibu-trigger.mjs @@ -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); +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 08a901a..e832439 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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."}