diff --git a/README.md b/README.md index 4e23fbb..50f8fe0 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ The extension is tolerant of both metric sources and never double counts: |---|---|---| | `REASONIX_RESULT_CAP_TOKENS` | `3000` | Token cap per tool result before head+tail compaction | | `REASONIX_SCAVENGE` | `0` | Set to `1` to auto-append tool calls scavenged from ``/reasoning content | +| `REASONIX_FOOTER` | `1` | One-line session cache footer in Pi's status bar; set to `0` to disable | --- @@ -206,6 +207,25 @@ Example output after a few turns with a stable prefix: --- + +## Footer + +Reasonix renders a one-line summary of the current session's cache usage into Pi's status bar (the same surface pi-cache-optimizer uses): + +``` +⚡️ 560/572 · 86.80M/90.01M (96%) · w0.20M · 🔧2 · 🌀1 · ⚙3 +``` + +- `hitRequests/totalRequests` — requests that reported a cache read +- `cached/total input tokens` — tokens served from DeepSeek's disk cache vs. total input, plus a token-level hit percentage +- `w` — tokens written to the cache (shown when non-zero) +- `🔧`/`🌀`/`⚙` — repaired tool calls, suppressed call storms, compacted tool results (each shown when non-zero) + +Counters are session-scoped: they reset on `/reload` or Pi restart, like pi-cache-optimizer's session footer mode. The footer refreshes at `message_end`/`turn_end` and skips redundant renders. Its status key is `!reasonix` so Pi's alphabetical status sort keeps it leftmost in the footer. + +- Default **on** for DeepSeek sessions; set `REASONIX_FOOTER=0` to disable. +- The same numbers appear in `/reasonix-status` under `Footer:`. + ## Verification On load, the extension logs to Pi's output: @@ -231,9 +251,11 @@ pi-reasonix/ │ ├── repair.ts # 4-pass tool-call repair pipeline │ │ # (scavenge, truncation repair, flatten, storm detection) │ ├── cost-control.ts # Tool-result compaction, context estimation +│ ├── footer.ts # Session cache stats + one-line footer rendering │ └── types.ts # Shared interfaces and type definitions ├── test/ │ ├── core.test.mjs # Unit tests for PrefixGuard, AppendOnlyLog, repair, cost control +│ ├── footer.test.mjs # Unit tests for session footer rendering │ └── core.integration.test.mjs # Integration tests for extension wiring ├── package.json ├── tsconfig.json diff --git a/extensions/index.ts b/extensions/index.ts index 73377b8..0733d20 100644 --- a/extensions/index.ts +++ b/extensions/index.ts @@ -33,7 +33,12 @@ import { scavengeToolCalls, detectCallStorm, } from "../src/repair.js"; -import type { ReasonixStats, DeepSeekChatMessage } from "../src/types.js"; +import { formatCacheFooter } from "../src/footer.js"; +import type { + ReasonixStats, + DeepSeekChatMessage, +} from "../src/types.js"; + /* ------------------------------------------------------------------ */ /* Config */ @@ -47,10 +52,31 @@ import type { ReasonixStats, DeepSeekChatMessage } from "../src/types.js"; const SCAVENGE_ENABLED = (process.env.REASONIX_SCAVENGE ?? "0") === "1"; +/** + * Persistent one-line cache footer in Pi's status bar (like the footer of + * pi-cache-optimizer). Backed by a small JSON file so the counters survive + * Pi restarts. Default on; set REASONIX_FOOTER=0 to disable. + * + * The footer only renders read-mostly stats — it never mutates requests, + * so unlike SCAVENGE it is safe to enable by default. + */ +const FOOTER_ENABLED = + (process.env.REASONIX_FOOTER ?? "1") !== "0"; + /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ +function footerUi(ctx: unknown): + | { setStatus?: (key: string, text: string | undefined) => void } + | undefined { + return ( + ctx as + | { ui?: { setStatus?: (key: string, text: string | undefined) => void } } + | undefined + )?.ui; +} + const DEEPSEEK_MODEL_PATTERNS = [ "deepseek-chat", "deepseek-reasoner", @@ -107,12 +133,16 @@ export default async function (pi: ExtensionAPI) { conversationTruncations: 0, totalTurns: 0, totalTokens: 0, + totalRequests: 0, + hitRequests: 0, }; let isDeepSeekSession = false; let prefixHash = ""; let currentModel = ""; + let lastFooterText = ""; + /** * Header-derived cache tokens for the current provider response. * Held back until message_end: usage fields (when present) are preferred, @@ -271,13 +301,40 @@ export default async function (pi: ExtensionAPI) { }, ); + /** + * Push the current session's cache usage into Pi's status bar. + * Idempotent: no-op while the text is unchanged, off when REASONIX_FOOTER=0. + * setStatus lives on the handler context (ExtensionContext.ui), not on the + * top-level ExtensionAPI, so renderFooter receives it from the caller. + */ + function renderFooter(ui?: { + setStatus?: (key: string, text: string | undefined) => void; + }): void { + if (!FOOTER_ENABLED || !isDeepSeekSession || !currentModel) return; + const text = formatCacheFooter({ + totalRequests: stats.totalRequests, + hitRequests: stats.hitRequests, + cachedInputTokens: stats.cacheHitTokens, + totalInputTokens: stats.cacheHitTokens + stats.cacheMissTokens, + cacheWriteInputTokens: stats.cacheWriteTokens, + callsRepaired: stats.callsRepaired, + stormsSuppressed: stats.stormsSuppressed, + resultsCompacted: stats.resultsCompacted, + }); + if (text === lastFooterText) return; + lastFooterText = text; + // Key starts with "!" so Pi's alphabetical footer sort puts this first, + // left of pi-lens and any other extension statuses. + ui?.setStatus?.("!reasonix", text); + } + /* ------------------------------------------------------------------ */ /* message_end — extract cache metrics + repair model tool calls */ /* ------------------------------------------------------------------ */ (pi.on as (...args: unknown[]) => void)( "message_end", - (event: Record) => { + (event: Record, ctx?: unknown) => { const msg = event?.message as Record | undefined; if (!msg) return; @@ -304,12 +361,18 @@ export default async function (pi: ExtensionAPI) { const missTokens = Math.max(0, totalInput - cacheRead); stats.cacheMissTokens += missTokens; } + stats.totalRequests++; + if (cacheRead > 0) stats.hitRequests++; + renderFooter(footerUi(ctx)); // Usage is authoritative for this response; discard header stash. pendingHeaderTokens = null; } else if (pendingHeaderTokens) { // No usage fields — fall back to the response headers. stats.cacheHitTokens += pendingHeaderTokens.hit; stats.cacheMissTokens += pendingHeaderTokens.miss; + stats.totalRequests++; + if (pendingHeaderTokens.hit > 0) stats.hitRequests++; + renderFooter(footerUi(ctx)); pendingHeaderTokens = null; } } @@ -397,12 +460,17 @@ export default async function (pi: ExtensionAPI) { /* turn_end — apply stashed header tokens if no usage arrived */ /* ------------------------------------------------------------------ */ - pi.on("turn_end", (_event: TurnEndEvent) => { + pi.on("turn_end", (_event: TurnEndEvent, ctx?: unknown) => { if (pendingHeaderTokens) { stats.cacheHitTokens += pendingHeaderTokens.hit; stats.cacheMissTokens += pendingHeaderTokens.miss; + stats.totalRequests++; + if (pendingHeaderTokens.hit > 0) stats.hitRequests++; pendingHeaderTokens = null; } + // Refresh the footer at every turn boundary (also covers header-stash-only + // responses and model switches that changed the displayed bucket). + renderFooter(footerUi(ctx)); }); /* ------------------------------------------------------------------ */ @@ -447,6 +515,20 @@ export default async function (pi: ExtensionAPI) { ` Miss tokens: ${stats.cacheMissTokens.toLocaleString()}`, ` Write tokens: ${stats.cacheWriteTokens.toLocaleString()}`, ` Hit ratio: ${getHitRatio(stats)}`, + ` Footer: ${ + FOOTER_ENABLED + ? formatCacheFooter({ + totalRequests: stats.totalRequests, + hitRequests: stats.hitRequests, + cachedInputTokens: stats.cacheHitTokens, + totalInputTokens: stats.cacheHitTokens + stats.cacheMissTokens, + cacheWriteInputTokens: stats.cacheWriteTokens, + callsRepaired: stats.callsRepaired, + stormsSuppressed: stats.stormsSuppressed, + resultsCompacted: stats.resultsCompacted, + }) + : "off (REASONIX_FOOTER=1 to enable)" + }`, "", " 🔧 Repairs", ` Args repaired: ${stats.callsRepaired}`, diff --git a/package.json b/package.json index e37c8da..5c4ed2c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "scripts": { "build": "tsc", "prepublishOnly": "npm run build && npm run test", - "test": "npm run build && node --experimental-vm-modules test/*.test.mjs", + "test": "npm run build && node --experimental-vm-modules --test test/*.test.mjs", "check": "npm run build && npm run test" }, "publishConfig": { diff --git a/src/footer.ts b/src/footer.ts new file mode 100644 index 0000000..9b2a3cc --- /dev/null +++ b/src/footer.ts @@ -0,0 +1,72 @@ +/** + * footer — session-scoped cache stats + one-line TUI footer rendering. + * + * The footer renders the CURRENT session's cache usage (hit/total requests + * and input tokens) plus repair/compaction counters, in the spirit of + * pi-cache-optimizer's session mode. Counters reset on /reload or Pi restart + * — no on-disk persistence. + */ + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +/** Session-scoped counters backing the one-line footer. */ +export interface CacheFooterStats { + totalRequests: number; + hitRequests: number; + /** Tokens served from the provider's disk cache (cache reads). */ + cachedInputTokens: number; + totalInputTokens: number; + /** Tokens written to the provider's disk cache for future requests. */ + cacheWriteInputTokens?: number; + /** Repair-pipeline counters (only shown when non-zero). */ + callsRepaired?: number; + stormsSuppressed?: number; + resultsCompacted?: number; +} + +/* ------------------------------------------------------------------ */ +/* Footer rendering */ +/* ------------------------------------------------------------------ */ + +/** + * Abbreviate a token count to a compact "M" string, matching the style of + * pi-cache-optimizer's footer (0.002M / 1.23M / 12.3M). + */ +export function formatTokenCount(value: number): string { + const millions = Math.max(0, Math.round(value)) / 1_000_000; + if (millions === 0) return "0M"; + if (millions < 0.001) return `${millions.toFixed(4)}M`; + if (millions < 0.01) return `${millions.toFixed(3)}M`; + if (millions >= 10) return `${millions.toFixed(1)}M`; + return `${millions.toFixed(2)}M`; +} + +/** + * One-line footer text, e.g. + * `⚡️ 560/572 · 86.80M/90.01M (96%) · w0.20M · 🔧2 🌀1 ⚙3`. + * + * Optional counters are appended only when non-zero so a quiet session keeps + * the line short. Returns `⚡️ --` before the first recorded request so the + * status bar shows the banner is live (and reasonix is active) immediately. + */ +export function formatCacheFooter(stats: CacheFooterStats | undefined): string { + if (!stats || stats.totalRequests === 0) return "⚡️ --"; + const percent = + stats.totalInputTokens > 0 + ? ` (${Math.round((stats.cachedInputTokens / stats.totalInputTokens) * 100)}%)` + : ""; + const extras: string[] = []; + if ((stats.cacheWriteInputTokens ?? 0) > 0) { + extras.push(`w${formatTokenCount(stats.cacheWriteInputTokens!)}`); + } + if ((stats.callsRepaired ?? 0) > 0) extras.push(`🔧${stats.callsRepaired}`); + if ((stats.stormsSuppressed ?? 0) > 0) extras.push(`🌀${stats.stormsSuppressed}`); + if ((stats.resultsCompacted ?? 0) > 0) extras.push(`⚙${stats.resultsCompacted}`); + const tail = extras.length > 0 ? ` · ${extras.join(" · ")}` : ""; + return ( + `⚡️ ${stats.hitRequests}/${stats.totalRequests} · ` + + `${formatTokenCount(stats.cachedInputTokens)}/${formatTokenCount(stats.totalInputTokens)}${percent}${tail}` + ); +} diff --git a/src/types.ts b/src/types.ts index e48ea6f..53e232f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,4 +58,7 @@ export interface ReasonixStats { /** Session-level roll-ups. */ totalTurns: number; totalTokens: number; + /** Session-level request counters (footer banner). */ + totalRequests: number; + hitRequests: number; } diff --git a/test/footer.test.mjs b/test/footer.test.mjs new file mode 100644 index 0000000..824bf74 --- /dev/null +++ b/test/footer.test.mjs @@ -0,0 +1,74 @@ +/** + * Tests for the session footer rendering module (src/footer.ts). + * + * Run: npm test (builds dist/ first, then runs node --test) + */ + +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; + +describe("formatTokenCount", () => { + it("abbreviates to M units", async () => { + const { formatTokenCount } = await import("../dist/src/footer.js"); + assert.equal(formatTokenCount(0), "0M"); + assert.equal(formatTokenCount(1234), "0.001M"); + assert.equal(formatTokenCount(12_345), "0.01M"); + assert.equal(formatTokenCount(1_234_567), "1.23M"); + assert.equal(formatTokenCount(86_801_920), "86.8M"); + }); +}); + +describe("formatCacheFooter", () => { + it("shows placeholder before any request", async () => { + const { formatCacheFooter } = await import("../dist/src/footer.js"); + assert.equal(formatCacheFooter(undefined), "⚡️ --"); + }); + + it("renders a compact one-line summary without a tok suffix", async () => { + const { formatCacheFooter } = await import("../dist/src/footer.js"); + const out = formatCacheFooter({ + totalRequests: 572, + hitRequests: 560, + cachedInputTokens: 86_801_920, + totalInputTokens: 90_008_079, + }); + assert.equal(out, "⚡️ 560/572 · 86.8M/90.0M (96%)"); + }); + + it("appends non-zero write/repair counters", async () => { + const { formatCacheFooter } = await import("../dist/src/footer.js"); + const out = formatCacheFooter({ + totalRequests: 572, + hitRequests: 560, + cachedInputTokens: 86_801_920, + totalInputTokens: 90_008_079, + cacheWriteInputTokens: 200_000, + callsRepaired: 2, + stormsSuppressed: 1, + resultsCompacted: 3, + }); + assert.equal(out, "⚡️ 560/572 · 86.8M/90.0M (96%) · w0.20M · 🔧2 · 🌀1 · ⚙3"); + }); + + it("omits zero counters to keep a quiet session short", async () => { + const { formatCacheFooter } = await import("../dist/src/footer.js"); + const out = formatCacheFooter({ + totalRequests: 572, + hitRequests: 560, + cachedInputTokens: 86_801_920, + totalInputTokens: 90_008_079, + }); + assert.equal(out, "⚡️ 560/572 · 86.8M/90.0M (96%)"); + }); + + it("omits the percentage until input tokens exist", async () => { + const { formatCacheFooter } = await import("../dist/src/footer.js"); + const out = formatCacheFooter({ + totalRequests: 1, + hitRequests: 1, + cachedInputTokens: 0, + totalInputTokens: 0, + }); + assert.equal(out, "⚡️ 1/1 · 0M/0M"); + }); +});