Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<think>`/reasoning content |
| `REASONIX_FOOTER` | `1` | One-line session cache footer in Pi's status bar; set to `0` to disable |

---

Expand Down Expand Up @@ -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<X>` — 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:
Expand All @@ -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
Expand Down
88 changes: 85 additions & 3 deletions extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>) => {
(event: Record<string, unknown>, ctx?: unknown) => {
const msg = event?.message as Record<string, unknown> | undefined;
if (!msg) return;

Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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));
});

/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -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}`,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
72 changes: 72 additions & 0 deletions src/footer.ts
Original file line number Diff line number Diff line change
@@ -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}`
);
}
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
74 changes: 74 additions & 0 deletions test/footer.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
});