feat(agent): interactive TUI launcher + identity + TOOLS.md (#35)
- 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
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
// 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");
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user