Lands Sage's sage/row66-r5 at7ffb640ac, round 4 (r4d82c830779) rebased ontoa36523713after 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 on7b9737953,a36523713and 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).
168 lines
6.9 KiB
TypeScript
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");
|
|
},
|
|
});
|
|
}
|