Add Nibu trigger smoke
This commit is contained in:
parent
bb9922d21a
commit
8f8d7edc77
4 changed files with 368 additions and 5 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
327
scripts/smoke-repo-persona-nibu-trigger.mjs
Normal file
327
scripts/smoke-repo-persona-nibu-trigger.mjs
Normal 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);
|
||||
}
|
||||
|
|
@ -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."}
|
||||
|
|
|
|||
Loading…
Reference in a new issue