// Vendored from pi-kiro (MIT, Copyright (c) 2026 Hongyi Lyu). See NOTICE. // // Stateful streaming parser that splits thinking tag content from text. // // Kiro returns thinking inline as `...` inside the text // stream. We separate it into structured ThinkingContent blocks so pi's UI // can display reasoning distinctly. Handles four tag variants and tokens // that straddle chunk boundaries. import type { AssistantMessage, AssistantMessageEventStream, TextContent, ThinkingContent } from "@earendil-works/pi-ai"; import { log } from "./debug.ts"; export const THINKING_START_TAG = ""; export const THINKING_END_TAG = ""; const THINKING_TAG_VARIANTS: Array<{ open: string; close: string }> = [ { open: "", close: "" }, { open: "", close: "" }, { open: "", close: "" }, { open: "", close: "" }, ]; /** Longest suffix of `text` that matches a prefix of `tag`. */ function trailingPrefixLength(text: string, tag: string): number { const max = Math.min(text.length, tag.length - 1); for (let len = max; len > 0; len--) { if (text.endsWith(tag.slice(0, len))) return len; } return 0; } function maxTrailingPrefixLength(text: string, tags: string[]): number { let max = 0; for (const tag of tags) { max = Math.max(max, trailingPrefixLength(text, tag)); } return max; } export class ThinkingTagParser { private textBuffer = ""; private inThinking = false; private thinkingExtracted = false; private thinkingBlockIndex: number | null = null; private textBlockIndex: number | null = null; private lastTextBlockIndex: number | null = null; private activeEndTag: string = THINKING_END_TAG; constructor( private output: AssistantMessage, private stream: AssistantMessageEventStream, ) {} processChunk(chunk: string): void { this.textBuffer += chunk; if (log.isDebug()) { log.debug("thinking.chunk", { chunkLen: chunk.length, bufferLen: this.textBuffer.length, inThinking: this.inThinking, thinkingExtracted: this.thinkingExtracted, }); } while (this.textBuffer.length > 0) { const prev = this.textBuffer.length; if (!this.inThinking && !this.thinkingExtracted) { this.processBeforeThinking(); if (this.textBuffer.length === 0) break; } if (this.inThinking) { this.processInsideThinking(); if (this.textBuffer.length === 0) break; } if (this.thinkingExtracted) { this.processAfterThinking(); break; } if (this.textBuffer.length >= prev) break; } } finalize(): void { if (log.isDebug()) { log.debug("thinking.finalize", { bufferLen: this.textBuffer.length, inThinking: this.inThinking, thinkingExtracted: this.thinkingExtracted, textBlockIndex: this.textBlockIndex, thinkingBlockIndex: this.thinkingBlockIndex, }); } if (this.textBuffer.length === 0) return; if (this.inThinking && this.thinkingBlockIndex !== null) { const block = this.output.content[this.thinkingBlockIndex] as ThinkingContent | undefined; if (block) { block.thinking += this.textBuffer; this.stream.push({ type: "thinking_delta", contentIndex: this.thinkingBlockIndex, delta: this.textBuffer, partial: this.output, }); this.stream.push({ type: "thinking_end", contentIndex: this.thinkingBlockIndex, content: block.thinking, partial: this.output, }); } } else { this.emitText(this.textBuffer); } this.textBuffer = ""; } getTextBlockIndex(): number | null { return this.textBlockIndex ?? this.lastTextBlockIndex; } private processBeforeThinking(): void { let bestPos = -1; let bestVariant: (typeof THINKING_TAG_VARIANTS)[number] | null = null; for (const variant of THINKING_TAG_VARIANTS) { const pos = this.textBuffer.indexOf(variant.open); if (pos !== -1 && (bestPos === -1 || pos < bestPos)) { bestPos = pos; bestVariant = variant; } } if (bestPos !== -1 && bestVariant) { if (log.isDebug()) { log.debug("thinking.open", { tag: bestVariant.open, at: bestPos }); } if (bestPos > 0) this.emitText(this.textBuffer.slice(0, bestPos)); this.textBuffer = this.textBuffer.slice(bestPos + bestVariant.open.length); this.activeEndTag = bestVariant.close; this.inThinking = true; return; } const trailing = maxTrailingPrefixLength( this.textBuffer, THINKING_TAG_VARIANTS.map((v) => v.open), ); const safeLen = this.textBuffer.length - trailing; if (safeLen > 0) { this.emitText(this.textBuffer.slice(0, safeLen)); this.textBuffer = this.textBuffer.slice(safeLen); } } private processInsideThinking(): void { const endPos = this.textBuffer.indexOf(this.activeEndTag); if (endPos !== -1) { if (log.isDebug()) { log.debug("thinking.close", { tag: this.activeEndTag, at: endPos }); } if (endPos > 0) this.emitThinking(this.textBuffer.slice(0, endPos)); if (this.thinkingBlockIndex !== null) { const block = this.output.content[this.thinkingBlockIndex] as ThinkingContent | undefined; if (block) { this.stream.push({ type: "thinking_end", contentIndex: this.thinkingBlockIndex, content: block.thinking, partial: this.output, }); } } this.textBuffer = this.textBuffer.slice(endPos + this.activeEndTag.length); this.inThinking = false; this.thinkingExtracted = true; this.lastTextBlockIndex = this.textBlockIndex; this.textBlockIndex = null; if (this.textBuffer.startsWith("\n\n")) this.textBuffer = this.textBuffer.slice(2); return; } const trailing = trailingPrefixLength(this.textBuffer, this.activeEndTag); const safeLen = this.textBuffer.length - trailing; if (safeLen > 0) { this.emitThinking(this.textBuffer.slice(0, safeLen)); this.textBuffer = this.textBuffer.slice(safeLen); } } private processAfterThinking(): void { this.emitText(this.textBuffer); this.textBuffer = ""; } private emitText(text: string): void { if (!text) return; if (this.textBlockIndex === null) { this.textBlockIndex = this.output.content.length; this.output.content.push({ type: "text", text: "" }); this.stream.push({ type: "text_start", contentIndex: this.textBlockIndex, partial: this.output }); } const block = this.output.content[this.textBlockIndex] as TextContent | undefined; if (!block) return; block.text += text; this.stream.push({ type: "text_delta", contentIndex: this.textBlockIndex, delta: text, partial: this.output, }); } private emitThinking(thinking: string): void { if (!thinking) return; if (this.thinkingBlockIndex === null) { if (this.textBlockIndex !== null) { // Thinking arrived after text; splice it before the text block so // content order is thinking → text. this.thinkingBlockIndex = this.textBlockIndex; this.output.content.splice(this.thinkingBlockIndex, 0, { type: "thinking", thinking: "" }); this.textBlockIndex = this.textBlockIndex + 1; } else { this.thinkingBlockIndex = this.output.content.length; this.output.content.push({ type: "thinking", thinking: "" }); } this.stream.push({ type: "thinking_start", contentIndex: this.thinkingBlockIndex, partial: this.output, }); } const block = this.output.content[this.thinkingBlockIndex] as ThinkingContent | undefined; if (!block) return; block.thinking += thinking; this.stream.push({ type: "thinking_delta", contentIndex: this.thinkingBlockIndex, delta: thinking, partial: this.output, }); } }