pi-editor-footer 0.12.2 → 0.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -4
- package/README.md +2 -2
- package/dist/ansi-color.js +6 -6
- package/dist/chrome-composition.js +3 -3
- package/dist/chrome-state.js +1 -1
- package/dist/footer.js +4 -2
- package/dist/model-info.js +2 -2
- package/dist/state.js +36 -16
- package/package.json +1 -1
- package/src/footer.ts +7 -39
- package/src/state.ts +67 -54
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.12.3](https://github.com/Rianico/pi-editor-footer/compare/v0.12.2...v0.12.3) (2026-09-17)
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
|
|
7
|
+
* **footer:** count total input tokens in footer statistics ([a2153a1](https://github.com/Rianico/pi-editor-footer/commit/a2153a1f0f4884e3879a610744a9e664a9124053))
|
|
8
|
+
|
|
9
|
+
### Documentation
|
|
10
|
+
|
|
11
|
+
* **agents:** mark agent-run token accounting a scope boundary ([ffbbee2](https://github.com/Rianico/pi-editor-footer/commit/ffbbee29300a40d6963e0916c7526e0a1ce49881))
|
|
12
|
+
* document total input token semantics for the footer ([9480bf9](https://github.com/Rianico/pi-editor-footer/commit/9480bf9f1508930d75a5e53b94d944d00d1f6a2a))
|
|
13
|
+
|
|
14
|
+
<!-- markdownlint-configure-file { "MD004": { "style": "asterisk" } } -->
|
|
15
|
+
<!-- Bullets stay `*`: semantic-release's preset writes `*`, and this file is excluded from the
|
|
16
|
+
formatters so they cannot normalize them to `-`. Keep `# Changelog` as the FIRST line:
|
|
17
|
+
@semantic-release/changelog rewrites the title in place only while the file starts with
|
|
18
|
+
the configured `changelogTitle`, and prepends release notes above it otherwise. Do not
|
|
19
|
+
move this title below the comments, and do not remove the title. -->
|
|
20
|
+
|
|
21
|
+
All notable changes to this project will be documented in this file.
|
|
22
|
+
|
|
1
23
|
## [0.12.2](https://github.com/Rianico/pi-editor-footer/compare/v0.12.1...v0.12.2) (2026-09-13)
|
|
2
24
|
|
|
3
25
|
### Bug Fixes
|
|
@@ -24,10 +46,6 @@
|
|
|
24
46
|
* allow pnpm builds for esbuild and genai ([36e139e](https://github.com/Rianico/pi-editor-footer/commit/36e139e9443ef928a4366c3d7c8afe494b6e0850))
|
|
25
47
|
* make prepare tolerant to missing husky for pi install ([23ebb62](https://github.com/Rianico/pi-editor-footer/commit/23ebb62cd8d732656fd113da27c807ffa34579fc))
|
|
26
48
|
|
|
27
|
-
# Changelog
|
|
28
|
-
|
|
29
|
-
All notable changes to this project will be documented in this file.
|
|
30
|
-
|
|
31
49
|
## [0.11.0] - 2026-08-29
|
|
32
50
|
|
|
33
51
|
### Changed
|
package/README.md
CHANGED
|
@@ -12,8 +12,8 @@ A [pi](https://pi.dev) extension that turns the editor chrome into a project-awa
|
|
|
12
12
|
|
|
13
13
|
**Footer** — single line below the input, responsive via `fitSegmentsByPriority` and `alignRight`:
|
|
14
14
|
- Left: `cwd` (`·` `git branch` + status `[! ? + ↑↓]` + stashed/conflicted) `•` `runtime` (`node`/`python`/`rust`/`go`… + version) `•` `timer` (`working`/`done`)
|
|
15
|
-
- Right: `tokens` (
|
|
16
|
-
- Separators: `·` between `cwd` and `git`, `•` as default between other left components; `
|
|
15
|
+
- Right: `tokens` (`↑ <total input> · ↓ <output> · $<cost>`) — `↑` counts every billed input token (uncached input + cache read + cache write), so it tracks the context bar's token figure instead of `usage.input` alone
|
|
16
|
+
- Separators: `·` between `cwd` and `git`, `•` as default between other left components; the `context` bar (`<pct> · <tokens>/<window> | c <hit%>`) sits on the top border next to the model label, not in the footer
|
|
17
17
|
- `cwd` respects `workspaceDisplay` (`~/development/ai/pi-skill-desc` vs `pi-skill-desc`, switchable in settings)
|
|
18
18
|
- `context` bar uses `stressColor` and `renderBar` (12 cols max) with `•`/`·` handling
|
|
19
19
|
- Extension statuses line (`wrapTextWithAnsi`) when `footerSegments.extensionStatuses`
|
package/dist/ansi-color.js
CHANGED
|
@@ -16,11 +16,11 @@ const BASIC16 = [
|
|
|
16
16
|
[0, 255, 255],
|
|
17
17
|
[255, 255, 255],
|
|
18
18
|
];
|
|
19
|
-
/** 6x6x6
|
|
19
|
+
/** 6x6x6 color-cube channel values (indices 0-5). */
|
|
20
20
|
const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
|
|
21
21
|
/** Grayscale ramp values (indices 232-255: 24 grays from 8 to 238). */
|
|
22
22
|
const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);
|
|
23
|
-
/** Anything but the truecolor marker means the 256-
|
|
23
|
+
/** Anything but the truecolor marker means the 256-color ramp. */
|
|
24
24
|
export function normalizeColorMode(mode) {
|
|
25
25
|
return mode === "truecolor" ? "truecolor" : "256color";
|
|
26
26
|
}
|
|
@@ -74,7 +74,7 @@ function colorDistance(r1, g1, b1, r2, g2, b2) {
|
|
|
74
74
|
// Weighted Euclidean distance (the eye is more sensitive to green).
|
|
75
75
|
return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114;
|
|
76
76
|
}
|
|
77
|
-
/**
|
|
77
|
+
/** Quantize RGB to the closest xterm-256 index (same rule as the theme loader). */
|
|
78
78
|
export function rgbTo256(r, g, b) {
|
|
79
79
|
const rIdx = findClosestCubeIndex(r);
|
|
80
80
|
const gIdx = findClosestCubeIndex(g);
|
|
@@ -90,7 +90,7 @@ export function rgbTo256(r, g, b) {
|
|
|
90
90
|
const grayIndex = 232 + grayIdx;
|
|
91
91
|
const grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue);
|
|
92
92
|
const spread = Math.max(r, g, b) - Math.min(r, g, b);
|
|
93
|
-
// Only consider grayscale when the
|
|
93
|
+
// Only consider grayscale when the color is nearly neutral AND closer.
|
|
94
94
|
if (spread < 10 && grayDist < cubeDist)
|
|
95
95
|
return grayIndex;
|
|
96
96
|
return cubeIndex;
|
|
@@ -126,14 +126,14 @@ export function parseFgAnsiToRgb(theme, color) {
|
|
|
126
126
|
return indexToRgb(Number(palette[1]));
|
|
127
127
|
return null;
|
|
128
128
|
}
|
|
129
|
-
/** SGR foreground sequence for an RGB value at the terminal's
|
|
129
|
+
/** SGR foreground sequence for an RGB value at the terminal's color fidelity. */
|
|
130
130
|
export function rgbToFgAnsi({ r, g, b }, mode) {
|
|
131
131
|
return normalizeColorMode(mode) === "truecolor"
|
|
132
132
|
? `\x1b[38;2;${r};${g};${b}m`
|
|
133
133
|
: `\x1b[38;5;${rgbTo256(r, g, b)}m`;
|
|
134
134
|
}
|
|
135
135
|
/**
|
|
136
|
-
*
|
|
136
|
+
* Color `s` with an exact hex, bypassing the theme's named-token lookup.
|
|
137
137
|
* Returned painter resets only the foreground (`\x1b[39m`), matching `theme.fg`.
|
|
138
138
|
*/
|
|
139
139
|
export function hexFg(hex, mode) {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* thus required touching 3-4 modules with no locality.
|
|
9
9
|
*
|
|
10
10
|
* Depth: one small interface (glyphs + isAscii + fg/dim/glow + format* helpers) hides
|
|
11
|
-
* icon-mode resolution, the SAFETY theme cast,
|
|
11
|
+
* icon-mode resolution, the SAFETY theme cast, color application, and the chrome format
|
|
12
12
|
* entry points (context bar, telemetry, tokens, run activity, stall). Callers learn one
|
|
13
13
|
* shape; LiveBorder's islands become thin lookups.
|
|
14
14
|
*
|
|
@@ -26,7 +26,7 @@ import { hexFg } from "./ansi-color.js";
|
|
|
26
26
|
* Read the live pi theme into a typed { fg, fgHex } surface. The cast is a SAFETY
|
|
27
27
|
* seam — a pi theme missing `fg` degrades to identity rather than throwing, keeping
|
|
28
28
|
* the chrome resilient. `fgHex` needs no theme call at all: the named-token lookup
|
|
29
|
-
* rejects raw hex, so hex is rendered straight to SGR at the theme's
|
|
29
|
+
* rejects raw hex, so hex is rendered straight to SGR at the theme's color mode.
|
|
30
30
|
*/
|
|
31
31
|
export function adaptTheme(rawTheme) {
|
|
32
32
|
const t = rawTheme;
|
|
@@ -62,7 +62,7 @@ export class ChromeComposition {
|
|
|
62
62
|
this.theme = adaptTheme(rawTheme);
|
|
63
63
|
this.glow = opts.glow ?? resolveGlow(rawTheme);
|
|
64
64
|
}
|
|
65
|
-
/**
|
|
65
|
+
/** Color a string with a theme style (derived once, cast cached). */
|
|
66
66
|
fg(style, s) {
|
|
67
67
|
return this.theme.fg(style, s);
|
|
68
68
|
}
|
package/dist/chrome-state.js
CHANGED
|
@@ -23,7 +23,7 @@ import { fmtTokens } from "./format.js";
|
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
24
|
/**
|
|
25
25
|
* Painter for one context tier: exact hex when the live theme can emit raw hex,
|
|
26
|
-
* else the nearest semantic token (tests/mocks, themes without
|
|
26
|
+
* else the nearest semantic token (tests/mocks, themes without color mode).
|
|
27
27
|
*/
|
|
28
28
|
function contextPainter(theme, tier) {
|
|
29
29
|
const hex = CONTEXT_TIER_HEX[tier];
|
package/dist/footer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
-
import { getUsageTotals } from "./state.js";
|
|
2
|
+
import { getUsageTotals, totalInputTokens } from "./state.js";
|
|
3
3
|
import { runtimeSymbol } from "./icons.js";
|
|
4
4
|
import { ChromeComposition } from "./chrome-composition.js";
|
|
5
5
|
import { alignRight, fitSegmentsByPriority, } from "./layout.js";
|
|
@@ -141,7 +141,9 @@ export function renderFooter(width, state, config, theme, ctx) {
|
|
|
141
141
|
}
|
|
142
142
|
const stats = [];
|
|
143
143
|
if (segments.tokens) {
|
|
144
|
-
|
|
144
|
+
// Total input billed for the model — includes the cached prompt prefix, not just
|
|
145
|
+
// `usage.input` (which excludes cacheRead/cacheWrite). See totalInputTokens.
|
|
146
|
+
stats.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(totalInputTokens(totals))}`));
|
|
145
147
|
stats.push(theme.fg("success", `${glyphs.output} ${fmtTokens(totals.output)}`));
|
|
146
148
|
}
|
|
147
149
|
if (segments.cost) {
|
package/dist/model-info.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* TrackingEditor — which owns the editor slot for this extension — can keep
|
|
7
7
|
* rendering the model label and thinking-level border glow that the original
|
|
8
8
|
* widget provided. Self-contained apart from pi-tui width utils, the shared
|
|
9
|
-
* ANSI
|
|
9
|
+
* ANSI quantizer (ansi-color.ts) and stripAnsi (format.ts).
|
|
10
10
|
*
|
|
11
11
|
* The port is intentional: pi allows exactly ONE custom editor (last
|
|
12
12
|
* `setEditorComponent` writer wins). pi-skill-desc must own the slot to track
|
|
@@ -41,7 +41,7 @@ const LABEL_PAD = 1;
|
|
|
41
41
|
// ---------------------------------------------------------------------------
|
|
42
42
|
// ---------------------------------------------------------------------------
|
|
43
43
|
// Color helpers: theme token ANSI → RGB → boosted glow ANSI
|
|
44
|
-
// (RGB/256
|
|
44
|
+
// (RGB/256 quantization lives in ansi-color.ts, shared with the chrome tiers)
|
|
45
45
|
// ---------------------------------------------------------------------------
|
|
46
46
|
/**
|
|
47
47
|
* Build a border color function for a thinking level: takes the theme's
|
package/dist/state.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { emptyGitStatus } from "./git.js";
|
|
2
2
|
import { fmtTokens } from "./format.js";
|
|
3
3
|
import { formatProviderLabel } from "./format.js";
|
|
4
|
+
/**
|
|
5
|
+
* Total input tokens billed to the model: uncached input + cache read + cache write.
|
|
6
|
+
*
|
|
7
|
+
* pi-ai `Usage.input` excludes the cached prompt prefix (it lives in cacheRead/cacheWrite),
|
|
8
|
+
* so `input` alone under-reports the prompt by the whole cached history on every turn.
|
|
9
|
+
*/
|
|
10
|
+
export function totalInputTokens(totals) {
|
|
11
|
+
return totals.input + totals.cacheRead + totals.cacheWrite;
|
|
12
|
+
}
|
|
4
13
|
let usageCache;
|
|
5
14
|
function entriesKey(ctx) {
|
|
6
15
|
const entries = ctx.sessionManager?.getEntries() ?? [];
|
|
@@ -8,9 +17,7 @@ function entriesKey(ctx) {
|
|
|
8
17
|
return `${entries.length}:${String(last?.id ?? "")}:${String(last?.timestamp ?? "")}`;
|
|
9
18
|
}
|
|
10
19
|
export function getUsageTotals(ctx) {
|
|
11
|
-
const key = entriesKey(
|
|
12
|
-
// SAFETY: pi seam — intentional unsafe cast, validated at runtime
|
|
13
|
-
ctx);
|
|
20
|
+
const key = entriesKey(ctx);
|
|
14
21
|
if (usageCache && usageCache.key === key)
|
|
15
22
|
return usageCache.totals;
|
|
16
23
|
const totals = {
|
|
@@ -21,23 +28,36 @@ export function getUsageTotals(ctx) {
|
|
|
21
28
|
cost: 0,
|
|
22
29
|
latestCacheHitRate: undefined,
|
|
23
30
|
};
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
const addUsage = (usage) => {
|
|
32
|
+
totals.input += usage.input ?? 0;
|
|
33
|
+
totals.output += usage.output ?? 0;
|
|
34
|
+
totals.cacheRead += usage.cacheRead ?? 0;
|
|
35
|
+
totals.cacheWrite += usage.cacheWrite ?? 0;
|
|
36
|
+
totals.cost += usage.cost?.total ?? 0;
|
|
37
|
+
};
|
|
38
|
+
for (const entry of ctx.sessionManager?.getEntries() ?? []) {
|
|
39
|
+
// Assistant turns carry the cache split the hit rate is derived from; tool results
|
|
40
|
+
// and summaries are usage from nested model calls — billed, so they count toward the
|
|
41
|
+
// session totals (parity with pi core's addUsageToTotals loop).
|
|
28
42
|
if (entry.type === "message" && entry.message?.role === "assistant") {
|
|
29
|
-
const
|
|
30
|
-
if (!
|
|
43
|
+
const usage = entry.message.usage;
|
|
44
|
+
if (!usage)
|
|
31
45
|
continue;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
totals.cacheRead += u.cacheRead ?? 0;
|
|
35
|
-
totals.cacheWrite += u.cacheWrite ?? 0;
|
|
36
|
-
totals.cost += u.cost?.total ?? 0;
|
|
37
|
-
const promptTokens = (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
|
|
46
|
+
addUsage(usage);
|
|
47
|
+
const promptTokens = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
|
38
48
|
if (promptTokens > 0) {
|
|
39
|
-
totals.latestCacheHitRate = ((
|
|
49
|
+
totals.latestCacheHitRate = ((usage.cacheRead ?? 0) / promptTokens) * 100;
|
|
40
50
|
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (entry.type === "message" && entry.message?.role === "toolResult") {
|
|
54
|
+
if (entry.message.usage)
|
|
55
|
+
addUsage(entry.message.usage);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (entry.type === "branch_summary" || entry.type === "compaction") {
|
|
59
|
+
if (entry.usage)
|
|
60
|
+
addUsage(entry.usage);
|
|
41
61
|
}
|
|
42
62
|
}
|
|
43
63
|
usageCache = { key, totals };
|
package/package.json
CHANGED
package/src/footer.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works
|
|
|
2
2
|
import type { ThemeConfig } from "./config.js";
|
|
3
3
|
import type { GitStatus } from "./git.js";
|
|
4
4
|
import type { RuntimeInfo } from "./runtime.js";
|
|
5
|
-
import type { FooterState, ModelMeta, UsageTotals } from "./state.js";
|
|
6
|
-
import { getUsageTotals } from "./state.js";
|
|
5
|
+
import type { FooterState, ModelMeta, UsageTotals, UsageTotalsSource } from "./state.js";
|
|
6
|
+
import { getUsageTotals, totalInputTokens } from "./state.js";
|
|
7
7
|
import type { IconGlyphs } from "./icons.js";
|
|
8
8
|
import { runtimeSymbol } from "./icons.js";
|
|
9
9
|
import { ChromeComposition } from "./chrome-composition.js";
|
|
@@ -191,7 +191,9 @@ export function renderFooter(
|
|
|
191
191
|
|
|
192
192
|
const stats: string[] = [];
|
|
193
193
|
if (segments.tokens) {
|
|
194
|
-
|
|
194
|
+
// Total input billed for the model — includes the cached prompt prefix, not just
|
|
195
|
+
// `usage.input` (which excludes cacheRead/cacheWrite). See totalInputTokens.
|
|
196
|
+
stats.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(totalInputTokens(totals))}`));
|
|
195
197
|
stats.push(theme.fg("success", `${glyphs.output} ${fmtTokens(totals.output)}`));
|
|
196
198
|
}
|
|
197
199
|
if (segments.cost) {
|
|
@@ -273,24 +275,7 @@ export function installFooter(
|
|
|
273
275
|
const totals = getUsageTotals(
|
|
274
276
|
// ast-grep-ignore: require-safety-comment-for-as-unknown-as
|
|
275
277
|
// SAFETY: pi seam — intentional unsafe cast, validated at runtime
|
|
276
|
-
/* SAFETY: intentional unsafe cast — validated at runtime */ ctx as unknown as
|
|
277
|
-
// SAFETY: intentional unsafe cast — validated at runtime
|
|
278
|
-
sessionManager?: {
|
|
279
|
-
getEntries(): {
|
|
280
|
-
type: string;
|
|
281
|
-
message?: {
|
|
282
|
-
role: string;
|
|
283
|
-
usage?: {
|
|
284
|
-
input?: number;
|
|
285
|
-
output?: number;
|
|
286
|
-
cacheRead?: number;
|
|
287
|
-
cacheWrite?: number;
|
|
288
|
-
cost?: { total?: number };
|
|
289
|
-
};
|
|
290
|
-
};
|
|
291
|
-
}[];
|
|
292
|
-
};
|
|
293
|
-
},
|
|
278
|
+
/* SAFETY: intentional unsafe cast — validated at runtime */ ctx as unknown as UsageTotalsSource,
|
|
294
279
|
);
|
|
295
280
|
return renderFooter(width, state, config, themeStub, {
|
|
296
281
|
cwd,
|
|
@@ -357,24 +342,7 @@ export function installFooter(
|
|
|
357
342
|
const totals = getUsageTotals(
|
|
358
343
|
// ast-grep-ignore: require-safety-comment-for-as-unknown-as
|
|
359
344
|
// SAFETY: pi seam — intentional unsafe cast, validated at runtime
|
|
360
|
-
/* SAFETY: intentional unsafe cast — validated at runtime */ ctx as unknown as
|
|
361
|
-
// SAFETY: intentional unsafe cast — validated at runtime
|
|
362
|
-
sessionManager?: {
|
|
363
|
-
getEntries(): {
|
|
364
|
-
type: string;
|
|
365
|
-
message?: {
|
|
366
|
-
role: string;
|
|
367
|
-
usage?: {
|
|
368
|
-
input?: number;
|
|
369
|
-
output?: number;
|
|
370
|
-
cacheRead?: number;
|
|
371
|
-
cacheWrite?: number;
|
|
372
|
-
cost?: { total?: number };
|
|
373
|
-
};
|
|
374
|
-
};
|
|
375
|
-
}[];
|
|
376
|
-
};
|
|
377
|
-
},
|
|
345
|
+
/* SAFETY: intentional unsafe cast — validated at runtime */ ctx as unknown as UsageTotalsSource,
|
|
378
346
|
);
|
|
379
347
|
return renderFooter(width, state, config, theme, {
|
|
380
348
|
cwd,
|
package/src/state.ts
CHANGED
|
@@ -21,35 +21,55 @@ export interface UsageTotals {
|
|
|
21
21
|
latestCacheHitRate: number | undefined;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Raw provider usage block as persisted on a session entry. */
|
|
25
|
+
export interface EntryUsage {
|
|
26
|
+
input?: number;
|
|
27
|
+
output?: number;
|
|
28
|
+
cacheRead?: number;
|
|
29
|
+
cacheWrite?: number;
|
|
30
|
+
cost?: { total?: number };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Session entry slice read by getUsageTotals — the pi seam, validated at runtime.
|
|
35
|
+
* `usage` sits at the top level on branch_summary / compaction entries and inside
|
|
36
|
+
* `message` on message entries.
|
|
37
|
+
*/
|
|
38
|
+
export interface UsageSessionEntry {
|
|
39
|
+
type: string;
|
|
40
|
+
id?: string;
|
|
41
|
+
timestamp?: string;
|
|
42
|
+
message?: { role: string; usage?: EntryUsage };
|
|
43
|
+
usage?: EntryUsage;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Minimal session-manager seam getUsageTotals reads entries through. */
|
|
47
|
+
export interface UsageTotalsSource {
|
|
48
|
+
sessionManager?: { getEntries(): UsageSessionEntry[] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Total input tokens billed to the model: uncached input + cache read + cache write.
|
|
53
|
+
*
|
|
54
|
+
* pi-ai `Usage.input` excludes the cached prompt prefix (it lives in cacheRead/cacheWrite),
|
|
55
|
+
* so `input` alone under-reports the prompt by the whole cached history on every turn.
|
|
56
|
+
*/
|
|
57
|
+
export function totalInputTokens(
|
|
58
|
+
totals: Pick<UsageTotals, "input" | "cacheRead" | "cacheWrite">,
|
|
59
|
+
): number {
|
|
60
|
+
return totals.input + totals.cacheRead + totals.cacheWrite;
|
|
61
|
+
}
|
|
62
|
+
|
|
24
63
|
let usageCache: { key: string; totals: UsageTotals } | undefined;
|
|
25
64
|
|
|
26
|
-
function entriesKey(ctx:
|
|
65
|
+
function entriesKey(ctx: UsageTotalsSource): string {
|
|
27
66
|
const entries = ctx.sessionManager?.getEntries() ?? [];
|
|
28
|
-
const last = entries.at(-1)
|
|
67
|
+
const last = entries.at(-1);
|
|
29
68
|
return `${entries.length}:${String(last?.id ?? "")}:${String(last?.timestamp ?? "")}`;
|
|
30
69
|
}
|
|
31
70
|
|
|
32
|
-
export function getUsageTotals(ctx: {
|
|
33
|
-
|
|
34
|
-
getEntries(): {
|
|
35
|
-
type: string;
|
|
36
|
-
message?: {
|
|
37
|
-
role: string;
|
|
38
|
-
usage?: {
|
|
39
|
-
input?: number;
|
|
40
|
-
output?: number;
|
|
41
|
-
cacheRead?: number;
|
|
42
|
-
cacheWrite?: number;
|
|
43
|
-
cost?: { total?: number };
|
|
44
|
-
};
|
|
45
|
-
};
|
|
46
|
-
}[];
|
|
47
|
-
};
|
|
48
|
-
}): UsageTotals {
|
|
49
|
-
const key = entriesKey(
|
|
50
|
-
// SAFETY: pi seam — intentional unsafe cast, validated at runtime
|
|
51
|
-
ctx as unknown as { sessionManager?: { getEntries(): unknown[] } },
|
|
52
|
-
);
|
|
71
|
+
export function getUsageTotals(ctx: UsageTotalsSource): UsageTotals {
|
|
72
|
+
const key = entriesKey(ctx);
|
|
53
73
|
if (usageCache && usageCache.key === key) return usageCache.totals;
|
|
54
74
|
|
|
55
75
|
const totals: UsageTotals = {
|
|
@@ -60,40 +80,33 @@ export function getUsageTotals(ctx: {
|
|
|
60
80
|
cost: 0,
|
|
61
81
|
latestCacheHitRate: undefined,
|
|
62
82
|
};
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
output?: number;
|
|
75
|
-
cacheRead?: number;
|
|
76
|
-
cacheWrite?: number;
|
|
77
|
-
cost?: { total?: number };
|
|
78
|
-
};
|
|
79
|
-
};
|
|
80
|
-
}[];
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
).sessionManager?.getEntries() ?? [];
|
|
84
|
-
for (const entry of entries) {
|
|
83
|
+
const addUsage = (usage: EntryUsage): void => {
|
|
84
|
+
totals.input += usage.input ?? 0;
|
|
85
|
+
totals.output += usage.output ?? 0;
|
|
86
|
+
totals.cacheRead += usage.cacheRead ?? 0;
|
|
87
|
+
totals.cacheWrite += usage.cacheWrite ?? 0;
|
|
88
|
+
totals.cost += usage.cost?.total ?? 0;
|
|
89
|
+
};
|
|
90
|
+
for (const entry of ctx.sessionManager?.getEntries() ?? []) {
|
|
91
|
+
// Assistant turns carry the cache split the hit rate is derived from; tool results
|
|
92
|
+
// and summaries are usage from nested model calls — billed, so they count toward the
|
|
93
|
+
// session totals (parity with pi core's addUsageToTotals loop).
|
|
85
94
|
if (entry.type === "message" && entry.message?.role === "assistant") {
|
|
86
|
-
const
|
|
87
|
-
if (!
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
totals.cacheRead += u.cacheRead ?? 0;
|
|
91
|
-
totals.cacheWrite += u.cacheWrite ?? 0;
|
|
92
|
-
totals.cost += u.cost?.total ?? 0;
|
|
93
|
-
const promptTokens = (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
|
|
95
|
+
const usage = entry.message.usage;
|
|
96
|
+
if (!usage) continue;
|
|
97
|
+
addUsage(usage);
|
|
98
|
+
const promptTokens = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
|
94
99
|
if (promptTokens > 0) {
|
|
95
|
-
totals.latestCacheHitRate = ((
|
|
100
|
+
totals.latestCacheHitRate = ((usage.cacheRead ?? 0) / promptTokens) * 100;
|
|
96
101
|
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (entry.type === "message" && entry.message?.role === "toolResult") {
|
|
105
|
+
if (entry.message.usage) addUsage(entry.message.usage);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (entry.type === "branch_summary" || entry.type === "compaction") {
|
|
109
|
+
if (entry.usage) addUsage(entry.usage);
|
|
97
110
|
}
|
|
98
111
|
}
|
|
99
112
|
usageCache = { key, totals };
|