diff --git a/tests/command_line/test_add_model_menu_coverage.py b/tests/command_line/test_add_model_menu_coverage.py index f62e12b1a..e5555e7d5 100644 --- a/tests/command_line/test_add_model_menu_coverage.py +++ b/tests/command_line/test_add_model_menu_coverage.py @@ -835,6 +835,7 @@ def run_side_effect(**kwargs): def test_run_pending_credentials_success( self, mock_sleep, mock_stdout, mock_input, mock_app_cls, mock_set_await ): + mock_input.return_value = "test-key" m = _make_model() p = _make_provider(env=[]) menu = _make_menu_with_providers([p]) diff --git a/tests/mcp/conftest.py b/tests/mcp/conftest.py index 5741d7088..b6b44e8d4 100644 --- a/tests/mcp/conftest.py +++ b/tests/mcp/conftest.py @@ -237,7 +237,17 @@ def mock_get_current_agent(): mock_agent = Mock() mock_agent.reload_code_generation_agent = Mock() - with patch("code_puppy.agents.get_current_agent", return_value=mock_agent) as mock: + with ( + patch("code_puppy.agents.get_current_agent", return_value=mock_agent) as mock, + patch( + "code_puppy.command_line.mcp.start_command.get_current_agent", + return_value=mock_agent, + ), + patch( + "code_puppy.command_line.mcp.stop_command.get_current_agent", + return_value=mock_agent, + ), + ): mock.agent = mock_agent yield mock diff --git a/ts/packages/core/src/agent.test.ts b/ts/packages/core/src/agent.test.ts index 99f1258e7..93b7c783b 100644 --- a/ts/packages/core/src/agent.test.ts +++ b/ts/packages/core/src/agent.test.ts @@ -125,9 +125,10 @@ test("context size prefers the API's real input_tokens over the estimate", async // Cold start: no API reading yet → falls back to the chars/2.5 estimate. expect(engine.estimateContextTokens()).toBeLessThan(10); await engine.runTurn("demo", { onTextDelta: () => {}, onStep: () => {} }); - // The mock's final request reports input_tokens: 99 — the REAL reading — - // while the estimate for this history would be far larger. - expect(engine.estimateContextTokens()).toBe(99); + // The mock's final request reports input_tokens: 99 + 40 cache-read + + // 10 cache-write — the true prompt size is the SUM (input_tokens is only + // the uncached remainder when prompt caching is active). + expect(engine.estimateContextTokens()).toBe(149); }); test("request cap: hitting the ceiling is loud and hands back a resume path", async () => { @@ -172,6 +173,88 @@ test("anti-stall: premature end_turn with open plan items triggers auto-continue } }); +test("lens: turn ledger records requests, tools, and totals", async () => { + const dir = `/tmp/mist-ts-lens-${Date.now()}`; + await Bun.$`mkdir -p ${dir}`; + await Bun.write(`${dir}/demo.txt`, "say hello now"); + const engine = new MistEngine(dir); + await engine.runTurn("demo", { onTextDelta: () => {}, onStep: () => {} }); + + const turns = engine.getLens(); + expect(turns).toHaveLength(1); + const turn = turns[0]!; + expect(turn.prompt).toBe("demo"); + // Scripted mock flow: plan → shell → edit → answer = 4 requests. + expect(turn.requests.length).toBe(4); + const tools = turn.requests.flatMap((r) => r.toolCalls); + expect(tools.map((t) => t.name)).toEqual(["update_plan", "shell", "replace_in_file"]); + const shell = tools.find((t) => t.name === "shell")!; + expect(shell.outputChars).toBeGreaterThan(0); + expect(shell.outputPreview).toContain("1"); // seq 1 4 output captured + // Totals: billed input is the SUM across requests; usage came from the mock. + const { lensTotals } = await import("./lens"); + const t = lensTotals(turn); + expect(t.requests).toBe(4); + expect(t.toolCalls).toBe(3); + expect(t.billedInputTokens).toBeGreaterThan(0); + expect(t.outputTokens).toBeGreaterThan(0); + // Cache accounting from the final request's usage (40 read / 10 written). + expect(t.cacheReadTokens).toBe(40); + expect(t.cacheWriteTokens).toBe(10); + const final = turn.requests[turn.requests.length - 1]!; + expect(final.inputTokens).toBe(149); // 99 uncached + 40 read + 10 written +}); + +test("prompt-cache breakpoints are sent on the wire (and MIST_CACHE=0 disables)", async () => { + let captured: Record | null = null; + const intercept = Bun.serve({ + port: 9941, + async fetch(req) { + captured = (await req.json()) as Record; + return new Response( + `data: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } })}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ); + }, + }); + try { + const { AnthropicClient } = await import("./anthropic"); + const client = new AnthropicClient(`http://127.0.0.1:9941`, "k", "m"); + await client.stream("sys", [{ role: "user", content: "hi" }], [], {}); + const sys = captured!["system"] as { cache_control?: unknown }[]; + expect(sys[0]!.cache_control).toEqual({ type: "ephemeral" }); + const msgs = captured!["messages"] as { content: { cache_control?: unknown }[] }[]; + expect(msgs[0]!.content[msgs[0]!.content.length - 1]!.cache_control).toEqual({ type: "ephemeral" }); + + process.env.MIST_CACHE = "0"; + await client.stream("sys", [{ role: "user", content: "hi" }], [], {}); + expect(typeof captured!["system"]).toBe("string"); // plain, no breakpoints + } finally { + delete process.env.MIST_CACHE; + intercept.stop(true); + } +}); + +test("lens: subagent traffic is attributed to the subagent entry", async () => { + process.env.MOCK_SUB = "1"; + try { + const dir = `/tmp/mist-ts-lens-sub-${Date.now()}`; + await Bun.$`mkdir -p ${dir}`; + await Bun.write(`${dir}/demo.txt`, "say hello now"); + const engine = new MistEngine(dir); + await engine.runTurn("demo", { onTextDelta: () => {}, onStep: () => {} }); + const turn = engine.getLens()[0]!; + expect(turn.subagents.length).toBe(2); + for (const s of turn.subagents) { + expect(s.inputTokens).toBeGreaterThan(0); // child usage attributed here + expect(s.reportChars).toBeGreaterThan(0); + expect(s.error).toBeUndefined(); + } + } finally { + delete process.env.MOCK_SUB; + } +}); + test("pre-tool hook blocks a matching shell command", async () => { const dir = `/tmp/mist-ts-hook-${Date.now()}`; await Bun.$`mkdir -p ${dir}/.mist`; diff --git a/ts/packages/core/src/agent.ts b/ts/packages/core/src/agent.ts index 5ce39a76f..af7346287 100644 --- a/ts/packages/core/src/agent.ts +++ b/ts/packages/core/src/agent.ts @@ -33,6 +33,7 @@ import { SUMMARIZATION_PROMPT, } from "./compaction"; import { runTool, toolSpecs } from "./tools"; +import type { RequestLens, SubagentLens, TurnLens } from "./lens"; import { McpManager } from "./mcp"; export const SYSTEM_PROMPT = `You are Mist, an AI coding agent helping the developer complete software-engineering work. @@ -61,7 +62,7 @@ Communicating results: - Don't narrate routine steps ("Let me check…") — the UI shows tool activity live. Work quietly, then summarize once. - Don't end with a plan or "Want me to…?" — do the work, then report.`; -const ENGINE_TOOLS: ToolSpec[] = [ +export const ENGINE_TOOLS: ToolSpec[] = [ { name: "update_plan", description: @@ -130,6 +131,8 @@ export interface AgentCallbacks { onToolDone?: (label: string, preview: string[], hiddenLines: number) => void; onThought?: (ms: number) => void; onSubagent?: (ev: SubagentEvent) => void; + /** A transient API failure is being retried after a backoff wait. */ + onRetry?: (attempt: number, maxAttempts: number, delayMs: number, reason: string) => void; } export type SubagentEvent = @@ -172,6 +175,8 @@ export class MistEngine { /** MCP server manager; null until attachMcp() is called. */ mcp: McpManager | null = null; plan: PlanItem[] = []; + /** Lens explainability ledger — one entry per completed turn (cap 50). */ + private lensTurns: TurnLens[] = []; /** Real context size: input_tokens reported by the last API request. */ private lastInputTokens = 0; /** context_length from the model registry, when declared. */ @@ -435,12 +440,28 @@ export class MistEngine { async runTurn(prompt: string, cb: AgentCallbacks): Promise { const client = await this.ensureClient(); const system = await this.systemPrompt(); + // Lens ledger: everything this turn does gets accounted here. + const lens: TurnLens = { + prompt: prompt.slice(0, 200), + startedAt: new Date().toISOString(), + ms: 0, + requests: [], + subagents: [], + autoContinues: 0, + capHit: false, + compactions: [], + }; + this.currentLens = lens; + const turnStart = Date.now(); // Proactive hygiene: stale tool results never accumulate. this.history = clearStaleToolResults(this.history).messages; // Auto-compact when the estimate crosses the threshold. if (this.estimateContextTokens() > this.compactThreshold()) { const r = await this.compact().catch(() => null); - if (r) cb.onCompacted?.(r); + if (r) { + cb.onCompacted?.(r); + lens.compactions.push({ beforeTokens: r.beforeTokens, afterTokens: r.afterTokens, summarized: r.summarized }); + } } this.history.push({ role: "user", content: prompt }); // Children never get invoke_subagent — one level of delegation only. @@ -463,13 +484,34 @@ export class MistEngine { for (let request = 0; request < maxRequests; request++) { this.drainSteers(); + const reqStart = Date.now(); const result = await client.stream(system, this.history, specs, { onTextDelta: cb.onTextDelta, + onRetry: cb.onRetry, }); - cb.onUsage?.(result.inputTokens, result.outputTokens); + // With prompt caching, input_tokens is only the UNCACHED remainder — + // the true prompt size is input + cache_read + cache_write. + const promptTokens = + result.inputTokens + (result.cacheReadTokens ?? 0) + (result.cacheWriteTokens ?? 0); + const reqLens: RequestLens = { + index: request, + ms: Date.now() - reqStart, + inputTokens: promptTokens, + cacheReadTokens: result.cacheReadTokens ?? 0, + cacheWriteTokens: result.cacheWriteTokens ?? 0, + outputTokens: result.outputTokens, + // Prefer REAL reasoning tokens (OpenAI o-series); else chars/3.5. + estThinkingTokens: result.reasoningTokens ?? Math.round((result.thinkingChars ?? 0) / 3.5), + thinkingMs: result.thinkingMs, + stopReason: result.stopReason, + textChars: result.text.length, + toolCalls: [], + }; + lens.requests.push(reqLens); + cb.onUsage?.(promptTokens, result.outputTokens); // Track the real context size (some third-party endpoints report 0 — // keep the last good reading). - if (result.inputTokens > 0) this.lastInputTokens = result.inputTokens; + if (promptTokens > 0) this.lastInputTokens = promptTokens; if (result.thinkingMs > 500) cb.onThought?.(result.thinkingMs); const assistantBlocks: ContentBlock[] = []; @@ -518,6 +560,10 @@ export class MistEngine { if (tu.name === "update_plan") { this.plan = normalizePlan(input["items"]); cb.onPlan?.(this.plan); + reqLens.toolCalls.push({ + name: "update_plan", label: `plan updated (${this.plan.length} items)`, + ms: 0, outputChars: 0, outputPreview: "", isError: false, blockedByHook: false, + }); toolResults.push({ type: "tool_result", tool_use_id: tu.id, @@ -531,19 +577,31 @@ export class MistEngine { .map((o) => String(o).trim()) .filter(Boolean) .slice(0, 6); + const askStart = Date.now(); const answer = cb.onQuestion ? await cb.onQuestion(question, options) : "(no user available — proceed with your best judgment)"; + reqLens.toolCalls.push({ + name: "ask_user", label: question.slice(0, 100), + ms: Date.now() - askStart, // includes human response time + outputChars: answer.length, outputPreview: answer.slice(0, 300), + isError: false, blockedByHook: false, + }); toolResults.push({ type: "tool_result", tool_use_id: tu.id, content: answer }); continue; } // ---- MCP-routed tools (namespaced server_tool) -------------------- if (this.mcp && this.mcp.allTools().some((t) => t.name === tu.name)) { + const mcpStart = Date.now(); try { const verdict = this.hooks ? applyPreToolHooks(this.hooks, tu.name, input) : null; if (verdict?.action === "block") { cb.onStep(`⊘ ${tu.name} blocked by hook`); + reqLens.toolCalls.push({ + name: tu.name, label: `blocked: ${verdict.message.slice(0, 80)}`, + ms: 0, outputChars: 0, outputPreview: "", isError: true, blockedByHook: true, + }); toolResults.push({ type: "tool_result", tool_use_id: tu.id, @@ -558,8 +616,18 @@ export class MistEngine { : (result as { content?: { type: string; text?: string }[] })?.content ?.map((c) => c.text ?? "") .join("\n") ?? JSON.stringify(result); + reqLens.toolCalls.push({ + name: tu.name, label: `mcp ${tu.name}`, + ms: Date.now() - mcpStart, outputChars: text.length, + outputPreview: text.slice(0, 1500), isError: false, blockedByHook: false, + }); toolResults.push({ type: "tool_result", tool_use_id: tu.id, content: text }); } catch (e) { + reqLens.toolCalls.push({ + name: tu.name, label: `mcp ${tu.name} failed`, + ms: Date.now() - mcpStart, outputChars: 0, + outputPreview: (e as Error).message.slice(0, 300), isError: true, blockedByHook: false, + }); toolResults.push({ type: "tool_result", tool_use_id: tu.id, @@ -574,6 +642,10 @@ export class MistEngine { const verdict = this.hooks ? applyPreToolHooks(this.hooks, tu.name, input) : null; if (verdict?.action === "block") { cb.onStep(`⊘ ${tu.name} blocked by hook`); + reqLens.toolCalls.push({ + name: tu.name, label: `blocked: ${verdict.message.slice(0, 80)}`, + ms: 0, outputChars: 0, outputPreview: "", isError: true, blockedByHook: true, + }); toolResults.push({ type: "tool_result", tool_use_id: tu.id, @@ -585,6 +657,7 @@ export class MistEngine { steps += 1; let stepLabel = ""; + const toolStart = Date.now(); const res = await runTool(tu.name, input, { cwd: this.cwd, onStep: (label) => { @@ -593,6 +666,12 @@ export class MistEngine { }, onDiff: cb.onDiff, }); + reqLens.toolCalls.push({ + name: tu.name, label: stepLabel || tu.name, + ms: Date.now() - toolStart, outputChars: res.content.length, + outputPreview: res.content.slice(0, 1500), + isError: Boolean(res.isError), blockedByHook: false, + }); { const outLines = res.content.split("\n").filter((l) => l.trim()); const preview = outLines.slice(0, 2).map((l) => (l.length > 90 ? `${l.slice(0, 89)}…` : l)); @@ -617,8 +696,10 @@ export class MistEngine { // real reading; one failed attempt stops retries for the rest of the turn. if (!midTurnCompactFailed && this.lastInputTokens > this.compactThreshold()) { const r = await this.compact().catch(() => null); - if (r) cb.onCompacted?.(r); - else midTurnCompactFailed = true; + if (r) { + cb.onCompacted?.(r); + lens.compactions.push({ beforeTokens: r.beforeTokens, afterTokens: r.afterTokens, summarized: r.summarized }); + } else midTurnCompactFailed = true; } } if (!endedNaturally) { @@ -631,9 +712,21 @@ export class MistEngine { `All progress is saved in this session — send "continue" and I'll pick up exactly where I left off. ` + `(Raise MIST_MAX_REQUESTS to allow longer turns.)`; } + lens.autoContinues = autoContinues; + lens.capHit = !endedNaturally; + lens.ms = Date.now() - turnStart; + this.currentLens = null; + this.lensTurns.push(lens); + if (this.lensTurns.length > 50) this.lensTurns.shift(); return { finalText, steps }; } + /** The lens ledger — one structured trace per completed turn (newest last). */ + getLens(): TurnLens[] { + return [...this.lensTurns]; + } + + private currentLens: TurnLens | null = null; private subagentSeq = 0; /** Run one delegated task in a fresh child engine; only the report returns. */ @@ -656,19 +749,36 @@ export class MistEngine { cb.onSubagent?.({ phase: "started", id, label, task }); const child = new MistEngine(this.cwd, true); if (this.modelOverride) child.setModel(this.modelOverride); + // Lens: attribute the child's traffic to THIS subagent (its usage still + // forwards to the parent's totals via cb.onUsage). + const subLens: SubagentLens = { + id, label, task: task.slice(0, 300), + steps: 0, inputTokens: 0, outputTokens: 0, ms: 0, reportChars: 0, + }; + this.currentLens?.subagents.push(subLens); + const subStart = Date.now(); try { const turn = await child.runTurn(task, { onTextDelta: () => {}, onStep: (step) => cb.onSubagent?.({ phase: "step", id, label, step }), - onUsage: cb.onUsage, + onUsage: (i, o) => { + subLens.inputTokens += i; + subLens.outputTokens += o; + cb.onUsage?.(i, o); + }, onDiff: cb.onDiff, // file edits surface in the transcript regardless of who made them onSavings: cb.onSavings, }); const report = turn.finalText.trim() || "(subagent finished without a report)"; + subLens.steps = turn.steps; + subLens.ms = Date.now() - subStart; + subLens.reportChars = report.length; cb.onSubagent?.({ phase: "done", id, label, steps: turn.steps, report }); return { type: "tool_result", tool_use_id: toolUseId, content: `[subagent "${label}" report]\n${report}` }; } catch (err) { const error = (err as Error).message; + subLens.ms = Date.now() - subStart; + subLens.error = error; cb.onSubagent?.({ phase: "error", id, label, error }); return { type: "tool_result", diff --git a/ts/packages/core/src/anthropic.ts b/ts/packages/core/src/anthropic.ts index 09e15b081..c1d847250 100644 --- a/ts/packages/core/src/anthropic.ts +++ b/ts/packages/core/src/anthropic.ts @@ -36,6 +36,26 @@ export class AnthropicClient implements ModelClient { maxTokens = 8192, ): Promise { const url = `${this.baseUrl.replace(/\/$/, "")}/v1/messages`; + // Prompt caching (MIST_CACHE=0 disables): breakpoint on the system block + // (caches tools+system) and on the last message block (caches the whole + // conversation prefix — the agent loop resends it every request, so + // subsequent requests read it at ~0.1x instead of paying full price). + const useCache = process.env.MIST_CACHE !== "0"; + const ephemeral = { type: "ephemeral" as const }; + const systemPayload = useCache + ? [{ type: "text", text: system, cache_control: ephemeral }] + : system; + let wireMessages: unknown[] = messages; + if (useCache && messages.length) { + const last = messages[messages.length - 1]!; + const lastContent = + typeof last.content === "string" + ? [{ type: "text", text: last.content, cache_control: ephemeral }] + : last.content.map((b, i) => + i === last.content.length - 1 ? { ...b, cache_control: ephemeral } : b, + ); + wireMessages = [...messages.slice(0, -1), { ...last, content: lastContent }]; + } const res = await fetch(url, { method: "POST", headers: { @@ -46,8 +66,8 @@ export class AnthropicClient implements ModelClient { }, body: JSON.stringify({ model: this.model, - system, - messages, + system: systemPayload, + messages: wireMessages, tools: tools.length ? tools : undefined, max_tokens: maxTokens, stream: true, @@ -116,6 +136,7 @@ export class AnthropicClient implements ModelClient { if (delta0?.["type"] === "thinking_delta") { if (thinkingStart === 0) thinkingStart = Date.now(); thinkingLast = Date.now(); + result.thinkingChars = (result.thinkingChars ?? 0) + String(delta0["thinking"] ?? "").length; } } const idx = ev["index"] as number; @@ -139,6 +160,12 @@ export class AnthropicClient implements ModelClient { const usage = msg?.["usage"] as Record | undefined; if (typeof usage?.["input_tokens"] === "number") result.inputTokens = usage["input_tokens"] as number; + // Cache accounting: input_tokens is only the UNCACHED remainder — + // total prompt = input + cache_read + cache_creation. + if (typeof usage?.["cache_read_input_tokens"] === "number") + result.cacheReadTokens = usage["cache_read_input_tokens"] as number; + if (typeof usage?.["cache_creation_input_tokens"] === "number") + result.cacheWriteTokens = usage["cache_creation_input_tokens"] as number; } else if (type === "error") { const err = ev["error"] as Record | undefined; throw new Error(`stream error: ${String(err?.["message"] ?? data).slice(0, 300)}`); diff --git a/ts/packages/core/src/gemini.test.ts b/ts/packages/core/src/gemini.test.ts index 759c8b2c3..5a66ee969 100644 --- a/ts/packages/core/src/gemini.test.ts +++ b/ts/packages/core/src/gemini.test.ts @@ -77,7 +77,10 @@ describe("GeminiClient", () => { expect(result.toolUses).toHaveLength(0); expect(result.stopReason).toBe("stop"); expect(deltas.join("")).toContain("**four numbers**"); - expect(result.inputTokens).toBe(50); + // promptTokenCount 50 with 35 cached → uncached remainder + cached + // subset (sum = 50). + expect(result.inputTokens).toBe(15); + expect(result.cacheReadTokens).toBe(35); mock.stop(); }); diff --git a/ts/packages/core/src/gemini.ts b/ts/packages/core/src/gemini.ts index 52caf9e29..048edf9f9 100644 --- a/ts/packages/core/src/gemini.ts +++ b/ts/packages/core/src/gemini.ts @@ -48,6 +48,8 @@ interface GChunk { usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number; + cachedContentTokenCount?: number; + thoughtsTokenCount?: number; }; } @@ -162,6 +164,17 @@ export class GeminiClient implements ModelClient { result.inputTokens = chunk.usageMetadata.promptTokenCount; if (typeof chunk.usageMetadata.candidatesTokenCount === "number") result.outputTokens = chunk.usageMetadata.candidatesTokenCount; + // Gemini 2.5+ implicit caching: cachedContentTokenCount is the + // cached SUBSET of promptTokenCount — split per TurnResult's + // contract (inputTokens = uncached remainder). + const cached = chunk.usageMetadata.cachedContentTokenCount; + if (typeof cached === "number" && cached > 0) { + result.cacheReadTokens = cached; + result.inputTokens = Math.max(0, result.inputTokens - cached); + } + // Real reasoning-token usage (billed separately from candidates). + if (typeof chunk.usageMetadata.thoughtsTokenCount === "number") + result.reasoningTokens = chunk.usageMetadata.thoughtsTokenCount; } const parts = chunk.candidates?.[0]?.content?.parts ?? []; for (const part of parts) { diff --git a/ts/packages/core/src/index.ts b/ts/packages/core/src/index.ts index 4a5635bcd..62d8a15fe 100644 --- a/ts/packages/core/src/index.ts +++ b/ts/packages/core/src/index.ts @@ -18,6 +18,7 @@ export { AnthropicClient } from "./anthropic"; export { OpenAIClient } from "./openai"; export { GeminiClient } from "./gemini"; export { RoundRobinClient } from "./round_robin"; +export { RetryingClient, isRetryableError } from "./retry"; export { createModelClient, configFromDef, envKeyForType, defaultBaseForType } from "./models"; export { startHeadlessServer } from "./headless"; export type { ServeOptions } from "./headless"; @@ -50,3 +51,7 @@ export type { ToolSpec, TurnResult, } from "./models"; +export { lensTotals, renderLensHtml } from "./lens"; +export type { TurnLens, RequestLens, SubagentLens, ToolCallLens, LensTotals } from "./lens"; +export { exportTraining, redactSecrets } from "./training_export"; +export type { TrainingExportOptions, TrainingExportResult } from "./training_export"; diff --git a/ts/packages/core/src/lens.ts b/ts/packages/core/src/lens.ts new file mode 100644 index 000000000..81c43f84d --- /dev/null +++ b/ts/packages/core/src/lens.ts @@ -0,0 +1,243 @@ +/** + * Lens — Mist's explainability & interpretability ledger. + * + * Answers "why did this small command burn 80k tokens?": every turn records a + * structured trace of model requests (tokens in/out, thinking share, stop + * reason), tool calls (duration, output size, previews, errors, hook blocks), + * subagents (with their OWN token attribution), and engine events + * (auto-continues, request-cap exits, compactions). + * + * Token semantics (important): + * - `inputTokens`/`outputTokens` are the API's real usage numbers. + * - THINKING IS INCLUDED in outputTokens — providers bill reasoning as output. + * `estThinkingTokens` (chars/3.5 over thinking deltas) separates the share + * so /lens can show how much of the spend was reasoning vs. visible text. + * - Billed input = Σ per-request input (the WHOLE history is resent every + * request — the usual culprit when a short prompt costs a lot). + */ + +export interface ToolCallLens { + name: string; + /** Display label (e.g. `$ seq 1 4`, `edited foo.ts`). */ + label: string; + ms: number; + outputChars: number; + /** First ~1500 chars of the tool result (full output lives in history). */ + outputPreview: string; + isError: boolean; + blockedByHook: boolean; +} + +export interface RequestLens { + index: number; + ms: number; + /** Full prompt size: uncached + cache-read + cache-write tokens. */ + inputTokens: number; + /** Of inputTokens, served from prompt cache (~0.1x price). */ + cacheReadTokens: number; + /** Of inputTokens, written to prompt cache (~1.25x price). */ + cacheWriteTokens: number; + /** Includes thinking — see estThinkingTokens for the reasoning share. */ + outputTokens: number; + estThinkingTokens: number; + thinkingMs: number; + stopReason: string; + textChars: number; + toolCalls: ToolCallLens[]; +} + +export interface SubagentLens { + id: string; + label: string; + task: string; + steps: number; + inputTokens: number; + outputTokens: number; + ms: number; + reportChars: number; + error?: string; +} + +export interface CompactionLens { + beforeTokens: number; + afterTokens: number; + summarized: number; +} + +export interface TurnLens { + prompt: string; + startedAt: string; + ms: number; + requests: RequestLens[]; + subagents: SubagentLens[]; + autoContinues: number; + capHit: boolean; + compactions: CompactionLens[]; +} + +export interface LensTotals { + requests: number; + toolCalls: number; + subagents: number; + /** Σ per-request prompt tokens (uncached + cached) across the turn. */ + billedInputTokens: number; + /** Of billedInputTokens, served from prompt cache at ~0.1x price. */ + cacheReadTokens: number; + cacheWriteTokens: number; + outputTokens: number; + estThinkingTokens: number; + /** Prompt size of the LAST request — the live context size. */ + finalContextTokens: number; + toolMs: number; + modelMs: number; + toolErrors: number; + hookBlocks: number; +} + +export function lensTotals(turn: TurnLens): LensTotals { + let billed = 0; + let cacheRead = 0; + let cacheWrite = 0; + let out = 0; + let think = 0; + let toolCalls = 0; + let toolMs = 0; + let modelMs = 0; + let errors = 0; + let blocks = 0; + for (const r of turn.requests) { + billed += r.inputTokens; + cacheRead += r.cacheReadTokens ?? 0; + cacheWrite += r.cacheWriteTokens ?? 0; + out += r.outputTokens; + think += r.estThinkingTokens; + modelMs += r.ms; + for (const t of r.toolCalls) { + toolCalls += 1; + toolMs += t.ms; + if (t.isError) errors += 1; + if (t.blockedByHook) blocks += 1; + } + } + // Subagent traffic is attributed separately AND included in nothing else — + // child requests never appear in the parent's request list. + for (const s of turn.subagents) { + billed += s.inputTokens; + out += s.outputTokens; + } + return { + requests: turn.requests.length, + toolCalls, + subagents: turn.subagents.length, + billedInputTokens: billed, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite, + outputTokens: out, + estThinkingTokens: think, + finalContextTokens: turn.requests[turn.requests.length - 1]?.inputTokens ?? 0, + toolMs, + modelMs, + toolErrors: errors, + hookBlocks: blocks, + }; +} + +const esc = (s: string): string => + s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); + +const fmt = (n: number): string => n.toLocaleString("en-US"); + +/** + * Self-contained interactive HTML report — no external assets, opens in any + * browser. Timeline of requests with token bars (input / output / thinking + * share), expandable tool calls with output previews, subagent cards. + */ +export function renderLensHtml(turns: TurnLens[], sessionId: string): string { + const rows = turns + .map((turn, ti) => { + const t = lensTotals(turn); + const maxTok = Math.max(1, ...turn.requests.map((r) => r.inputTokens + r.outputTokens)); + const reqRows = turn.requests + .map((r) => { + const thinkPct = r.outputTokens ? Math.min(100, Math.round((r.estThinkingTokens / r.outputTokens) * 100)) : 0; + const tools = r.toolCalls + .map( + (c) => ` +
+ ${esc(c.name)} ${esc(c.label.slice(0, 100))} + ${c.ms}ms · ${fmt(c.outputChars)} chars${c.isError ? " · ERROR" : ""}${c.blockedByHook ? " · BLOCKED BY HOOK" : ""} + +
${esc(c.outputPreview) || "(empty output)"}
+
`, + ) + .join(""); + return ` +
+
+ #${r.index + 1} + + in ${fmt(r.inputTokens)}${r.cacheReadTokens ? ` (${fmt(r.cacheReadTokens)} cached)` : ""} · out ${fmt(r.outputTokens)}${r.estThinkingTokens ? ` (≈${thinkPct}% thinking)` : ""} · ${r.ms}ms · ${esc(r.stopReason)} +
+ ${tools} +
`; + }) + .join(""); + const subs = turn.subagents + .map( + (s) => ` +
+ ◇ ${esc(s.label)} ${s.steps} steps · in ${fmt(s.inputTokens)} · out ${fmt(s.outputTokens)} · ${s.ms}ms${s.error ? " · FAILED" : ""} +
task: ${esc(s.task.slice(0, 500))}${s.error ? `\nerror: ${esc(s.error)}` : ""}
+
`, + ) + .join(""); + const flags = [ + turn.autoContinues ? `⟳ ${turn.autoContinues} auto-continue(s)` : "", + turn.capHit ? "⏸ request cap hit" : "", + ...turn.compactions.map((c) => `⇣ compacted ${fmt(c.beforeTokens)} → ${fmt(c.afterTokens)}`), + ] + .filter(Boolean) + .join(" · "); + return ` +
+

Turn ${ti + 1}: ${esc(turn.prompt.slice(0, 120))}

+
+
${fmt(t.billedInputTokens)}billed input tok
+
${fmt(t.outputTokens)}output tok
+
${fmt(t.estThinkingTokens)}≈ thinking tok
+
${fmt(t.finalContextTokens)}context size
+
${t.requests}model requests
+
${t.toolCalls}tool calls
+
${t.subagents}subagents
+
${Math.round(t.modelMs / 1000)}s / ${Math.round(t.toolMs / 1000)}smodel / tool time
+
+ ${flags ? `

${flags}

` : ""} + ${reqRows} + ${subs ? `

Subagents

${subs}` : ""} +
`; + }) + .join(""); + + return `Mist Lens — ${esc(sessionId.slice(0, 8))} + +

◆ Mist Lens session ${esc(sessionId.slice(0, 8))} · ${turns.length} turn(s)

+

Blue = input tokens (whole history resent per request) · green = output (thinking included; ≈share shown). Click any tool or subagent to expand.

+${rows}`; +} diff --git a/ts/packages/core/src/models.ts b/ts/packages/core/src/models.ts index 12874c0bb..a01b6f706 100644 --- a/ts/packages/core/src/models.ts +++ b/ts/packages/core/src/models.ts @@ -38,11 +38,21 @@ export interface StreamCallbacks { onTextDelta?: (text: string) => void; /** Fired the instant a tool call begins (before its arguments finish). */ onToolUse?: (name: string) => void; + /** A transient API failure is being retried after a backoff wait. */ + onRetry?: (attempt: number, maxAttempts: number, delayMs: number, reason: string) => void; } export interface TurnResult { text: string; thinkingMs: number; + /** Chars of streamed thinking deltas (reasoning-share estimate for /lens). */ + thinkingChars?: number; + /** REAL reasoning tokens when the provider reports them (OpenAI o-series). */ + reasoningTokens?: number; + /** Prompt-cache accounting: tokens served from cache (~0.1x price). */ + cacheReadTokens?: number; + /** Prompt-cache accounting: tokens written to cache (~1.25x price). */ + cacheWriteTokens?: number; toolUses: { id: string; name: string; input: unknown }[]; /** Provider-native stop reason; agent loop checks against "tool_use". */ stopReason: string; @@ -113,10 +123,15 @@ export async function createModelClient( return new RoundRobinClient(kids, { rotateEvery: cfg.rotate_every ?? 1 }); } + // Leaf clients get transient-failure retries (round_robin candidates are + // wrapped here too via the recursive resolve — never wrap the outer node, + // that would multiply attempts). + const { RetryingClient } = await import("./retry"); + // Anthropic-compatible: anthropic, custom_anthropic, claude_code, aws_bedrock. if (t === "anthropic" || t === "custom_anthropic" || t === "claude_code" || t === "aws_bedrock") { const { AnthropicClient } = await import("./anthropic"); - return new AnthropicClient(cfg.baseUrl, cfg.apiKey, cfg.model); + return new RetryingClient(new AnthropicClient(cfg.baseUrl, cfg.apiKey, cfg.model)); } // OpenAI-compatible chat-completions family. if ( @@ -134,12 +149,12 @@ export async function createModelClient( t === "minimax" ) { const { OpenAIClient } = await import("./openai"); - return new OpenAIClient(cfg.baseUrl, cfg.apiKey, cfg.model, cfg.type); + return new RetryingClient(new OpenAIClient(cfg.baseUrl, cfg.apiKey, cfg.model, cfg.type)); } // Gemini family — generateContent stream with Google function-calling schema. if (t === "gemini" || t === "custom_gemini" || t === "gemini_oauth") { const { GeminiClient } = await import("./gemini"); - return new GeminiClient(cfg.baseUrl, cfg.apiKey, cfg.model, cfg.type); + return new RetryingClient(new GeminiClient(cfg.baseUrl, cfg.apiKey, cfg.model, cfg.type)); } throw new Error(`unknown model type '${t}' for model '${cfg.model}'`); } diff --git a/ts/packages/core/src/openai.test.ts b/ts/packages/core/src/openai.test.ts index cce5e2ad7..5ce3d8ee5 100644 --- a/ts/packages/core/src/openai.test.ts +++ b/ts/packages/core/src/openai.test.ts @@ -102,7 +102,10 @@ describe("OpenAIClient", () => { expect(result.toolUses).toHaveLength(0); expect(result.stopReason).toBe("stop"); expect(deltas.join("")).toContain("**four numbers**"); - expect(result.inputTokens).toBe(42); + // prompt_tokens 42 with 30 cached → inputTokens is the uncached + // remainder, cacheReadTokens the cached subset (sum = 42). + expect(result.inputTokens).toBe(12); + expect(result.cacheReadTokens).toBe(30); expect(result.outputTokens).toBe(55); mock.stop(); diff --git a/ts/packages/core/src/openai.ts b/ts/packages/core/src/openai.ts index b17c3fb83..531b6ada6 100644 --- a/ts/packages/core/src/openai.ts +++ b/ts/packages/core/src/openai.ts @@ -38,7 +38,7 @@ interface OAIFunctionSpec { }; } -interface OAIMessage { +export interface OAIMessage { role: "system" | "user" | "assistant" | "tool"; content?: string | null; tool_calls?: { @@ -49,6 +49,52 @@ interface OAIMessage { tool_call_id?: string; } +/** + * Anthropic-shaped history → OpenAI chat messages. Shared by the streaming + * client and the training-data exporter (open-model SFT stacks consume the + * OpenAI function-calling shape). + */ +export function toOpenAIMessages(system: string, messages: ChatMessage[]): OAIMessage[] { + const out: OAIMessage[] = []; + if (system) out.push({ role: "system", content: system }); + for (const msg of messages) { + if (typeof msg.content === "string") { + out.push({ role: msg.role, content: msg.content }); + continue; + } + // Assistant turn: split into content + tool_calls. + if (msg.role === "assistant") { + const texts: string[] = []; + const calls: NonNullable = []; + for (const block of msg.content) { + if (block.type === "text") texts.push(block.text); + else if (block.type === "tool_use") + calls.push({ + id: block.id, + type: "function", + function: { name: block.name, arguments: JSON.stringify(block.input ?? {}) }, + }); + } + const entry: OAIMessage = { role: "assistant", content: texts.join("") || null }; + if (calls.length) entry.tool_calls = calls; + out.push(entry); + continue; + } + // User turn: may contain tool_result blocks and/or text. + const toolResults: ContentBlock[] = []; + const textParts: string[] = []; + for (const block of msg.content) { + if (block.type === "tool_result") toolResults.push(block); + else if (block.type === "text") textParts.push(block.text); + } + for (const tr of toolResults) { + out.push({ role: "tool", tool_call_id: tr.tool_use_id, content: tr.content }); + } + if (textParts.length) out.push({ role: "user", content: textParts.join("\n") }); + } + return out; +} + interface OAIChoiceDelta { role?: string; content?: string | null; @@ -190,6 +236,21 @@ export class OpenAIClient implements ModelClient { result.inputTokens = chunk.usage.prompt_tokens; if (typeof chunk.usage.completion_tokens === "number") result.outputTokens = chunk.usage.completion_tokens; + // o-series reasoning tokens are REAL usage (billed inside + // completion_tokens) — surface them for /lens attribution. + const details = (chunk.usage as { completion_tokens_details?: { reasoning_tokens?: number } }) + .completion_tokens_details; + if (typeof details?.reasoning_tokens === "number") + result.reasoningTokens = details.reasoning_tokens; + // OpenAI caches automatically (prompts ≥1024 tok) and reports the + // cached SUBSET of prompt_tokens. TurnResult's contract is + // inputTokens = uncached remainder (Anthropic semantics), so split. + const pDetails = (chunk.usage as { prompt_tokens_details?: { cached_tokens?: number } }) + .prompt_tokens_details; + if (typeof pDetails?.cached_tokens === "number" && pDetails.cached_tokens > 0) { + result.cacheReadTokens = pDetails.cached_tokens; + result.inputTokens = Math.max(0, result.inputTokens - pDetails.cached_tokens); + } } const choice = chunk.choices?.[0]; if (!choice) continue; @@ -244,47 +305,10 @@ export class OpenAIClient implements ModelClient { return result; } - // ---- Anthropic history → OpenAI messages -------------------------------- + // ---- Anthropic history → OpenAI messages (shared helper above) ---------- private translateHistory(system: string, messages: ChatMessage[]): OAIMessage[] { - const out: OAIMessage[] = []; - if (system) out.push({ role: "system", content: system }); - for (const msg of messages) { - if (typeof msg.content === "string") { - out.push({ role: msg.role, content: msg.content }); - continue; - } - // Assistant turn: split into content + tool_calls. - if (msg.role === "assistant") { - const texts: string[] = []; - const calls: NonNullable = []; - for (const block of msg.content) { - if (block.type === "text") texts.push(block.text); - else if (block.type === "tool_use") - calls.push({ - id: block.id, - type: "function", - function: { name: block.name, arguments: JSON.stringify(block.input ?? {}) }, - }); - } - const entry: OAIMessage = { role: "assistant", content: texts.join("") || null }; - if (calls.length) entry.tool_calls = calls; - out.push(entry); - continue; - } - // User turn: may contain tool_result blocks and/or text. - const toolResults: ContentBlock[] = []; - const textParts: string[] = []; - for (const block of msg.content) { - if (block.type === "tool_result") toolResults.push(block); - else if (block.type === "text") textParts.push(block.text); - } - for (const tr of toolResults) { - out.push({ role: "tool", tool_call_id: tr.tool_use_id, content: tr.content }); - } - if (textParts.length) out.push({ role: "user", content: textParts.join("\n") }); - } - return out; + return toOpenAIMessages(system, messages); } private translateTools(tools: ToolSpec[]): OAIFunctionSpec[] { diff --git a/ts/packages/core/src/retry.test.ts b/ts/packages/core/src/retry.test.ts new file mode 100644 index 000000000..ce459341a --- /dev/null +++ b/ts/packages/core/src/retry.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { AnthropicClient } from "./anthropic"; +import { RetryingClient, isRetryableError } from "./retry"; + +// Fast, deterministic backoff for tests. +const saved: Record = {}; +beforeEach(() => { + saved["MIST_RETRY_BASE_MS"] = process.env.MIST_RETRY_BASE_MS; + saved["MIST_RETRIES"] = process.env.MIST_RETRIES; + process.env.MIST_RETRY_BASE_MS = "5"; +}); +afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } +}); + +const okStream = () => + new Response( + [ + `data: ${JSON.stringify({ type: "message_start", message: { usage: { input_tokens: 5 } } })}`, + `data: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}`, + `data: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "recovered" } })}`, + `data: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } })}`, + "", + ].join("\n\n"), + { headers: { "content-type": "text/event-stream" } }, + ); + +test("classifies retryable vs terminal errors", () => { + expect(isRetryableError(new Error("model call failed: HTTP 529 overloaded"))).toBe(true); + expect(isRetryableError(new Error("model call failed: HTTP 429 rate limited"))).toBe(true); + expect(isRetryableError(new Error("model call failed: HTTP 503 upstream"))).toBe(true); + expect(isRetryableError(new Error("fetch failed"))).toBe(true); + expect(isRetryableError(new Error("stream error: Overloaded"))).toBe(true); + expect(isRetryableError(new Error("model call failed: HTTP 400 bad request"))).toBe(false); + expect(isRetryableError(new Error("model call failed: HTTP 401 bad key"))).toBe(false); +}); + +test("retries transient 529s with backoff, then succeeds", async () => { + let calls = 0; + const flaky = Bun.serve({ + port: 9951, + fetch() { + calls++; + if (calls <= 2) return new Response("overloaded", { status: 529 }); + return okStream(); + }, + }); + try { + const client = new RetryingClient(new AnthropicClient("http://127.0.0.1:9951", "k", "m")); + const retriesSeen: number[] = []; + const result = await client.stream("sys", [{ role: "user", content: "hi" }], [], { + onRetry: (attempt) => retriesSeen.push(attempt), + }); + expect(calls).toBe(3); + expect(retriesSeen).toEqual([1, 2]); + expect(result.text).toBe("recovered"); + } finally { + flaky.stop(true); + } +}); + +test("terminal errors (400) surface immediately — no retry", async () => { + let calls = 0; + const bad = Bun.serve({ + port: 9952, + fetch() { + calls++; + return new Response("bad request", { status: 400 }); + }, + }); + try { + const client = new RetryingClient(new AnthropicClient("http://127.0.0.1:9952", "k", "m")); + await expect(client.stream("sys", [{ role: "user", content: "hi" }], [], {})).rejects.toThrow( + "HTTP 400", + ); + expect(calls).toBe(1); + } finally { + bad.stop(true); + } +}); + +test("never retries after visible output — no duplicated text", async () => { + let calls = 0; + const midStream = Bun.serve({ + port: 9953, + fetch() { + calls++; + // Streams a visible delta, THEN dies with a retryable stream error. + return new Response( + [ + `data: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}`, + `data: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "partial" } })}`, + `data: ${JSON.stringify({ type: "error", error: { message: "Overloaded" } })}`, + "", + ].join("\n\n"), + { headers: { "content-type": "text/event-stream" } }, + ); + }, + }); + try { + const client = new RetryingClient(new AnthropicClient("http://127.0.0.1:9953", "k", "m")); + const deltas: string[] = []; + await expect( + client.stream("sys", [{ role: "user", content: "hi" }], [], { + onTextDelta: (t) => deltas.push(t), + }), + ).rejects.toThrow("Overloaded"); + expect(calls).toBe(1); // guard held: partial text on screen → surface, don't re-send + expect(deltas).toEqual(["partial"]); + } finally { + midStream.stop(true); + } +}); + +test("MIST_RETRIES=0 disables retries entirely", async () => { + let calls = 0; + const flaky = Bun.serve({ + port: 9954, + fetch() { + calls++; + return new Response("overloaded", { status: 529 }); + }, + }); + try { + process.env.MIST_RETRIES = "0"; + const client = new RetryingClient(new AnthropicClient("http://127.0.0.1:9954", "k", "m")); + await expect(client.stream("sys", [{ role: "user", content: "hi" }], [], {})).rejects.toThrow( + "HTTP 529", + ); + expect(calls).toBe(1); + } finally { + flaky.stop(true); + } +}); diff --git a/ts/packages/core/src/retry.ts b/ts/packages/core/src/retry.ts new file mode 100644 index 000000000..78d7d3316 --- /dev/null +++ b/ts/packages/core/src/retry.ts @@ -0,0 +1,92 @@ +/** + * Transient-failure retries for model calls — Claude Code style. + * + * Wraps any leaf ModelClient. Retryable failures (429/5xx/529 overloaded, + * network drops, provider "overloaded" stream errors) back off exponentially + * with jitter and re-send the SAME request; the UI is told via onRetry so the + * wait is visible instead of a silent stall. + * + * Safety rule: NEVER retry once visible output has streamed to the UI — + * a re-send would duplicate text the user already read. Those errors + * surface immediately (the turn is resumable, nothing is lost). + * + * Knobs: MIST_RETRIES (default 5, 0 disables), MIST_RETRY_BASE_MS + * (default 1000; doubles per attempt, capped at 30s, ±25% jitter). + */ + +import type { ChatMessage, ModelClient, StreamCallbacks, ToolSpec, TurnResult } from "./models"; + +const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504, 529]); + +export function isRetryableError(err: unknown): boolean { + const msg = String((err as Error | undefined)?.message ?? err ?? ""); + const http = msg.match(/HTTP (\d{3})/); + if (http) return RETRYABLE_STATUS.has(Number(http[1])); + if (/overloaded|rate.?limit|too many requests|server had an error/i.test(msg)) return true; + // Network-level failures (fetch throws before any HTTP status exists). + return /fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|socket|network|terminated/i.test( + msg, + ); +} + +export function maxRetries(): number { + const n = Number(process.env.MIST_RETRIES ?? 5); + return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 5; +} + +/** attempt is 1-based; exponential with cap + ±25% jitter. */ +export function retryDelayMs(attempt: number): number { + const base = Number(process.env.MIST_RETRY_BASE_MS ?? 1000) || 1000; + const exp = Math.min(base * 2 ** (attempt - 1), 30_000); + const jitter = 0.75 + Math.random() * 0.5; + return Math.round(exp * jitter); +} + +const shortReason = (err: unknown): string => + String((err as Error | undefined)?.message ?? err ?? "unknown error") + .replace(/\s+/g, " ") + .slice(0, 120); + +export class RetryingClient implements ModelClient { + constructor(private inner: ModelClient) {} + + async stream( + system: string, + messages: ChatMessage[], + tools: ToolSpec[], + cb: StreamCallbacks, + maxTokens?: number, + ): Promise { + const retries = maxRetries(); + let emitted = false; + // Track visible output only where a listener exists — headless calls + // (title gen, summarizer) have no UI to duplicate, so they may retry + // even after a mid-stream failure. + const guarded: StreamCallbacks = { ...cb }; + if (cb.onTextDelta) { + const orig = cb.onTextDelta; + guarded.onTextDelta = (t) => { + emitted = true; + orig(t); + }; + } + if (cb.onToolUse) { + const orig = cb.onToolUse; + guarded.onToolUse = (n) => { + emitted = true; + orig(n); + }; + } + + for (let attempt = 0; ; attempt++) { + try { + return await this.inner.stream(system, messages, tools, guarded, maxTokens); + } catch (err) { + if (emitted || attempt >= retries || !isRetryableError(err)) throw err; + const delayMs = retryDelayMs(attempt + 1); + cb.onRetry?.(attempt + 1, retries, delayMs, shortReason(err)); + await Bun.sleep(delayMs); + } + } + } +} diff --git a/ts/packages/core/src/session.ts b/ts/packages/core/src/session.ts index 6a657ac85..550caff75 100644 --- a/ts/packages/core/src/session.ts +++ b/ts/packages/core/src/session.ts @@ -204,6 +204,11 @@ export class EngineSession { return this.engine.estimateContextTokens(); } + /** Lens explainability ledger (the /lens command). */ + lens() { + return this.engine.getLens(); + } + historyLength(): number { return this.engine.exportHistory().length; } @@ -268,6 +273,13 @@ export class EngineSession { onToolDone: (label, preview, hidden_lines) => this.emit("step.done", { label, preview, hidden_lines }), onThought: (ms) => this.emit("thought", { ms }), + onRetry: (attempt, maxAttempts, delayMs, reason) => + this.emit("model.retry", { + attempt, + max_attempts: maxAttempts, + delay_ms: delayMs, + reason, + }), onSubagent: (ev) => this.emit(`subagent.${ev.phase}`, { ...ev }), onCompacted: (r) => this.emit("context.compacted", { diff --git a/ts/packages/core/src/testing/mock_gemini.ts b/ts/packages/core/src/testing/mock_gemini.ts index fa41352a5..552668128 100644 --- a/ts/packages/core/src/testing/mock_gemini.ts +++ b/ts/packages/core/src/testing/mock_gemini.ts @@ -61,7 +61,14 @@ export function startMockGemini(port = 9894) { ], }, { - usageMetadata: { promptTokenCount: 50, candidatesTokenCount: 60, totalTokenCount: 110 }, + // cachedContentTokenCount is a SUBSET of promptTokenCount + // (implicit caching) — the client must split it out. + usageMetadata: { + promptTokenCount: 50, + candidatesTokenCount: 60, + totalTokenCount: 110, + cachedContentTokenCount: 35, + }, }, ]); } diff --git a/ts/packages/core/src/testing/mock_model.ts b/ts/packages/core/src/testing/mock_model.ts index e850056d9..d8dbc526e 100644 --- a/ts/packages/core/src/testing/mock_model.ts +++ b/ts/packages/core/src/testing/mock_model.ts @@ -47,9 +47,15 @@ export function startMockModel(port = 9876) { port, async fetch(req) { if (!req.url.endsWith("/v1/messages")) return new Response("nf", { status: 404 }); - const payload = (await req.json()) as { system?: string; messages: { role: string; content: unknown }[]; tools?: unknown[] }; + const payload = (await req.json()) as { system?: string | { text?: string }[]; messages: { role: string; content: unknown }[]; tools?: unknown[] }; + // With prompt caching the client sends system as an array of text + // blocks — normalize to a string for branch sniffing. + const systemText = + typeof payload.system === "string" + ? payload.system + : (payload.system ?? []).map((b) => b.text ?? "").join("\n"); // Title-generation calls (no tools, title system prompt) — canned name. - if (typeof payload.system === "string" && payload.system.includes("session title")) { + if (systemText.includes("session title")) { return sse([ { type: "message_start", message: { usage: { input_tokens: 8 } } }, { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, @@ -119,7 +125,7 @@ export function startMockModel(port = 9876) { } // Subagent child conversations carry the subagent marker in the system // prompt — answer them with a canned one-shot report. - if (process.env.MOCK_SUB === "1" && typeof payload.system === "string" && payload.system.includes("SUBAGENT")) { + if (process.env.MOCK_SUB === "1" && systemText.includes("SUBAGENT")) { return sse([ { type: "message_start", message: { usage: { input_tokens: 15 } } }, { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, @@ -256,7 +262,8 @@ export function startMockModel(port = 9876) { ]); } return sse([ - { type: "message_start", message: { usage: { input_tokens: 99 } } }, + // Cache fields present: input_tokens is the UNCACHED remainder only. + { type: "message_start", message: { usage: { input_tokens: 99, cache_read_input_tokens: 40, cache_creation_input_tokens: 10 } } }, { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, ...ANSWER.map((chunk) => ({ type: "content_block_delta", diff --git a/ts/packages/core/src/testing/mock_openai.ts b/ts/packages/core/src/testing/mock_openai.ts index ac7080ce4..673086fa9 100644 --- a/ts/packages/core/src/testing/mock_openai.ts +++ b/ts/packages/core/src/testing/mock_openai.ts @@ -59,7 +59,17 @@ export function startMockOpenAI(port = 9877) { return sse([ ...ANSWER.map((t) => chunk({ role: "assistant", content: t })), chunk({}, "stop"), - { choices: [], usage: { prompt_tokens: 42, completion_tokens: 55, total_tokens: 97 } }, + { + choices: [], + usage: { + prompt_tokens: 42, + completion_tokens: 55, + total_tokens: 97, + // OpenAI automatic caching: cached_tokens is a SUBSET of + // prompt_tokens — the client must split it out. + prompt_tokens_details: { cached_tokens: 30 }, + }, + }, ]); } diff --git a/ts/packages/core/src/training_export.test.ts b/ts/packages/core/src/training_export.test.ts new file mode 100644 index 000000000..5115f16e9 --- /dev/null +++ b/ts/packages/core/src/training_export.test.ts @@ -0,0 +1,105 @@ +/** + * Training exporter tests: JSONL shape (anthropic + openai), redaction, + * min-turns filtering. + */ + +import { describe, expect, test } from "bun:test"; +import { SessionStore } from "./store"; +import { exportTraining, redactSecrets } from "./training_export"; +import type { ChatMessage } from "./models"; + +async function seedSessions(cwd: string): Promise { + const store = new SessionStore(cwd); + // A real agentic session with tool traffic and a planted secret. + await store.create("sess1", "fix the auth bug"); + await store.appendMessages("sess1", [ + { role: "user", content: "fix the auth bug" }, + { + role: "assistant", + content: [ + { type: "text", text: "Reading the config first." }, + { type: "tool_use", id: "t1", name: "read_file", input: { path: ".env" } }, + ], + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "t1", content: "API_KEY=sk-ant-abc123def456ghi789\ndone" }, + ], + }, + { role: "assistant", content: [{ type: "text", text: "Fixed it." }] }, + ] as ChatMessage[]); + // An empty throwaway session (should be filtered by min-turns). + await store.create("sess2", "(tool session)"); +} + +describe("training export", () => { + test("anthropic format: one JSONL line per qualifying session, secrets redacted", async () => { + const cwd = `/tmp/mist-train-${Date.now()}`; + process.env.MIST_SESSIONS_DIR = `${cwd}/.sessions`; + try { + await seedSessions(cwd); + const out = `${cwd}/out.jsonl`; + const res = await exportTraining(cwd, out, { format: "anthropic" }); + expect(res.written).toBe(1); + expect(res.skipped).toBe(1); // the empty session + + const lines = (await Bun.file(out).text()).trim().split("\n"); + expect(lines).toHaveLength(1); + const traj = JSON.parse(lines[0]!) as { + system: string; + tools: { name: string }[]; + messages: ChatMessage[]; + }; + expect(traj.system).toContain("You are Mist"); + expect(traj.tools.map((t) => t.name)).toContain("shell"); + expect(traj.tools.map((t) => t.name)).toContain("invoke_subagent"); + expect(traj.messages).toHaveLength(4); + // The planted key must be gone. + expect(lines[0]).not.toContain("sk-ant-abc123"); + expect(lines[0]).toContain("[REDACTED_ANTHROPIC_KEY]"); + } finally { + delete process.env.MIST_SESSIONS_DIR; + } + }); + + test("openai format: tool_calls + role:tool shape", async () => { + const cwd = `/tmp/mist-train-oai-${Date.now()}`; + process.env.MIST_SESSIONS_DIR = `${cwd}/.sessions`; + try { + await seedSessions(cwd); + const out = `${cwd}/out.jsonl`; + await exportTraining(cwd, out, { format: "openai" }); + const traj = JSON.parse((await Bun.file(out).text()).trim()) as { + tools: { type: string; function: { name: string } }[]; + messages: { role: string; tool_calls?: unknown[]; tool_call_id?: string }[]; + }; + expect(traj.tools[0]!.type).toBe("function"); + expect(traj.messages[0]!.role).toBe("system"); + const assistant = traj.messages.find((m) => m.tool_calls); + expect(assistant).toBeTruthy(); + const toolMsg = traj.messages.find((m) => m.role === "tool"); + expect(toolMsg?.tool_call_id).toBe("t1"); + } finally { + delete process.env.MIST_SESSIONS_DIR; + } + }); + + test("redactSecrets scrubs common credential shapes", () => { + const dirty = [ + "key sk-ant-api03-XXXXXXXXXXXX here", + "openai sk-abcdefghijklmnopqrstuvwx", + "github ghp_ABCDEFGHIJKLMNOPQRSTUVWX", + "curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6'", + '{"api_key": "super-secret-value"}', + "aws AKIAIOSFODNN7EXAMPLE", + ].join("\n"); + const clean = redactSecrets(dirty); + expect(clean).not.toContain("sk-ant-api03"); + expect(clean).not.toContain("ghp_ABCDEFGH"); + expect(clean).not.toContain("super-secret-value"); + expect(clean).not.toContain("AKIAIOSFODNN7EXAMPLE"); + expect(clean).not.toContain("eyJhbGciOiJIUzI1NiIsInR5cCI6"); + expect(clean).toContain("[REDACTED_GITHUB_TOKEN]"); + }); +}); diff --git a/ts/packages/core/src/training_export.ts b/ts/packages/core/src/training_export.ts new file mode 100644 index 000000000..52f3d7c1b --- /dev/null +++ b/ts/packages/core/src/training_export.ts @@ -0,0 +1,138 @@ +/** + * Training-data exporter — turn stored Mist sessions into SFT-ready JSONL + * trajectories for training open models. + * + * Each session becomes one JSONL line carrying the system prompt, the tool + * specs the model saw, and the full multi-turn message history (tool calls + + * results included). Two shapes: + * + * - `anthropic` — the native content-block shape (tool_use / tool_result) + * - `openai` — the function-calling shape (assistant.tool_calls + + * role:"tool" messages) that most open-model SFT stacks + * (LLaMA-Factory, Axolotl, torchtune) consume directly + * + * Hygiene: a redaction pass scrubs common secret shapes (API keys, bearer + * tokens, GitHub PATs, AWS keys) — NEVER ship trajectories with live + * credentials into a training corpus. The pass is pattern-based; review a + * sample before large-scale use. + */ + +import { SessionStore } from "./store"; +import { SYSTEM_PROMPT, ENGINE_TOOLS } from "./agent"; +import { toolSpecs } from "./tools"; +import { toOpenAIMessages } from "./openai"; +import type { ChatMessage } from "./models"; + +export interface TrainingExportOptions { + format: "anthropic" | "openai"; + /** Skip sessions with fewer genuine user turns than this (default 1). */ + minTurns: number; + redact: boolean; +} + +const SECRET_PATTERNS: [RegExp, string][] = [ + [/sk-ant-[A-Za-z0-9_-]{8,}/g, "[REDACTED_ANTHROPIC_KEY]"], + [/sk-[A-Za-z0-9]{20,}/g, "[REDACTED_API_KEY]"], + [/gh[pousr]_[A-Za-z0-9]{20,}/g, "[REDACTED_GITHUB_TOKEN]"], + [/xox[baprs]-[A-Za-z0-9-]{10,}/g, "[REDACTED_SLACK_TOKEN]"], + [/AKIA[A-Z0-9]{16}/g, "[REDACTED_AWS_KEY]"], + [/Bearer\s+[A-Za-z0-9._~+/-]{16,}=*/g, "Bearer [REDACTED]"], + [/("(?:api_key|apiKey|token|secret|password|authorization)"\s*:\s*")[^"]{6,}(")/gi, "$1[REDACTED]$2"], +]; + +export function redactSecrets(text: string): string { + let out = text; + for (const [re, sub] of SECRET_PATTERNS) out = out.replace(re, sub); + return out; +} + +function redactMessages(messages: ChatMessage[]): ChatMessage[] { + return messages.map((m) => { + if (typeof m.content === "string") return { ...m, content: redactSecrets(m.content) }; + return { + ...m, + content: m.content.map((b) => { + if (b.type === "text") return { ...b, text: redactSecrets(b.text) }; + if (b.type === "tool_result") return { ...b, content: redactSecrets(b.content) }; + if (b.type === "tool_use") { + return { ...b, input: JSON.parse(redactSecrets(JSON.stringify(b.input ?? {}))) }; + } + return b; + }), + }; + }); +} + +/** Count genuine user prompts (string content, not engine plumbing). */ +function realTurns(messages: ChatMessage[]): number { + return messages.filter( + (m) => + m.role === "user" && + typeof m.content === "string" && + !m.content.startsWith("[auto-continue]") && + !m.content.startsWith("[conversation summary"), + ).length; +} + +export interface TrainingExportResult { + written: number; + skipped: number; + outPath: string; +} + +/** + * Export every stored session for `cwd` as JSONL trajectories. One line per + * session; newest sessions last. + */ +export async function exportTraining( + cwd: string, + outPath: string, + opts: Partial = {}, +): Promise { + const format = opts.format ?? "anthropic"; + const minTurns = opts.minTurns ?? 1; + const redact = opts.redact ?? true; + + const store = new SessionStore(cwd); + const metas = (await store.list()).reverse(); // oldest first + const lines: string[] = []; + let skipped = 0; + + const tools = [...ENGINE_TOOLS, ...toolSpecs].map((t) => ({ + name: t.name, + description: t.description, + input_schema: t.input_schema, + })); + + for (const meta of metas) { + const stored = await store.load(meta.id); + if (!stored || realTurns(stored.messages) < minTurns) { + skipped += 1; + continue; + } + const messages = redact ? redactMessages(stored.messages) : stored.messages; + const base = { + session_id: meta.id, + title: redact ? redactSecrets(meta.title) : meta.title, + created_at: meta.created_at, + source: "mist", + format, + }; + if (format === "openai") { + lines.push( + JSON.stringify({ + ...base, + // OpenAI function-calling shape: system message + tool_calls/tool + // roles — consumable by most open-model SFT frameworks as-is. + tools: tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.input_schema } })), + messages: toOpenAIMessages(SYSTEM_PROMPT, messages), + }), + ); + } else { + lines.push(JSON.stringify({ ...base, system: SYSTEM_PROMPT, tools, messages })); + } + } + + await Bun.write(outPath, lines.join("\n") + (lines.length ? "\n" : "")); + return { written: lines.length, skipped, outPath }; +} diff --git a/ts/packages/protocol/src/events.ts b/ts/packages/protocol/src/events.ts index 0ea414de3..5285d628c 100644 --- a/ts/packages/protocol/src/events.ts +++ b/ts/packages/protocol/src/events.ts @@ -46,6 +46,7 @@ export type MistEvent = | { kind: "mcp_status"; started: string[]; failed: string[] } | { kind: "context_compacted"; beforeTokens: number; afterTokens: number; summarized: number } | { kind: "narration"; text: string } + | { kind: "model_retry"; attempt: number; maxAttempts: number; delayMs: number; reason: string } | { kind: "step_done"; label: string; preview: string[]; hiddenLines: number } | { kind: "thought"; ms: number } | { @@ -143,6 +144,14 @@ export function classifyEvent(env: EventEnvelope): MistEvent { }; case "narration": return { kind: "narration", text: str(d["text"]) }; + case "model.retry": + return { + kind: "model_retry", + attempt: typeof d["attempt"] === "number" ? d["attempt"] : 0, + maxAttempts: typeof d["max_attempts"] === "number" ? d["max_attempts"] : 0, + delayMs: typeof d["delay_ms"] === "number" ? d["delay_ms"] : 0, + reason: str(d["reason"]), + }; case "step.done": return { kind: "step_done", diff --git a/ts/packages/tui/src/completions.ts b/ts/packages/tui/src/completions.ts index d53b250f9..a5f583ff1 100644 --- a/ts/packages/tui/src/completions.ts +++ b/ts/packages/tui/src/completions.ts @@ -127,6 +127,7 @@ const DESCRIPTIONS: Record = { prune: "trim history to N turns", truncate: "truncate history to token budget", mcp: "manage MCP servers", + lens: "explainability: tokens, tools, subagents (html/json)", }; function commandDescription(cmd: string): string { diff --git a/ts/packages/tui/src/index.tsx b/ts/packages/tui/src/index.tsx index 2d61961a2..149640bcf 100644 --- a/ts/packages/tui/src/index.tsx +++ b/ts/packages/tui/src/index.tsx @@ -17,7 +17,7 @@ import { Box, Static, Text, render, useApp, useInput } from "ink"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { classifyEvent } from "@mist/protocol"; import type { EventEnvelope } from "@mist/protocol"; -import { EngineSession, SessionStore, getConfiguredModelName, listModelNames, persistModelChoice } from "@mist/core"; +import { EngineSession, SessionStore, getConfiguredModelName, lensTotals, listModelNames, persistModelChoice, renderLensHtml } from "@mist/core"; import { readConfig, getConfig, setConfig, SETTING_DEFS } from "@mist/core"; import type { ChatMessage, SessionMeta, StoredSession } from "@mist/core"; import { MistClient } from "./client"; @@ -37,7 +37,7 @@ const COMMAND_NAMES = [ "help", "theme", "model", "resume", "sessions", "rename", "new", "clear", "compact", "steps", "tools", "status", "record", "export", "dump_context", "quit", "exit", "q", "set", "show", "cd", "reasoning", "verbosity", - "pop", "prune", "truncate", "mcp", + "pop", "prune", "truncate", "mcp", "lens", ]; type Item = @@ -282,8 +282,10 @@ function TranscriptItem({ it }: { it: Item }) { case "user": return ( - - ❯ {it.text} + + {" ❯ "} + {it.text} + {" "} ); @@ -592,6 +594,14 @@ function App({ initialPrompt, resume, banner }: { initialPrompt?: string; resume case "thought": push(item("info", `Thought for ${Math.max(1, Math.round(ev.ms / 1000))}s`)); break; + case "model_retry": + push( + item( + "info", + `⚠ API error (${ev.reason}) — retrying in ${Math.max(1, Math.round(ev.delayMs / 1000))}s (attempt ${ev.attempt}/${ev.maxAttempts})`, + ), + ); + break; case "narration": { flushStepGroup(); if (ev.text.trim()) push(item("narration", ev.text.trim())); @@ -746,6 +756,7 @@ function App({ initialPrompt, resume, banner }: { initialPrompt?: string; resume { label: "/sessions", desc: "list saved sessions for this directory", action: "/sessions" }, { label: "/rename", desc: "name this session (auto-named from your first question otherwise)", action: "/rename" }, { label: "/new", desc: "start a fresh conversation (alias /clear)", action: "/new" }, + { label: "/lens", desc: "explainability: tokens, tools, subagents — /lens html for the diagram", action: "/lens" }, { label: "/compact", desc: "summarize older context to free tokens", action: "/compact" }, { label: "/pop", desc: "remove the last turn from history", action: "/pop" }, { label: "/prune", desc: "keep only the last N turns", action: "/prune" }, @@ -842,6 +853,53 @@ function App({ initialPrompt, resume, banner }: { initialPrompt?: string; resume await sessionRef.current?.rename(arg); break; } + case "lens": { + const turns = sessionRef.current?.lens() ?? []; + if (!turns.length) { + say("(no turns recorded yet — run a prompt first)"); + break; + } + const sub = rest[0]?.toLowerCase(); + if (sub === "html") { + const file = rest[1] || `mist-lens-${sessionId.slice(0, 8)}.html`; + await Bun.write(file, renderLensHtml(turns, sessionId)); + say(`🔍 interactive lens report (${turns.length} turns) → ${file} — open in a browser`); + break; + } + if (sub === "json") { + const file = rest[1] || `mist-lens-${sessionId.slice(0, 8)}.json`; + await Bun.write(file, JSON.stringify(turns, null, 2)); + say(`🔍 full lens ledger → ${file}`); + break; + } + // Default: text summary of the LAST turn. + const turn = turns[turns.length - 1]!; + const t = lensTotals(turn); + say(`🔍 lens — last turn: “${turn.prompt.slice(0, 60)}” (${Math.round(turn.ms / 1000)}s)`); + say(`tokens: ${t.billedInputTokens.toLocaleString()} input (history×${t.requests} requests) · ${t.outputTokens.toLocaleString()} output (≈${t.estThinkingTokens.toLocaleString()} thinking) · context now ${t.finalContextTokens.toLocaleString()}`); + if (t.cacheReadTokens || t.cacheWriteTokens) { + const pct = t.billedInputTokens ? Math.round((t.cacheReadTokens / t.billedInputTokens) * 100) : 0; + say(`cache: ${t.cacheReadTokens.toLocaleString()} read (~0.1x price) · ${t.cacheWriteTokens.toLocaleString()} written · ${pct}% of input served from cache`); + } else { + say(`cache: no activity — endpoint may not support prompt caching (MIST_CACHE=0 disables sending breakpoints)`); + } + say(`work: ${t.requests} model requests (${Math.round(t.modelMs / 1000)}s) · ${t.toolCalls} tool calls (${Math.round(t.toolMs / 1000)}s) · ${t.subagents} subagents${t.toolErrors ? ` · ${t.toolErrors} tool errors` : ""}${t.hookBlocks ? ` · ${t.hookBlocks} hook blocks` : ""}`); + if (turn.autoContinues || turn.capHit || turn.compactions.length) { + say(`events: ${[turn.autoContinues ? `⟳ ${turn.autoContinues} auto-continue` : "", turn.capHit ? "⏸ cap hit" : "", ...turn.compactions.map((c) => `⇣ compacted ${c.beforeTokens.toLocaleString()}→${c.afterTokens.toLocaleString()}`)].filter(Boolean).join(" · ")}`); + } + // Top token-consuming requests + biggest tool outputs. + const topReq = [...turn.requests].sort((a, b) => b.outputTokens - a.outputTokens)[0]; + if (topReq) say(`hottest request: #${topReq.index + 1} — out ${topReq.outputTokens.toLocaleString()} tok, ${topReq.toolCalls.length} tools, ${topReq.stopReason}`); + const allTools = turn.requests.flatMap((r) => r.toolCalls); + for (const c of [...allTools].sort((a, b) => b.outputChars - a.outputChars).slice(0, 3)) { + say(` ${c.isError ? "✗" : "·"} ${c.name}: ${c.label.slice(0, 60)} — ${c.outputChars.toLocaleString()} chars, ${c.ms}ms`); + } + for (const s of turn.subagents) { + say(` ◇ ${s.label}: ${s.steps} steps · in ${s.inputTokens.toLocaleString()} / out ${s.outputTokens.toLocaleString()} tok · ${Math.round(s.ms / 1000)}s${s.error ? ` · FAILED: ${s.error.slice(0, 60)}` : ""}`); + } + say("/lens html → interactive diagram · /lens json → full ledger (all turns, tool outputs)"); + break; + } case "compact": { say("compacting…"); await sessionRef.current?.compact(); @@ -1556,6 +1614,8 @@ Usage: mist -c | --continue resume the latest session for this directory mist -r | --resume resume a specific session (id prefix ok) mist --sessions list saved sessions for this directory + mist --export-training [f] sessions → SFT JSONL (--format=openai, + --min-turns=N, --no-redact) mist --help | --version While the agent works: type + Enter to steer it; Esc interrupts; Ctrl+C quits. @@ -1584,6 +1644,24 @@ async function main(): Promise { return; } + // --export-training: dump stored sessions as SFT-ready JSONL trajectories. + if (args.includes("--export-training")) { + const { exportTraining } = await import("@mist/core"); + const idx = args.indexOf("--export-training"); + const next = args[idx + 1]; + const outPath = next && !next.startsWith("--") ? next : `mist-training-${Date.now()}.jsonl`; + const format = args.includes("--format=openai") ? "openai" as const : "anthropic" as const; + const mt = args.find((a) => a.startsWith("--min-turns=")); + const minTurns = mt ? Math.max(1, Number(mt.split("=")[1])) : 1; + const redact = !args.includes("--no-redact"); + const res = await exportTraining(process.cwd(), outPath, { format, minTurns, redact }); + console.log( + `exported ${res.written} trajectories (${format}${redact ? ", secrets redacted" : ", RAW — review before sharing"}) → ${res.outPath}` + + (res.skipped ? ` · ${res.skipped} skipped (< ${minTurns} turns)` : ""), + ); + return; + } + // --serve: headless HTTP/SSE server (never renders the TUI). if (args.includes("--serve")) { const { startHeadlessServer } = await import("@mist/core"); diff --git a/ts/packages/tui/src/mascot.tsx b/ts/packages/tui/src/mascot.tsx index 972a3cf3c..aa95fd8f9 100644 --- a/ts/packages/tui/src/mascot.tsx +++ b/ts/packages/tui/src/mascot.tsx @@ -23,8 +23,8 @@ const GRID = [ ".MMMMMMMMMM.", ".MPPMMMMPPM.", ".MPPMMMMPPM.", - "MMRMMPPMMRMM", - "MMMMMMMMMMMM", + "MMMMMPPMMMMM", + "MMRMMMMMMRMM", ".MMMMMMMMMM.", ".MMMMMMMMMM.", "..MMMMMMMM..", diff --git a/ts/packages/tui/src/theme.ts b/ts/packages/tui/src/theme.ts index d5fdfc9b8..c9af5bb8b 100644 --- a/ts/packages/tui/src/theme.ts +++ b/ts/packages/tui/src/theme.ts @@ -36,6 +36,9 @@ export interface Theme { path: string; // file/dir names — distinct so they pop border: string; user: string; + /** Background band behind user prompts in the transcript — the "find my + * input while scrolling" cue. Subtle, theme-tinted, darker than text. */ + userBg: string; /** Color of the spinner-verb text in the busy status line. */ verb: string; /** Theme-specific spinner verbs — replaces the standard pools when set. */ @@ -58,7 +61,8 @@ export const THEMES: Record = { code: "#6FAE94", path: "#C9A227", // gold — file names flash like the gold eye border: "#45646E", // calm slate - user: "#E2E9E8", // deeper haze + user: "#9FD4C2", // soft mint — clearly not body text + userBg: "#1F3330", // deep misty teal band verb: "#2E8C7C", // forms announced in sudden-clarity green verbs: [ "Low Clouds, Distant Haze", @@ -83,7 +87,8 @@ export const THEMES: Record = { code: "#f2bd7e", path: "#b794f4", border: "#7d4e00", - user: "#ffecd1", + user: "#f2bd7e", // butterscotch — pops against the milky body text + userBg: "#3A2410", // dark toasted-brown band verb: "#df9241", }, hinokami: { @@ -101,6 +106,7 @@ export const THEMES: Record = { path: "#FFC94A", // bright gold — pops on the green-black border: "#4A5442", // checkered-haori sage user: "#FFC94A", + userBg: "#2E2313", // embered dark-gold band verb: "#FF7A29", // forms called out in Sun Breathing flame orange verbs: [ "Dance", @@ -132,7 +138,8 @@ export const THEMES: Record = { code: "#B5384A", // flame markings (strings) path: "#D4AF5A", // gold irises — six eyes find every file border: "#453A50", - user: "#F2ECF5", + user: "#D4AF5A", // gold irises — unmistakable against lavender text + userBg: "#2A2138", // deep kimono-purple band verb: "#C24B72", // forms called out in living-blade crimson verbs: [ "Dark Moon, Evening Palace",