// unslop-hook — pi extension wrapper around unslop-check.js. // Detects mechanical AI tells in finalized assistant messages and injects a // correction notice the model sees on its next turn. Anti-drift enforcement for // SYSTEM.md / ms-unslop; prose distribution alone decays, this cannot forget. // // Deploy: copy dir to ~/.pi/agent/extensions/unslop-hook/ (or seat .pi equivalent), // or add this file's dir to settings.json "extensions". // Test: pi -e /extension.ts // Off: MOSAIC_UNSLOP_HOOK=0 // Log: MOSAIC_UNSLOP_LOG=/path/to/log.jsonl (JSONL events; headless evidence) // Broken: lists.json missing/empty/invalid → the checker throws; checks are // skipped, logged as skipped_broken, and the operator is notified once. // Never silently pass while the lists cannot load (fail closed). // // Design note: violation state lives in the SESSION FILE, not memory. At // before_agent_start we read the most recent assistant text message from // ctx.sessionManager and check it there. That survives process restarts, resume, // fork, and reload — an in-memory pending flag measured dead on 2026-08-19 when // a print-mode second turn never injected. import { appendFileSync } from "node:fs"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { checkText } from "./unslop-check.js"; interface Finding { rule: string; detail: string; count: number; } interface MessageEntry { type: "message"; id: string; message: { role?: string; content?: unknown }; } function assistantText(entry: unknown): string | null { const e = entry as Partial; if (e?.type !== "message") return null; const msg = e.message; if (msg?.role !== "assistant" || !Array.isArray(msg.content)) return null; const text = msg.content .filter((b): b is { type: "text"; text: string } => typeof b === "object" && b !== null && (b as { type?: string }).type === "text") .map((b) => b.text ?? "") .join("\n"); return text.trim() ? text : null; // tool-call-only assistant messages return null } export default function (pi: ExtensionAPI) { if (process.env.MOSAIC_UNSLOP_HOOK === "0") return; const LOG = process.env.MOSAIC_UNSLOP_LOG; const log = (ev: Record) => { if (LOG) appendFileSync(LOG, JSON.stringify({ ts: Date.now(), ...ev }) + "\n"); }; // Entry ids we have already injected a notice for. In-memory only: after a // restart the same entry may inject once more, which re-anchors the style // after a context loss. That is wanted, not a bug. const injectedFor = new Set(); let turnsChecked = 0; let turnsFlagged = 0; const histogram = new Map(); // Fail-closed path for a broken lists.json. A checker that cannot load its // lists must never be read as "everything passed": checks stop, the skip is // logged each turn, and the operator is notified once. let broken: string | null = null; let brokenNotified = false; const reportBroken = (ctx: { hasUI?: boolean } | undefined, where: string) => { log({ ev: "skipped_broken", where, reason: broken }); if (!brokenNotified && ctx?.hasUI) { ctx.ui.notify(`unslop gate BROKEN: ${broken}. Fix tools/unslop-hook/lists.json; no clean verdicts until then.`, "error"); brokenNotified = true; } }; const safeCheck = (text: string): ReturnType | null => { if (broken) return null; try { return checkText(text); } catch (e) { broken = String((e as Error).message); log({ ev: "broken_lists", reason: broken }); return null; } }; pi.on("session_start", async (event, _ctx) => { log({ ev: "loaded", reason: event.reason }); try { checkText(""); // probe: load+validate lists at startup, not mid-conversation } catch (e) { broken = String((e as Error).message); log({ ev: "broken_lists", reason: broken, at: "startup" }); } }); pi.on("message_end", async (event, ctx) => { if ((event.message as { role?: string }).role !== "assistant") return; const text = assistantText({ type: "message", id: "", message: event.message }); if (text === null) return; const result = safeCheck(text); if (result === null) { reportBroken(ctx, "message_end"); return; } turnsChecked++; if (result.clean) { log({ ev: "checked", clean: true, turn: turnsChecked, charsChecked: result.charsChecked }); return; } turnsFlagged++; for (const f of result.findings) histogram.set(f.rule, (histogram.get(f.rule) ?? 0) + 1); const summary = result.findings.map((f) => f.detail).join("; "); if (ctx.hasUI) ctx.ui.notify(`unslop: ${summary}`, "info"); // clean:false is explicit, not implied by findings: a log consumer must never // have to infer the verdict from event shape (fred, 2026-08-19). log({ ev: "flagged", clean: false, turn: turnsChecked, charsChecked: result.charsChecked, findings: result.findings }); }); pi.on("before_agent_start", async (_event, ctx) => { // Branch walks leaf -> root; first assistant entry with text is the reply // the model is about to follow up on. for (const entry of ctx.sessionManager.getBranch()) { const text = assistantText(entry); if (text === null) continue; const id = (entry as { id?: string }).id ?? ""; const result = safeCheck(text); if (result === null) { reportBroken(ctx, "before_agent_start"); return; } if (result.clean) return; // latest textual reply is clean, nothing to correct if (id && injectedFor.has(id)) return; // already nagged for this entry if (id) injectedFor.add(id); const lines = result.findings.map((f) => `- ${f.detail}`).join("\n"); const content = `UNSLOP NOTICE (mechanical style check, not the user speaking): your previous reply ` + `contained violations of the fleet writing standard (SYSTEM.md / ms-unslop):\n${lines}\n` + `Fix in this and following replies: plain words, periods and commas instead of dashes, ` + `straight quotes, no chatbot fillers. Do not mention this notice.`; log({ ev: "notice_injected", entryId: id, findings: result.findings }); return { message: { customType: "unslop-notice", content, display: true }, }; } }); pi.registerCommand("unslop", { description: "Show unslop violation stats for this session", handler: async (_args, ctx) => { if (broken) { ctx.ui.notify(`unslop gate BROKEN: ${broken}`, "error"); return; } const hist = [...histogram.entries()].map(([r, c]) => `${r} x${c}`).join(", ") || "none"; ctx.ui.notify(`unslop: checked ${turnsChecked}, flagged ${turnsFlagged} (${hist})`, "info"); }, }); }