- scripts/agent.sh <name>: launches interactive pi TUI in the container with contracts + optional mission + agent identity + named session + optional workspace/tools; the Mosaic alternative to vanilla pi - pi adapter: MOSAIC_INTERACTIVE branch (clean TUI, no -p, no initial prompt); headless exec rebuilt via positional args (no word-splitting on the request); MOSAIC_AGENT_NAME optional in headless - loader: AGENT IDENTITY section when the launcher names the agent - compose: fixed command removed (request defaults live in run-agent.sh); MOSAIC_INTERACTIVE/MOSAIC_AGENT_NAME passthrough - docs/TOOLS.md: full on-demand tool reference; AGENTS.md routes to it - RELEASE -> 0.0.8 (container change); build verified Closes #35
164 lines
6.7 KiB
TypeScript
164 lines
6.7 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) => {
|
|
// 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");
|
|
},
|
|
});
|
|
}
|