/** * pi-aftc-toolset — think-tag parser feature module. * * Converts inline `...` tags inside assistant text into * proper pi `ThinkingContent` blocks. Without this, models that emit * thinking as text-tags (the DeepSeek / Qwen convention) show the raw * `` markers in the TUI. With this hook active, the tags are * stripped at message-finalize time and replaced with pi's standard * collapsible thinking rendering (theme `thinkingText`, `Ctrl+T` toggle, * `hideThinkingBlock` setting — all native, no UI work here). * * Architecture — single hook, no shared state, no commands: * * pi.on("message_end", …) walks `event.message.content`. For each * TextContent block, every `` substring is split out * into a sibling ThinkingContent block, in source order. Non-text * blocks (ToolCall, existing ThinkingContent) pass through unchanged. * * Idempotent / safe: * - Skips when the message already contains a ThinkingContent block * (don't fight native reasoning providers like Anthropic). * - Skips when stopReason is "error" or "aborted" (don't mangle * partial output). * - Skips text blocks that carry `textSignature` (preserves * provider-side signature replay — modifying a signed text block * would invalidate the signature on the next turn). * * Streaming caveat: * `message_end` is the only message-replacement hook (`message_update` * does not support `{ message }`). During streaming, raw `` * tags are visible in the TUI until the assistant turn finalizes, at * which point this hook collapses them into proper blocks. Acceptable * for a first cut — matches the behaviour of every other "strip on * finalize" provider in the wild (DeepSeek, Qwen via openrouter, etc.). * * Per .dev/dev_guide.md section 1.5, this is a self-contained feature module: no * commands, no shared state, no dependencies on other feature modules, * wired into pi by the orchestrator in index.ts. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { TextContent, ThinkingContent } from "@earendil-works/pi-ai"; import { getPreference, setPreference } from "./config"; // ───────────────────────────────────────────────────────────────────────────── // Regex — global, non-greedy, requires closing tag. Unclosed `` is // left as plain text (we never swallow half a tag). // ───────────────────────────────────────────────────────────────────────────── const THINK_RE = /([\s\S]*?)<\/think>/g; // ───────────────────────────────────────────────────────────────────────────── // Block-type guards — narrowed locally to avoid pulling `AgentMessage` // (not exported from `@earendil-works/pi-coding-agent`'s public surface). // Matches the same shape `event.message.content` has at runtime for an // assistant message: (TextContent | ThinkingContent | ToolCall)[]. // ───────────────────────────────────────────────────────────────────────────── interface AssistantContentBlock { type: string; } function isTextBlock(b: AssistantContentBlock): b is TextContent { return b.type === "text"; } function isThinkingBlock(b: AssistantContentBlock): b is ThinkingContent { return b.type === "thinking"; } function hasThinkingSignature(b: TextContent): boolean { // textSignature is optional; presence means a provider (Anthropic, // etc.) attached a signature for replay validation. Don't touch it. return typeof b.textSignature === "string" && b.textSignature.length > 0; } // ───────────────────────────────────────────────────────────────────────────── // Split one TextContent block into an ordered list of TextContent / // ThinkingContent blocks. Adjacent text and thinking segments are kept in // source order so the rendered output reads identically to the original // minus the literal `` / `` markers. // ───────────────────────────────────────────────────────────────────────────── function splitTextBlock(block: TextContent): Array { const text = block.text; if (!text.includes("")) { // Common case — no tag present, return the original block // reference unchanged so we don't allocate a copy. return [block]; } const out: Array = []; let lastIndex = 0; THINK_RE.lastIndex = 0; let match: RegExpExecArray | null; while ((match = THINK_RE.exec(text)) !== null) { const before = text.slice(lastIndex, match.index); if (before.length > 0) { out.push({ type: "text", text: before }); } const thinking = match[1]; if (thinking.length > 0) { out.push({ type: "thinking", thinking }); } lastIndex = match.index + match[0].length; } const after = text.slice(lastIndex); if (after.length > 0) { out.push({ type: "text", text: after }); } // If we didn't extract any thinking blocks (e.g. text contained // `` but no matching `` — an unclosed tag), return // the original block reference so the caller can detect "no real // change happened" and avoid an unnecessary message replacement. if (out.length === 0) { return [block]; } const extractedThinking = out.some( (b) => b.type === "thinking", ); if (!extractedThinking) { return [block]; } return out; } // ───────────────────────────────────────────────────────────────────────────── // Walk an assistant message's content array and return a transformed // copy with think-tags extracted, or `undefined` if no change was made // (signals the hook to leave the message alone — keeps `event.message` // referentially equal in the no-op case). // ───────────────────────────────────────────────────────────────────────────── type ContentBlock = TextContent | ThinkingContent | AssistantContentBlock; function transformContent( content: ContentBlock[], ): Array | undefined { // Skip if the message already has a thinking block — the provider // already produced proper structured thinking; our text-tag work // would only conflict. for (const b of content) { if (isThinkingBlock(b)) return undefined; } let changed = false; const next: ContentBlock[] = []; for (const block of content) { if (!isTextBlock(block)) { // ToolCall, ImageContent, anything else — pass through. next.push(block); continue; } // Don't touch signed text blocks — splitting would invalidate // the signature on next-turn replay. if (hasThinkingSignature(block)) { next.push(block); continue; } if (!block.text.includes("")) { next.push(block); continue; } const split = splitTextBlock(block); // splitTextBlock always returns at least one element when // called with a block that contains ``; the array is // never empty here. if (split.length === 1 && split[0] === block) { // splitTextBlock short-circuits in this case but be defensive. next.push(block); continue; } changed = true; for (const seg of split) next.push(seg); } return changed ? next : undefined; } // ───────────────────────────────────────────────────────────────────────────── // Public factory — wired by the orchestrator (index.ts) // ───────────────────────────────────────────────────────────────────────────── export function createThinkParser(pi: ExtensionAPI): void { // Opt-in: off by default. Users enable it with // /aftc-enable-think-processing. State persists in state.json // (a USER PREFERENCE that survives /reload, /new, and fresh pi // startup). Reading once at startup avoids a per-turn // preference lookup; toggling requires /reload to take effect. const enabled = getPreference("thinkProcessingEnabled", false); if (enabled) registerThinkParserHook(pi); pi.registerCommand("aftc-enable-think-processing", { description: "Enable the → ThinkingContent hook (requires /reload to take effect)", handler: async (_args: string, ctx: { ui: { notify?: (m: string, l: string) => void; hasUI?: boolean } }) => { setPreference("thinkProcessingEnabled", true); if (ctx.ui?.notify) ctx.ui.notify("Think-tag processing ON. Run /reload to activate.", "info"); }, }); pi.registerCommand("aftc-disable-think-processing", { description: "Disable the → ThinkingContent hook (requires /reload to take effect)", handler: async (_args: string, ctx: { ui: { notify?: (m: string, l: string) => void; hasUI?: boolean } }) => { setPreference("thinkProcessingEnabled", false); if (ctx.ui?.notify) ctx.ui.notify("Think-tag processing OFF. Run /reload to deactivate.", "info"); }, }); if (enabled) { console.log( "[aftc-toolset] loaded — /aftc-enable-think-processing, /aftc-disable-think-processing (think-tag parsing: ON)", ); } else { console.log( "[aftc-toolset] loaded — /aftc-enable-think-processing, /aftc-disable-think-processing (think-tag parsing: OFF; opt-in)", ); } } /** The actual hook. Split out so the toggle commands can re-register * it after a /reload once the preference flips. */ function registerThinkParserHook(pi: ExtensionAPI): void { pi.on("message_end", async (event, _ctx) => { // `event` is typed as `MessageEndEvent` by the `pi.on("message_end", ...)` // overload (see `@earendil-works/pi-coding-agent` extensions types). // `event.message` is `AgentMessage` whose runtime shape for assistant // messages is `{ role: "assistant", content: [...], stopReason, ... }`. // We narrow at runtime (rather than importing AgentMessage, which isn't // re-exported from the public API surface) — same pattern as core.ts. const message = event.message; if (!message || message.role !== "assistant") return undefined; if (message.stopReason === "error" || message.stopReason === "aborted") { return undefined; } if (!Array.isArray(message.content)) return undefined; const nextContent = transformContent( message.content as ContentBlock[], ); if (!nextContent) return undefined; // Replace the message; pi requires the role to be preserved, // which it is (we only mutated `content`). return { message: { ...message, content: nextContent, }, }; }); }