diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 031127e21..11ed9a61a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,9 +1,9 @@ # Changelog ## [Unreleased] - ### Added +- Added `snapcompact-savings.jsonl` journaling for snapcompact tool-result compaction, recording session, provider, model, tool call, and estimated token savings whenever tool output is rendered as image frames - Added `subagent:` loop-phase breadcrumbs around in-process subagent event dispatch and finalization so the TUI event-loop watchdog can attribute a main-thread stall to subagent execution ([#2485](https://github.com/can1357/oh-my-pi/issues/2485)) - `highlightMagicKeywords(text, resetTo?, phase?)` now accepts an optional `phase` ∈ [0, 1) that rotates the gradient cyclically; sent bubbles omit it (static palette unchanged). `hasMagicKeyword(text)` exported from `modes/magic-keywords` is the cheap shimmer-gate the editor uses on every render. - Added a `fastModeScope` setting (`both` | `openai` | `claude`, default `both`) controlling which providers `/fast on` (and the fast-mode toggle) target. `both` keeps the prior unscoped priority behavior; `openai`/`claude` scope fast mode to one family. `/fast status` now reports the active scope. diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index faf977da2..a30021f20 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -131,6 +131,7 @@ import { import { getRestorableSessionModels } from "./session/session-context"; import { SessionManager } from "./session/session-manager"; import { SnapcompactInlineTransformer } from "./session/snapcompact-inline"; +import { createSnapcompactSavingsRecorder } from "./session/snapcompact-savings-journal"; import { closeAllConnections } from "./ssh/connection-manager"; import { unmountAll } from "./ssh/sshfs-mount"; import { @@ -2340,11 +2341,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const snapcompactSystemPromptMode = settings.get("snapcompact.systemPrompt"); const snapcompactInline = snapcompactSystemPromptMode !== "none" || settings.get("snapcompact.toolResults") - ? new SnapcompactInlineTransformer({ - renderSystemPrompt: snapcompactSystemPromptMode, - renderToolResults: settings.get("snapcompact.toolResults"), - shape: settings.get("snapcompact.shape"), - }) + ? new SnapcompactInlineTransformer( + { + renderSystemPrompt: snapcompactSystemPromptMode, + renderToolResults: settings.get("snapcompact.toolResults"), + shape: settings.get("snapcompact.shape"), + }, + // Journal the tokens each imaged tool result keeps off the wire + // (frames never reach session.jsonl, so this is their only trace). + createSnapcompactSavingsRecorder(() => sessionManager.getSessionFile() ?? null), + ) : undefined; const transformProviderContext = obfuscator || snapcompactInline diff --git a/packages/coding-agent/src/session/snapcompact-inline.ts b/packages/coding-agent/src/session/snapcompact-inline.ts index 06bc63ac4..e764a597e 100644 --- a/packages/coding-agent/src/session/snapcompact-inline.ts +++ b/packages/coding-agent/src/session/snapcompact-inline.ts @@ -32,6 +32,17 @@ export interface SnapcompactInlineOptions { shape?: snapcompact.ShapeVariantName | "auto"; } +/** + * Reports the per-tool-result tokens kept off the wire when a swap is applied. + * `savedTokens` is `textTokens - frames * shape.frameTokenEstimate` for each + * imaged tool result (always > 0; the savings gate guarantees it). Wired to the + * append-only savings journal; never throws into the request path. + */ +export type SnapcompactSavingsSink = ( + savings: ReadonlyArray<{ toolCallId: string; savedTokens: number }>, + model: Model, +) => void; + // Per-provider image-count budgets live in @oh-my-pi/snapcompact // (`providerImageBudget`): snapcompact frames are 1568px (<2000px) so // dimension/size limits never bind; only COUNT does. Once the budget is @@ -398,7 +409,10 @@ export class SnapcompactInlineTransformer { #toolCache = new Map(); #systemCache?: FrameCacheEntry; - constructor(private readonly options: SnapcompactInlineOptions) {} + constructor( + private readonly options: SnapcompactInlineOptions, + private readonly onToolResultSavings?: SnapcompactSavingsSink, + ) {} transform(context: Context, model: Model): Context { // Vision gate: providers silently DROP images on text-only models — @@ -464,13 +478,16 @@ export class SnapcompactInlineTransformer { }); let changed = false; + const savings: Array<{ toolCallId: string; savedTokens: number }> = []; for (const swap of plan.toolResults) { const target = targets.get(swap.id); if (!target) continue; const frames = this.#framesFor(this.#toolCache, swap.id, target.text, shape); messages[target.index] = { ...target.message, content: [{ type: "text", text: toolResultNote }, ...frames] }; changed = true; + savings.push({ toolCallId: swap.id, savedTokens: Math.max(0, swap.textTokens - swap.frames * shape.frameTokenEstimate) }); } + if (savings.length > 0) this.onToolResultSavings?.(savings, model); if (this.options.renderToolResults) { // Drop cache entries for tool calls no longer in the context // (compacted away) so the cache stays bounded by live history. diff --git a/packages/coding-agent/src/session/snapcompact-savings-journal.ts b/packages/coding-agent/src/session/snapcompact-savings-journal.ts new file mode 100644 index 000000000..f96537764 --- /dev/null +++ b/packages/coding-agent/src/session/snapcompact-savings-journal.ts @@ -0,0 +1,113 @@ +/** + * Append-only journal of snapcompact tool-result savings. + * + * Snapcompact frames are transient — built per provider request in + * `transformProviderContext` and never written to session.jsonl — so the tokens + * they keep off the wire would otherwise leave no trace. This records one line + * the FIRST time a tool result is imaged in a session: + * + * {"ts":,"session":,"provider":..,"model":..,"toolCallId":..,"savedTokens":..} + * + * Newline-delimited JSON, opened with O_APPEND so concurrent appenders (parallel + * agents/subagents) never interleave a partial line. Writes are fire-and-forget; + * a failure is logged at debug and never propagates into the request hot path. + * + * Readers MUST dedup by (session, toolCallId): a session resumed in a fresh + * process re-images the same results and may append a second line. The savings + * for a given (session, toolCallId) are stable, so any-per-key is correct. + */ + +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import type { Model } from "@oh-my-pi/pi-ai"; +import { getStatsDbPath, isEnoent, logger } from "@oh-my-pi/pi-utils"; + +export interface SnapcompactSavingsRecord { + /** Epoch milliseconds when the swap was applied. */ + ts: number; + /** Session file path (matches the stats `messages.session_file` key). */ + session: string; + provider: string; + model: string; + toolCallId: string; + savedTokens: number; +} + +/** `~/.omp/.../snapcompact-savings.jsonl`, colocated with stats.db. */ +export function snapcompactSavingsJournalPath(): string { + return path.join(path.dirname(getStatsDbPath()), "snapcompact-savings.jsonl"); +} + +/** + * Appends savings to the journal, deduped by toolCallId for the recorder's + * lifetime (one per session). Returns the in-flight append so callers/tests can + * await durability; the production transform leaves it floating (fire-and-forget, + * and it never rejects — I/O errors are swallowed to debug). `getSession` is read + * at write time so a session file assigned late is still captured; a null session + * (in-memory / SDK embedding) or non-positive savings skip the write. + */ +export type SnapcompactSavingsRecorder = ( + savings: ReadonlyArray<{ toolCallId: string; savedTokens: number }>, + model: Model, +) => Promise; + +export function createSnapcompactSavingsRecorder( + getSession: () => string | null, + journalPath: string = snapcompactSavingsJournalPath(), +): SnapcompactSavingsRecorder { + const seen = new Set(); + let dirEnsured = false; + return async (savings, model) => { + const session = getSession(); + if (!session) return; + const ts = Date.now(); + const lines: string[] = []; + for (const { toolCallId, savedTokens } of savings) { + if (savedTokens <= 0 || seen.has(toolCallId)) continue; + seen.add(toolCallId); + lines.push( + JSON.stringify({ + ts, + session, + provider: model.provider, + model: model.id, + toolCallId, + savedTokens, + } satisfies SnapcompactSavingsRecord), + ); + } + if (lines.length === 0) return; + try { + if (!dirEnsured) { + await fs.mkdir(path.dirname(journalPath), { recursive: true }); + dirEnsured = true; + } + await fs.appendFile(journalPath, `${lines.join("\n")}\n`); + } catch (err) { + logger.debug("snapcompact savings journal append failed", { err: String(err) }); + } + }; +} + +/** Read all journal records. Malformed lines are skipped; a missing file is empty. */ +export async function readSnapcompactSavingsJournal( + journalPath: string = snapcompactSavingsJournalPath(), +): Promise { + let text: string; + try { + text = await Bun.file(journalPath).text(); + } catch (err) { + if (isEnoent(err)) return []; + throw err; + } + const records: SnapcompactSavingsRecord[] = []; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + try { + records.push(JSON.parse(line) as SnapcompactSavingsRecord); + } catch { + /* skip malformed line */ + } + } + return records; +} diff --git a/packages/coding-agent/test/snapcompact-inline.test.ts b/packages/coding-agent/test/snapcompact-inline.test.ts index 97df9f595..64a9905cd 100644 --- a/packages/coding-agent/test/snapcompact-inline.test.ts +++ b/packages/coding-agent/test/snapcompact-inline.test.ts @@ -115,6 +115,40 @@ describe("SnapcompactInlineTransformer", () => { expect(result.systemPrompt).toBe(context.systemPrompt); }); + it("reports per-tool-result savings to the sink for each imaged result only", () => { + const received: Array<{ toolCallId: string; savedTokens: number }>[] = []; + let model = ""; + const transformer = new SnapcompactInlineTransformer( + { renderSystemPrompt: "none", renderToolResults: true }, + (savings, m) => { + received.push(savings.map(s => ({ ...s }))); + model = m.id; + }, + ); + transformer.transform(makeContext(), makeModel()); + + // Only the large historical result (call_1) is imaged; call_2 is small, + // call_3 is the most-recent (kept crisp). + expect(received).toHaveLength(1); + expect(received[0]).toHaveLength(1); + expect(received[0][0].toolCallId).toBe("call_1"); + expect(received[0][0].savedTokens).toBeGreaterThan(0); + expect(model).toBe("test-model"); + }); + + it("never calls the savings sink when nothing is imaged", () => { + let calls = 0; + const transformer = new SnapcompactInlineTransformer( + { renderSystemPrompt: "none", renderToolResults: true }, + () => { + calls++; + }, + ); + // Text-only model → vision gate short-circuits before any swap. + transformer.transform(makeContext(), makeModel({ input: ["text"] })); + expect(calls).toBe(0); + }); + it("never mutates the input context (persisted history shares these references)", () => { const transformer = new SnapcompactInlineTransformer({ renderSystemPrompt: "all", renderToolResults: true }); const context = makeContext(); diff --git a/packages/coding-agent/test/snapcompact-savings-journal.test.ts b/packages/coding-agent/test/snapcompact-savings-journal.test.ts new file mode 100644 index 000000000..61fb32ab6 --- /dev/null +++ b/packages/coding-agent/test/snapcompact-savings-journal.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { Model } from "@oh-my-pi/pi-ai"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { + createSnapcompactSavingsRecorder, + readSnapcompactSavingsJournal, +} from "@oh-my-pi/pi-coding-agent/session/snapcompact-savings-journal"; + +function model(provider = "anthropic", id = "claude-test"): Model { + return buildModel({ + id, + name: id, + api: "anthropic-messages", + provider, + baseUrl: "https://example.invalid", + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + }); +} + +async function tmpJournal(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "snap-savings-journal-")); + return path.join(dir, "snapcompact-savings.jsonl"); +} + +describe("snapcompact savings journal", () => { + it("appends one attributed record per imaged tool result", async () => { + const journal = await tmpJournal(); + const record = createSnapcompactSavingsRecorder(() => "/proj/session.jsonl", journal); + await record( + [ + { toolCallId: "call_1", savedTokens: 5000 }, + { toolCallId: "call_2", savedTokens: 3000 }, + ], + model("google", "gemini-test"), + ); + + const recs = await readSnapcompactSavingsJournal(journal); + expect(recs.map(r => r.toolCallId).sort()).toEqual(["call_1", "call_2"]); + const first = recs.find(r => r.toolCallId === "call_1"); + expect(first).toMatchObject({ + session: "/proj/session.jsonl", + provider: "google", + model: "gemini-test", + savedTokens: 5000, + }); + expect(typeof first?.ts).toBe("number"); + }); + + it("records each tool result once per session, even when re-imaged on later requests", async () => { + const journal = await tmpJournal(); + const record = createSnapcompactSavingsRecorder(() => "/proj/session.jsonl", journal); + // call_1 stays in context and is re-imaged on every request; call_2 appears later. + await record([{ toolCallId: "call_1", savedTokens: 5000 }], model()); + await record( + [ + { toolCallId: "call_1", savedTokens: 5000 }, + { toolCallId: "call_2", savedTokens: 4000 }, + ], + model(), + ); + + const recs = await readSnapcompactSavingsJournal(journal); + expect(recs.map(r => r.toolCallId).sort()).toEqual(["call_1", "call_2"]); + }); + + it("writes nothing without a session or for non-positive savings", async () => { + const journal = await tmpJournal(); + await createSnapcompactSavingsRecorder(() => null, journal)([{ toolCallId: "call_1", savedTokens: 5000 }], model()); + await createSnapcompactSavingsRecorder( + () => "/proj/session.jsonl", + journal, + )( + [ + { toolCallId: "call_zero", savedTokens: 0 }, + { toolCallId: "call_neg", savedTokens: -10 }, + ], + model(), + ); + expect(await readSnapcompactSavingsJournal(journal)).toEqual([]); + }); + + it("returns empty for a missing journal file", async () => { + expect(await readSnapcompactSavingsJournal(await tmpJournal())).toEqual([]); + }); +});