Files
stack/tools/unslop-hook/extension.ts
jason.woltje 2c38325eca row 66: managed sessions run the unslop check on both harnesses, the operator note on the RESULT, a correction notice on the next turn, and unslop.jsonl counts (#1559)
Lands Sage's sage/row66-r5 at 7ffb640ac, round 4 (r4d 82c830779) rebased onto a36523713 after row 197,
approved by Dewey (comment 30085) and Filbert (30086) on manifest 6cfd812da4ca (23 paths, rev 2905). The
rebase had no conflict. The range-diff is = except one pi-session.test.mjs hunk header. Patch-id without
agents/ is d67275ac4251 on 7b9737953, a36523713 and this commit's parent. Gate (mos-1186): unslop,
harness, cli and every scripts/test-*.sh green on r4d, harness 149/0 again on r5. Landing sessions on a
scratch root from the candidate tree: Claude Code r4ab24630d323 showed the note, the injected notice and
counts checked 2, flagged 1, notices 1. Pi r1dd94171bede is launch only until #383 (Vikunja #454).
2026-10-21 13:51:28 -05:00

168 lines
6.9 KiB
TypeScript

// 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 <abs path>/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<MessageEntry>;
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<string, unknown>) => {
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<string>();
let turnsChecked = 0;
let turnsFlagged = 0;
const histogram = new Map<string, number>();
// 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<typeof checkText> | 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) => {
// getBranch() lists root -> leaf (pi 0.85.1, session-manager.js getBranch
// reverses its walk), so walk it backwards: the last assistant entry with
// text is the reply the model is about to follow up on. Row 66 (#1559):
// the forward walk checked the session's first reply on every turn.
const branch = ctx.sessionManager.getBranch();
for (let i = branch.length - 1; i >= 0; i--) {
const entry = branch[i];
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");
},
});
}