@pi-archimedes/footer 2.7.3 → 2.9.0
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/package.json +4 -4
- package/src/index.ts +11 -3
- package/src/utils/format.test.ts +54 -0
- package/src/utils/format.ts +124 -0
- package/src/utils/render-smoke.test.ts +34 -2
- package/src/utils/stats.test.ts +183 -4
- package/src/utils/stats.ts +171 -66
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/footer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/danielcherubini/pi-archimedes.git"
|
|
@@ -19,15 +19,15 @@
|
|
|
19
19
|
"./config": "./src/config.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@pi-archimedes/core": "2.
|
|
22
|
+
"@pi-archimedes/core": "2.9.0"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
|
26
26
|
"@earendil-works/pi-tui": ">=0.1.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
30
|
-
"@earendil-works/pi-tui": "^0.
|
|
29
|
+
"@earendil-works/pi-coding-agent": "^0.87.0",
|
|
30
|
+
"@earendil-works/pi-tui": "^0.87.0",
|
|
31
31
|
"typescript": "^6.0.0"
|
|
32
32
|
},
|
|
33
33
|
"pi": {
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dir | branch [+status] | worktree | model | ◕thinking | ↑↓R W $cost ━ context%
|
|
2
|
+
* dir | branch [+status] | worktree | model | ◕thinking · ⚠ext-status | ↑↓R W $cost ━ context%
|
|
3
3
|
*
|
|
4
4
|
* Adaptive layout — wraps to additional lines instead of truncating:
|
|
5
5
|
* - fits width → single line (system info · stats · context bar)
|
|
6
6
|
* - doesn't fit → two lines (system info above, stats + bar below)
|
|
7
7
|
* - left sections alone overflow → three or more lines
|
|
8
8
|
* - below splitThreshold (default 150) → at least two lines, as configured
|
|
9
|
+
* - oversized extension statuses are word-wrapped into multiple chunks so no
|
|
10
|
+
* content is ever truncated by clampLine
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -15,7 +17,7 @@ import { loadFooterConfig } from "./config.js";
|
|
|
15
17
|
import { CostAccumulator } from "./cost-accumulator.js";
|
|
16
18
|
import { getGitStatus, isInsideLinkedWorktree } from "./utils/git.js";
|
|
17
19
|
import { getContextWindowInfo, getTokenUsageStats, type TokenUsageStats } from "./utils/stats.js";
|
|
18
|
-
import { formatContextBar, formatGitStatusIndicators, formatThinkingIndicator, formatTokenCount } from "./utils/format.js";
|
|
20
|
+
import { formatContextBar, formatGitStatusIndicators, formatThinkingIndicator, formatTokenCount, wrapStatusToChunks } from "./utils/format.js";
|
|
19
21
|
import { footerIcons } from "./utils/icons.js";
|
|
20
22
|
import { packFooterLines, SEP_W, SEPARATOR } from "./utils/layout.js";
|
|
21
23
|
|
|
@@ -76,12 +78,18 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
76
78
|
|
|
77
79
|
const branchIcon = inWorktree ? footerIcons.worktree : footerIcons.branch;
|
|
78
80
|
|
|
79
|
-
//
|
|
81
|
+
// Extension status texts (pi setStatus) — may contain ANSI; oversized statuses wrap
|
|
82
|
+
const extensionStatusChunks = [...footerData.getExtensionStatuses().values()]
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.flatMap((s) => wrapStatusToChunks(s, width));
|
|
85
|
+
|
|
86
|
+
// System info sections: dir | branch [+status] | model | thinking | ext-statuses
|
|
80
87
|
const leftSections = [
|
|
81
88
|
colorize("syntaxFunction", " " + footerIcons.directory + currentDirectory),
|
|
82
89
|
currentBranch ? colorize("success", branchIcon + " " + currentBranch + (gitStatusStr ? " " + gitStatusStr : "")) : "",
|
|
83
90
|
colorize("syntaxType", footerIcons.model + " " + activeModel),
|
|
84
91
|
thinkingIndicatorStr,
|
|
92
|
+
...extensionStatusChunks,
|
|
85
93
|
].filter(Boolean);
|
|
86
94
|
|
|
87
95
|
// Usage stats: ↑in ↓out RcacheRead WcacheWrite $cost contextWindow
|
package/src/utils/format.test.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { stripAnsi } from "@pi-archimedes/core/text";
|
|
3
4
|
import {
|
|
4
5
|
formatTokenCount,
|
|
5
6
|
formatContextBar,
|
|
6
7
|
formatGitStatusIndicators,
|
|
7
8
|
formatThinkingIndicator,
|
|
9
|
+
wrapStatusToChunks,
|
|
8
10
|
} from "./format.js";
|
|
9
11
|
import { thinkingLevelColors, thinkingLevelIcons, type ColorFn } from "./icons.js";
|
|
10
12
|
|
|
@@ -161,3 +163,55 @@ describe("formatThinkingIndicator", () => {
|
|
|
161
163
|
expect(formatThinkingIndicator("unknown", mockColor)).toBe("◑ unknown");
|
|
162
164
|
});
|
|
163
165
|
});
|
|
166
|
+
|
|
167
|
+
// ── wrapStatusToChunks ──────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
describe("wrapStatusToChunks", () => {
|
|
170
|
+
it("short status fits → returned unchanged (including ANSI)", () => {
|
|
171
|
+
const status = "\u001b[33m⚠ token invalid\u001b[0m";
|
|
172
|
+
const chunks = wrapStatusToChunks(status, 80);
|
|
173
|
+
expect(chunks).toEqual([status]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("empty status → [status]", () => {
|
|
177
|
+
expect(wrapStatusToChunks("", 80)).toEqual([""]);
|
|
178
|
+
expect(wrapStatusToChunks(" ", 80)).toEqual([" "]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("oversized status wraps: no content loss, every chunk ≤ width", () => {
|
|
182
|
+
const colored = "\u001b[33m⚠\u001b[0m";
|
|
183
|
+
// Build a ~40-word status with an ANSI-colored prefix
|
|
184
|
+
const words = Array.from({ length: 40 }, (_, i) => `word${i}`);
|
|
185
|
+
const status = colored + " " + words.join(" ");
|
|
186
|
+
const width = 40;
|
|
187
|
+
const chunks = wrapStatusToChunks(status, width);
|
|
188
|
+
// Every chunk must fit in width
|
|
189
|
+
expect(chunks.every((c) => visibleWidth(c) <= width)).toBe(true);
|
|
190
|
+
// No content loss — normalise whitespace in stripped versions
|
|
191
|
+
const joined = chunks.map(stripAnsi).join(" ").replace(/\s+/g, " ").trim();
|
|
192
|
+
const original = stripAnsi(status).replace(/\s+/g, " ").trim();
|
|
193
|
+
expect(joined).toBe(original);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("oversized single word (URL) splits without loss", () => {
|
|
197
|
+
const url = "https://" + "a".repeat(192);
|
|
198
|
+
const width = 30;
|
|
199
|
+
const chunks = wrapStatusToChunks(url, width);
|
|
200
|
+
// Concat of stripped chunks equals stripped original
|
|
201
|
+
expect(chunks.map(stripAnsi).join("")).toBe(stripAnsi(url));
|
|
202
|
+
// Every chunk fits
|
|
203
|
+
expect(chunks.every((c) => visibleWidth(c) <= width)).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("word-wider-than-width preserves ANSI — concatenated raw string equals input", () => {
|
|
207
|
+
const longWord = "\u001b[31m" + "Z".repeat(60) + "\u001b[0m";
|
|
208
|
+
const width = 20;
|
|
209
|
+
const chunks = wrapStatusToChunks(longWord, width);
|
|
210
|
+
// Some chunk must contain the opening escape
|
|
211
|
+
expect(chunks.some((c) => c.includes("\u001b[31m"))).toBe(true);
|
|
212
|
+
// Concatenation of raw chunks (no spaces) equals the input
|
|
213
|
+
expect(chunks.join("")).toBe(longWord);
|
|
214
|
+
// No chunk exceeds width
|
|
215
|
+
expect(chunks.every((c) => visibleWidth(c) <= width)).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
});
|
package/src/utils/format.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
1
2
|
import { footerIcons, gitDisplayIcons, gitStatusColors, thinkingLevelColors, thinkingLevelIcons, type ColorFn } from "./icons.js";
|
|
2
3
|
|
|
3
4
|
// Token counts use binary units (1K = 1024)
|
|
@@ -52,3 +53,126 @@ export function formatGitStatusIndicators(
|
|
|
52
53
|
export function formatThinkingIndicator(thinkingLevel: string, colorize: ColorFn): string {
|
|
53
54
|
return colorize(thinkingLevelColors[thinkingLevel] || "dim", `${thinkingLevelIcons[thinkingLevel] || "◑"} ${thinkingLevel}`);
|
|
54
55
|
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Wrap an extension status into chunks that fit the terminal.
|
|
59
|
+
* - fits (visibleWidth ≤ width) → [status] (unchanged, including ANSI)
|
|
60
|
+
* - oversized → greedy word-wrap: whole words packed per chunk; a single word
|
|
61
|
+
* wider than `width` is split at character level on the raw string (ANSI
|
|
62
|
+
* sequences stay attached to the chars they prefix because we split on plain
|
|
63
|
+
* whitespace only — never mid-escape).
|
|
64
|
+
* No content is ever dropped; every returned chunk has visibleWidth ≤ width.
|
|
65
|
+
*/
|
|
66
|
+
export function wrapStatusToChunks(status: string, width: number): string[] {
|
|
67
|
+
// Edge cases: empty/whitespace or non-positive width → return as-is
|
|
68
|
+
if (width <= 0 || !status.trim()) return [status];
|
|
69
|
+
// Already fits → return unchanged (preserves ANSI)
|
|
70
|
+
if (visibleWidth(status) <= width) return [status];
|
|
71
|
+
|
|
72
|
+
// Split on whitespace boundaries only — never inside ANSI escapes
|
|
73
|
+
// (ANSI escapes contain no plain whitespace, so splitting on /\s+/ is safe)
|
|
74
|
+
const words = status.split(/(\s+)/);
|
|
75
|
+
// words alternates: [word, sep, word, sep, ...]
|
|
76
|
+
// We reassemble non-sep tokens and carry sep tokens to preserve spacing context
|
|
77
|
+
// Simpler: collect [token, isSep] pairs
|
|
78
|
+
const tokens: Array<{ text: string; isSep: boolean }> = [];
|
|
79
|
+
for (const w of words) {
|
|
80
|
+
if (w === "") continue;
|
|
81
|
+
tokens.push({ text: w, isSep: /^\s+$/.test(w) });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const chunks: string[] = [];
|
|
85
|
+
let current = "";
|
|
86
|
+
let currentW = 0;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Split a single word (no whitespace) that is wider than `width` into
|
|
90
|
+
* sub-chunks of at most `width` visible columns, character by character.
|
|
91
|
+
* ANSI escapes are kept intact (we only split printable characters;
|
|
92
|
+
* the regex /\x1b\[[^m]*m/ never contains whitespace, so they were
|
|
93
|
+
* never split by the outer word-split, and here we advance char by char
|
|
94
|
+
* through the raw string, which keeps escape bytes together with their
|
|
95
|
+
* surrounding characters as long as we don't split inside an escape).
|
|
96
|
+
*
|
|
97
|
+
* Implementation: iterate character-by-character through the raw string,
|
|
98
|
+
* accumulating into a sub-chunk until adding the next char would exceed width.
|
|
99
|
+
*/
|
|
100
|
+
function splitWideWord(word: string): string[] {
|
|
101
|
+
const result: string[] = [];
|
|
102
|
+
let sub = "";
|
|
103
|
+
let subW = 0;
|
|
104
|
+
let i = 0;
|
|
105
|
+
while (i < word.length) {
|
|
106
|
+
// Detect ANSI escape sequence starting at i (\x1b[...m)
|
|
107
|
+
// An escape contributes 0 visible width, so we append it whole.
|
|
108
|
+
if (word[i] === "\x1b") {
|
|
109
|
+
const end = word.indexOf("m", i + 1);
|
|
110
|
+
if (end !== -1) {
|
|
111
|
+
sub += word.slice(i, end + 1);
|
|
112
|
+
i = end + 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Regular character (may be multi-byte Unicode)
|
|
117
|
+
const char = word[i]!;
|
|
118
|
+
const charW = visibleWidth(char);
|
|
119
|
+
if (subW + charW > width && sub !== "") {
|
|
120
|
+
result.push(sub);
|
|
121
|
+
sub = char;
|
|
122
|
+
subW = charW;
|
|
123
|
+
} else {
|
|
124
|
+
sub += char;
|
|
125
|
+
subW += charW;
|
|
126
|
+
}
|
|
127
|
+
i++;
|
|
128
|
+
}
|
|
129
|
+
if (sub !== "") result.push(sub);
|
|
130
|
+
return result.length > 0 ? result : [word];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function flushCurrent() {
|
|
134
|
+
if (current !== "") {
|
|
135
|
+
chunks.push(current);
|
|
136
|
+
current = "";
|
|
137
|
+
currentW = 0;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const token of tokens) {
|
|
142
|
+
if (token.isSep) {
|
|
143
|
+
// Separator — skip (we rebuild spacing implicitly via chunks)
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const word = token.text;
|
|
147
|
+
const wordW = visibleWidth(word);
|
|
148
|
+
|
|
149
|
+
if (wordW > width) {
|
|
150
|
+
// Word is wider than the entire terminal — flush then char-split it
|
|
151
|
+
flushCurrent();
|
|
152
|
+
const subChunks = splitWideWord(word);
|
|
153
|
+
for (let j = 0; j < subChunks.length - 1; j++) {
|
|
154
|
+
chunks.push(subChunks[j]!);
|
|
155
|
+
}
|
|
156
|
+
// Last sub-chunk becomes the start of the next current
|
|
157
|
+
const last = subChunks[subChunks.length - 1]!;
|
|
158
|
+
current = last;
|
|
159
|
+
currentW = visibleWidth(last);
|
|
160
|
+
} else if (currentW === 0) {
|
|
161
|
+
// Starting a new chunk
|
|
162
|
+
current = word;
|
|
163
|
+
currentW = wordW;
|
|
164
|
+
} else if (currentW + 1 + wordW <= width) {
|
|
165
|
+
// Append with a single space
|
|
166
|
+
current += " " + word;
|
|
167
|
+
currentW += 1 + wordW;
|
|
168
|
+
} else {
|
|
169
|
+
// Doesn't fit — flush and start new chunk
|
|
170
|
+
flushCurrent();
|
|
171
|
+
current = word;
|
|
172
|
+
currentW = wordW;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
flushCurrent();
|
|
176
|
+
|
|
177
|
+
return chunks.length > 0 ? chunks : [status];
|
|
178
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { stripAnsi } from "@pi-archimedes/core/text";
|
|
3
3
|
import { packFooterLines, SEP_W, SEPARATOR } from "./layout.js";
|
|
4
|
-
import { formatContextBar } from "./format.js";
|
|
4
|
+
import { formatContextBar, wrapStatusToChunks } from "./format.js";
|
|
5
5
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
6
6
|
|
|
7
7
|
// Minimal stand-ins: same visible chars as the real footer, no ANSI
|
|
@@ -14,13 +14,14 @@ const MODEL = "🧠 claude-sonnet-4-6";
|
|
|
14
14
|
const THINKING = "◕ high"; // current format for the 'high' level
|
|
15
15
|
const STATS = "↑141 ↓42k R8.7M W220k $4.25 116k/977k";
|
|
16
16
|
|
|
17
|
-
function buildLines(width: number, pct: number, splitThreshold = 150) {
|
|
17
|
+
function buildLines(width: number, pct: number, splitThreshold = 150, extraSections: string[] = []) {
|
|
18
18
|
const leftSections = [
|
|
19
19
|
DIR,
|
|
20
20
|
BRANCH,
|
|
21
21
|
WORKTREE,
|
|
22
22
|
MODEL,
|
|
23
23
|
THINKING,
|
|
24
|
+
...extraSections,
|
|
24
25
|
].filter(Boolean);
|
|
25
26
|
let groups = packFooterLines([...leftSections, STATS], width, SEP_W);
|
|
26
27
|
if (groups.length < 2 && width < splitThreshold && leftSections.length > 0) {
|
|
@@ -84,4 +85,35 @@ describe("render glue simulation", () => {
|
|
|
84
85
|
// No line exceeds 40 visible columns
|
|
85
86
|
expect(lines.every((l) => visibleWidth(stripAnsi(l)) <= 40)).toBe(true);
|
|
86
87
|
});
|
|
88
|
+
|
|
89
|
+
it("extension status chunk wraps without truncation on the user's terminal", () => {
|
|
90
|
+
const STATUS = "⚠ litellm token invalid — re-auth required";
|
|
91
|
+
const lines = buildLines(158, 12, 150, [STATUS]);
|
|
92
|
+
const stripped = lines.map((l) => stripAnsi(l));
|
|
93
|
+
const all = stripped.join(" ");
|
|
94
|
+
// (a) status text is fully present across joined lines (no clipping)
|
|
95
|
+
expect(all).toContain(STATUS);
|
|
96
|
+
// (b) no line exceeds 158 visible columns
|
|
97
|
+
expect(lines.every((l) => visibleWidth(stripAnsi(l)) <= 158)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("oversized extension status wraps through the production path without truncation", () => {
|
|
101
|
+
const LONG =
|
|
102
|
+
"⚠ litellm token invalid — re-auth required: " +
|
|
103
|
+
"gcloud auth application-default login (see https://support.google.com/a/answer/9368756 for details) — check Settings → Auth for more context " +
|
|
104
|
+
"and verify your credentials are up-to-date before retrying the long operation that failed";
|
|
105
|
+
// Verify it is >158 visible chars
|
|
106
|
+
expect(visibleWidth(LONG)).toBeGreaterThan(158);
|
|
107
|
+
const chunks = wrapStatusToChunks(LONG, 158);
|
|
108
|
+
const lines = buildLines(158, 12, 150, chunks);
|
|
109
|
+
const stripped = lines.map((l) => stripAnsi(l));
|
|
110
|
+
// (a) every word of the original status appears in the joined lines
|
|
111
|
+
const allText = stripped.join(" ");
|
|
112
|
+
const words = stripAnsi(LONG).split(/\s+/).filter(Boolean);
|
|
113
|
+
for (const word of words) {
|
|
114
|
+
expect(allText).toContain(word);
|
|
115
|
+
}
|
|
116
|
+
// (b) every line ≤ 158 visible cols
|
|
117
|
+
expect(lines.every((l) => visibleWidth(stripAnsi(l)) <= 158)).toBe(true);
|
|
118
|
+
});
|
|
87
119
|
});
|
package/src/utils/stats.test.ts
CHANGED
|
@@ -21,9 +21,16 @@ interface UserMessage {
|
|
|
21
21
|
content: string;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
interface ToolResultMessage {
|
|
25
|
+
role: "toolResult";
|
|
26
|
+
usage?: MessageUsage | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
interface SessionEntry {
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
id: string;
|
|
31
|
+
type: "message" | "usage" | "compaction" | "branch_summary";
|
|
32
|
+
message?: AssistantMessage | UserMessage | ToolResultMessage | undefined;
|
|
33
|
+
usage?: MessageUsage | undefined;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
interface MockContext {
|
|
@@ -34,20 +41,48 @@ interface MockContext {
|
|
|
34
41
|
model?: { contextWindow?: number };
|
|
35
42
|
}
|
|
36
43
|
|
|
37
|
-
|
|
44
|
+
let idCounter = 1;
|
|
45
|
+
|
|
46
|
+
function makeAssistantEntry(idOrUsage: string | MessageUsage, maybeUsage?: MessageUsage): SessionEntry {
|
|
47
|
+
const id = typeof idOrUsage === "string" ? idOrUsage : `asst-${idCounter++}`;
|
|
48
|
+
const usage = typeof idOrUsage === "string" ? maybeUsage! : idOrUsage;
|
|
38
49
|
return {
|
|
50
|
+
id,
|
|
39
51
|
type: "message",
|
|
40
52
|
message: { role: "assistant", usage },
|
|
41
53
|
};
|
|
42
54
|
}
|
|
43
55
|
|
|
44
|
-
function makeUserEntry(): SessionEntry {
|
|
56
|
+
function makeUserEntry(id = `user-${idCounter++}`): SessionEntry {
|
|
45
57
|
return {
|
|
58
|
+
id,
|
|
46
59
|
type: "message",
|
|
47
60
|
message: { role: "user", content: "hello" },
|
|
48
61
|
};
|
|
49
62
|
}
|
|
50
63
|
|
|
64
|
+
function makeUsageEntry(id: string, usage: MessageUsage): SessionEntry {
|
|
65
|
+
return {
|
|
66
|
+
id,
|
|
67
|
+
type: "usage",
|
|
68
|
+
usage,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function makeToolResultEntry(id: string, usage?: MessageUsage): SessionEntry {
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
type: "message",
|
|
76
|
+
message: usage !== undefined ? { role: "toolResult", usage } : { role: "toolResult" },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeCompactionEntry(id: string, usage?: MessageUsage): SessionEntry {
|
|
81
|
+
return usage !== undefined
|
|
82
|
+
? { id, type: "compaction", usage }
|
|
83
|
+
: { id, type: "compaction" };
|
|
84
|
+
}
|
|
85
|
+
|
|
51
86
|
function makeCtx(entries: SessionEntry[], contextUsage?: { contextWindow?: number; percent?: number }, modelContextWindow?: number): any {
|
|
52
87
|
return {
|
|
53
88
|
sessionManager: { getEntries: () => entries },
|
|
@@ -61,12 +96,14 @@ function makeCtx(entries: SessionEntry[], contextUsage?: { contextWindow?: numbe
|
|
|
61
96
|
describe("getTokenUsageStats", () => {
|
|
62
97
|
let getTokenUsageStats: typeof import("./stats.js").getTokenUsageStats;
|
|
63
98
|
let invalidateStatsCache: typeof import("./stats.js").invalidateStatsCache;
|
|
99
|
+
let resetStatsState: typeof import("./stats.js").resetStatsState;
|
|
64
100
|
|
|
65
101
|
async function loadModule() {
|
|
66
102
|
vi.resetModules();
|
|
67
103
|
const mod = await import("./stats.js");
|
|
68
104
|
getTokenUsageStats = mod.getTokenUsageStats;
|
|
69
105
|
invalidateStatsCache = mod.invalidateStatsCache;
|
|
106
|
+
resetStatsState = mod.resetStatsState;
|
|
70
107
|
}
|
|
71
108
|
|
|
72
109
|
it("empty entries returns zero stats", async () => {
|
|
@@ -124,6 +161,135 @@ describe("getTokenUsageStats", () => {
|
|
|
124
161
|
expect(result.totalOutput).toBe(50);
|
|
125
162
|
});
|
|
126
163
|
|
|
164
|
+
it("accumulates tokens and cost from UsageEntry (cache warming)", async () => {
|
|
165
|
+
await loadModule();
|
|
166
|
+
const ctx = makeCtx([
|
|
167
|
+
makeAssistantEntry("a1", { input: 100, output: 50, cacheRead: 10, cacheWrite: 5, cost: { total: 0.01 } }),
|
|
168
|
+
makeUsageEntry("u1", { input: 0, output: 0, cacheRead: 500, cacheWrite: 200, cost: { total: 0.005 } }),
|
|
169
|
+
]);
|
|
170
|
+
const result = getTokenUsageStats(ctx);
|
|
171
|
+
expect(result).toEqual({
|
|
172
|
+
totalInput: 100,
|
|
173
|
+
totalOutput: 50,
|
|
174
|
+
totalCacheRead: 510,
|
|
175
|
+
totalCacheWrite: 205,
|
|
176
|
+
totalCost: 0.015,
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("accumulates usage from ToolResultMessage", async () => {
|
|
181
|
+
await loadModule();
|
|
182
|
+
const ctx = makeCtx([
|
|
183
|
+
makeAssistantEntry("a1", { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }),
|
|
184
|
+
makeToolResultEntry("t1", { input: 200, output: 80, cacheRead: 50, cacheWrite: 20, cost: { total: 0.02 } }),
|
|
185
|
+
makeToolResultEntry("t2"), // toolResult without usage ignored
|
|
186
|
+
]);
|
|
187
|
+
const result = getTokenUsageStats(ctx);
|
|
188
|
+
expect(result).toEqual({
|
|
189
|
+
totalInput: 300,
|
|
190
|
+
totalOutput: 130,
|
|
191
|
+
totalCacheRead: 50,
|
|
192
|
+
totalCacheWrite: 20,
|
|
193
|
+
totalCost: 0.03,
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("accumulates usage from CompactionEntry and branch_summary", async () => {
|
|
198
|
+
await loadModule();
|
|
199
|
+
const ctx = makeCtx([
|
|
200
|
+
makeCompactionEntry("c1", { input: 1000, output: 200, cacheRead: 100, cacheWrite: 50, cost: { total: 0.04 } }),
|
|
201
|
+
{
|
|
202
|
+
id: "bs1",
|
|
203
|
+
type: "branch_summary",
|
|
204
|
+
usage: { input: 500, output: 100, cacheRead: 50, cacheWrite: 25, cost: { total: 0.02 } },
|
|
205
|
+
} as SessionEntry,
|
|
206
|
+
]);
|
|
207
|
+
const result = getTokenUsageStats(ctx);
|
|
208
|
+
expect(result).toEqual({
|
|
209
|
+
totalInput: 1500,
|
|
210
|
+
totalOutput: 300,
|
|
211
|
+
totalCacheRead: 150,
|
|
212
|
+
totalCacheWrite: 75,
|
|
213
|
+
totalCost: 0.06,
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("detects branch switch: changing entry IDs at anchor position triggers clean re-scan", async () => {
|
|
218
|
+
await loadModule();
|
|
219
|
+
// Branch 1: entries [a1, a2]
|
|
220
|
+
const branch1 = [
|
|
221
|
+
makeAssistantEntry("a1", { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }),
|
|
222
|
+
makeAssistantEntry("a2", { input: 200, output: 100, cacheRead: 0, cacheWrite: 0, cost: { total: 0.02 } }),
|
|
223
|
+
];
|
|
224
|
+
let ctx = makeCtx(branch1);
|
|
225
|
+
let result = getTokenUsageStats(ctx);
|
|
226
|
+
expect(result.totalInput).toBe(300);
|
|
227
|
+
expect(result.totalOutput).toBe(150);
|
|
228
|
+
|
|
229
|
+
// Branch switch: second entry is different id/usage (a2_alt instead of a2)
|
|
230
|
+
const branch2: SessionEntry[] = [
|
|
231
|
+
branch1[0]!,
|
|
232
|
+
makeAssistantEntry("a2_alt", { input: 50, output: 25, cacheRead: 0, cacheWrite: 0, cost: { total: 0.005 } }),
|
|
233
|
+
];
|
|
234
|
+
ctx = makeCtx(branch2);
|
|
235
|
+
result = getTokenUsageStats(ctx);
|
|
236
|
+
// Should NOT be 350 (accumulated on old running total); must re-scan branch2 cleanly
|
|
237
|
+
expect(result).toEqual({
|
|
238
|
+
totalInput: 150,
|
|
239
|
+
totalOutput: 75,
|
|
240
|
+
totalCacheRead: 0,
|
|
241
|
+
totalCacheWrite: 0,
|
|
242
|
+
totalCost: 0.015,
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("detects branch switch: changing first entry ID triggers clean re-scan even if tail anchor matches", async () => {
|
|
247
|
+
await loadModule();
|
|
248
|
+
// Branch 1: entries [a1, a2]
|
|
249
|
+
const branch1 = [
|
|
250
|
+
makeAssistantEntry("a1", { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }),
|
|
251
|
+
makeAssistantEntry("a2", { input: 200, output: 100, cacheRead: 0, cacheWrite: 0, cost: { total: 0.02 } }),
|
|
252
|
+
];
|
|
253
|
+
let ctx = makeCtx(branch1);
|
|
254
|
+
let result = getTokenUsageStats(ctx);
|
|
255
|
+
expect(result.totalInput).toBe(300);
|
|
256
|
+
|
|
257
|
+
// Branch switch where tail ID 'a2' happens to match, but head entry is 'a1_replaced'
|
|
258
|
+
const branch2: SessionEntry[] = [
|
|
259
|
+
makeAssistantEntry("a1_replaced", { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 } }),
|
|
260
|
+
branch1[1]!,
|
|
261
|
+
];
|
|
262
|
+
ctx = makeCtx(branch2);
|
|
263
|
+
result = getTokenUsageStats(ctx);
|
|
264
|
+
expect(result).toEqual({
|
|
265
|
+
totalInput: 210,
|
|
266
|
+
totalOutput: 105,
|
|
267
|
+
totalCacheRead: 0,
|
|
268
|
+
totalCacheWrite: 0,
|
|
269
|
+
totalCost: 0.021,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("updates total when tail entry usage mutates in-place with unchanged entry count", async () => {
|
|
274
|
+
await loadModule();
|
|
275
|
+
const tailEntry = makeAssistantEntry("a1", { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } });
|
|
276
|
+
const entries = [tailEntry];
|
|
277
|
+
const ctx = makeCtx(entries);
|
|
278
|
+
|
|
279
|
+
const initial = getTokenUsageStats(ctx);
|
|
280
|
+
expect(initial.totalOutput).toBe(50);
|
|
281
|
+
expect(initial.totalCost).toBe(0.01);
|
|
282
|
+
|
|
283
|
+
// Direct in-place property mutation of existing usage object (aliasing test)
|
|
284
|
+
const usageObj = (tailEntry.message as AssistantMessage).usage;
|
|
285
|
+
usageObj.output = 120;
|
|
286
|
+
usageObj.cost = { total: 0.025 };
|
|
287
|
+
|
|
288
|
+
const updated = getTokenUsageStats(ctx);
|
|
289
|
+
expect(updated.totalOutput).toBe(120);
|
|
290
|
+
expect(updated.totalCost).toBe(0.025);
|
|
291
|
+
});
|
|
292
|
+
|
|
127
293
|
it("cache returns same result within TTL", async () => {
|
|
128
294
|
await loadModule();
|
|
129
295
|
const ctx = makeCtx([
|
|
@@ -146,6 +312,19 @@ describe("getTokenUsageStats", () => {
|
|
|
146
312
|
expect(first).not.toBe(second); // different reference (cache cleared)
|
|
147
313
|
});
|
|
148
314
|
|
|
315
|
+
it("resetStatsState clears module state completely", async () => {
|
|
316
|
+
await loadModule();
|
|
317
|
+
const ctx = makeCtx([
|
|
318
|
+
makeAssistantEntry("a1", { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }),
|
|
319
|
+
]);
|
|
320
|
+
const first = getTokenUsageStats(ctx);
|
|
321
|
+
expect(first.totalInput).toBe(100);
|
|
322
|
+
resetStatsState();
|
|
323
|
+
const second = getTokenUsageStats(ctx);
|
|
324
|
+
expect(second).toEqual(first);
|
|
325
|
+
expect(second).not.toBe(first);
|
|
326
|
+
});
|
|
327
|
+
|
|
149
328
|
it("property: stats are monotonic (adding entries never decreases totals)", async () => {
|
|
150
329
|
await loadModule();
|
|
151
330
|
fc.assert(
|
package/src/utils/stats.ts
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
-
interface MessageUsage {
|
|
4
|
-
input
|
|
5
|
-
output
|
|
6
|
-
cacheRead
|
|
7
|
-
cacheWrite
|
|
8
|
-
cost
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface AssistantMessage {
|
|
12
|
-
usage: MessageUsage;
|
|
3
|
+
export interface MessageUsage {
|
|
4
|
+
input?: number;
|
|
5
|
+
output?: number;
|
|
6
|
+
cacheRead?: number;
|
|
7
|
+
cacheWrite?: number;
|
|
8
|
+
cost?: { total?: number };
|
|
13
9
|
}
|
|
14
10
|
|
|
15
11
|
export interface TokenUsageStats {
|
|
@@ -20,84 +16,183 @@ export interface TokenUsageStats {
|
|
|
20
16
|
totalCost: number;
|
|
21
17
|
}
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
export function extractEntryUsage(sessionEntry: any): MessageUsage | null {
|
|
20
|
+
if (sessionEntry?.type === "usage") {
|
|
21
|
+
return sessionEntry.usage ?? null;
|
|
22
|
+
}
|
|
23
|
+
if (sessionEntry?.type === "message" && sessionEntry.message?.role === "assistant") {
|
|
24
|
+
return sessionEntry.message.usage ?? null;
|
|
25
|
+
}
|
|
26
|
+
if (sessionEntry?.type === "message" && sessionEntry.message?.role === "toolResult" && sessionEntry.message.usage) {
|
|
27
|
+
return sessionEntry.message.usage;
|
|
28
|
+
}
|
|
29
|
+
if ((sessionEntry?.type === "compaction" || sessionEntry?.type === "branch_summary") && sessionEntry.usage) {
|
|
30
|
+
return sessionEntry.usage;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
25
34
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
function snapshotUsage(usage: MessageUsage | null | undefined): MessageUsage | undefined {
|
|
36
|
+
if (!usage) return undefined;
|
|
37
|
+
return {
|
|
38
|
+
input: usage.input ?? 0,
|
|
39
|
+
output: usage.output ?? 0,
|
|
40
|
+
cacheRead: usage.cacheRead ?? 0,
|
|
41
|
+
cacheWrite: usage.cacheWrite ?? 0,
|
|
42
|
+
cost: { total: usage.cost?.total ?? 0 },
|
|
43
|
+
};
|
|
30
44
|
}
|
|
31
45
|
|
|
32
|
-
|
|
46
|
+
function usageEquals(a: MessageUsage | undefined, b: MessageUsage | undefined): boolean {
|
|
47
|
+
if (!a && !b) return true;
|
|
48
|
+
if (!a || !b) return false;
|
|
49
|
+
return (
|
|
50
|
+
(a.input ?? 0) === (b.input ?? 0) &&
|
|
51
|
+
(a.output ?? 0) === (b.output ?? 0) &&
|
|
52
|
+
(a.cacheRead ?? 0) === (b.cacheRead ?? 0) &&
|
|
53
|
+
(a.cacheWrite ?? 0) === (b.cacheWrite ?? 0) &&
|
|
54
|
+
(a.cost?.total ?? 0) === (b.cost?.total ?? 0)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
33
57
|
|
|
34
58
|
// Running total from initial scan — avoids re-scanning old entries
|
|
35
59
|
let runningTotal: TokenUsageStats | undefined;
|
|
36
60
|
let runningTotalEntryCount = 0;
|
|
61
|
+
let lastFirstEntryId: string | undefined;
|
|
62
|
+
let lastAnchorEntryId: string | undefined;
|
|
63
|
+
let lastTailUsage: MessageUsage | undefined;
|
|
64
|
+
let statsCache: TokenUsageStats | undefined;
|
|
37
65
|
|
|
38
66
|
export function getTokenUsageStats(ctx: ExtensionContext): TokenUsageStats {
|
|
39
67
|
const entries = ctx.sessionManager.getEntries();
|
|
40
68
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
69
|
+
if (entries.length === 0) {
|
|
70
|
+
resetStatsState();
|
|
71
|
+
return {
|
|
72
|
+
totalInput: 0,
|
|
73
|
+
totalOutput: 0,
|
|
74
|
+
totalCacheRead: 0,
|
|
75
|
+
totalCacheWrite: 0,
|
|
76
|
+
totalCost: 0,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const anchorMatch =
|
|
81
|
+
runningTotal !== undefined &&
|
|
82
|
+
runningTotalEntryCount > 0 &&
|
|
83
|
+
entries[0]?.id === lastFirstEntryId &&
|
|
84
|
+
entries[runningTotalEntryCount - 1]?.id === lastAnchorEntryId;
|
|
85
|
+
|
|
86
|
+
if (anchorMatch && runningTotal) {
|
|
87
|
+
if (entries.length === runningTotalEntryCount) {
|
|
88
|
+
// Invariant: for unchanged entry count, only the tail entry's usage mutates during streaming.
|
|
89
|
+
const currentTailUsage = extractEntryUsage(entries[entries.length - 1]);
|
|
90
|
+
if (!usageEquals(currentTailUsage ?? undefined, lastTailUsage)) {
|
|
91
|
+
const pIn = lastTailUsage?.input ?? 0;
|
|
92
|
+
const pOut = lastTailUsage?.output ?? 0;
|
|
93
|
+
const pCr = lastTailUsage?.cacheRead ?? 0;
|
|
94
|
+
const pCw = lastTailUsage?.cacheWrite ?? 0;
|
|
95
|
+
const pCost = lastTailUsage?.cost?.total ?? 0;
|
|
96
|
+
|
|
97
|
+
const cIn = currentTailUsage?.input ?? 0;
|
|
98
|
+
const cOut = currentTailUsage?.output ?? 0;
|
|
99
|
+
const cCr = currentTailUsage?.cacheRead ?? 0;
|
|
100
|
+
const cCw = currentTailUsage?.cacheWrite ?? 0;
|
|
101
|
+
const cCost = currentTailUsage?.cost?.total ?? 0;
|
|
102
|
+
|
|
103
|
+
runningTotal = {
|
|
104
|
+
totalInput: runningTotal.totalInput + (cIn - pIn),
|
|
105
|
+
totalOutput: runningTotal.totalOutput + (cOut - pOut),
|
|
106
|
+
totalCacheRead: runningTotal.totalCacheRead + (cCr - pCr),
|
|
107
|
+
totalCacheWrite: runningTotal.totalCacheWrite + (cCw - pCw),
|
|
108
|
+
totalCost: runningTotal.totalCost + (cCost - pCost),
|
|
109
|
+
};
|
|
110
|
+
lastTailUsage = snapshotUsage(currentTailUsage);
|
|
111
|
+
statsCache = runningTotal;
|
|
112
|
+
return runningTotal;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (statsCache !== undefined) {
|
|
116
|
+
return statsCache;
|
|
117
|
+
}
|
|
118
|
+
statsCache = { ...runningTotal };
|
|
119
|
+
return statsCache;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (entries.length > runningTotalEntryCount) {
|
|
123
|
+
let totalInput = runningTotal.totalInput;
|
|
124
|
+
let totalOutput = runningTotal.totalOutput;
|
|
125
|
+
let totalCacheRead = runningTotal.totalCacheRead;
|
|
126
|
+
let totalCacheWrite = runningTotal.totalCacheWrite;
|
|
127
|
+
let totalCost = runningTotal.totalCost;
|
|
128
|
+
|
|
129
|
+
const prevTailUsage = extractEntryUsage(entries[runningTotalEntryCount - 1]);
|
|
130
|
+
if (!usageEquals(prevTailUsage ?? undefined, lastTailUsage)) {
|
|
131
|
+
const pIn = lastTailUsage?.input ?? 0;
|
|
132
|
+
const pOut = lastTailUsage?.output ?? 0;
|
|
133
|
+
const pCr = lastTailUsage?.cacheRead ?? 0;
|
|
134
|
+
const pCw = lastTailUsage?.cacheWrite ?? 0;
|
|
135
|
+
const pCost = lastTailUsage?.cost?.total ?? 0;
|
|
136
|
+
|
|
137
|
+
const cIn = prevTailUsage?.input ?? 0;
|
|
138
|
+
const cOut = prevTailUsage?.output ?? 0;
|
|
139
|
+
const cCr = prevTailUsage?.cacheRead ?? 0;
|
|
140
|
+
const cCw = prevTailUsage?.cacheWrite ?? 0;
|
|
141
|
+
const cCost = prevTailUsage?.cost?.total ?? 0;
|
|
142
|
+
|
|
143
|
+
totalInput += cIn - pIn;
|
|
144
|
+
totalOutput += cOut - pOut;
|
|
145
|
+
totalCacheRead += cCr - pCr;
|
|
146
|
+
totalCacheWrite += cCw - pCw;
|
|
147
|
+
totalCost += cCost - pCost;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (let i = runningTotalEntryCount; i < entries.length; i++) {
|
|
151
|
+
const u = extractEntryUsage(entries[i]);
|
|
152
|
+
if (u) {
|
|
153
|
+
totalInput += u.input ?? 0;
|
|
154
|
+
totalOutput += u.output ?? 0;
|
|
155
|
+
totalCacheRead += u.cacheRead ?? 0;
|
|
156
|
+
totalCacheWrite += u.cacheWrite ?? 0;
|
|
157
|
+
totalCost += u.cost?.total ?? 0;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
runningTotal = { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost };
|
|
162
|
+
runningTotalEntryCount = entries.length;
|
|
163
|
+
lastFirstEntryId = entries[0]?.id;
|
|
164
|
+
lastAnchorEntryId = entries[entries.length - 1]?.id;
|
|
165
|
+
lastTailUsage = snapshotUsage(extractEntryUsage(entries[entries.length - 1]));
|
|
166
|
+
statsCache = runningTotal;
|
|
167
|
+
return runningTotal;
|
|
168
|
+
}
|
|
48
169
|
}
|
|
49
170
|
|
|
171
|
+
// Else (first run, entries truncated, or branch switch where anchor id mismatched):
|
|
50
172
|
let totalInput = 0,
|
|
51
173
|
totalOutput = 0,
|
|
52
174
|
totalCacheRead = 0,
|
|
53
175
|
totalCacheWrite = 0,
|
|
54
176
|
totalCost = 0;
|
|
55
177
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const assistantMessage = sessionEntry.message as AssistantMessage;
|
|
65
|
-
totalInput += assistantMessage.usage.input;
|
|
66
|
-
totalOutput += assistantMessage.usage.output;
|
|
67
|
-
totalCacheRead += assistantMessage.usage.cacheRead;
|
|
68
|
-
totalCacheWrite += assistantMessage.usage.cacheWrite;
|
|
69
|
-
totalCost += assistantMessage.usage.cost.total;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
runningTotal = { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost };
|
|
73
|
-
runningTotalEntryCount = entries.length;
|
|
74
|
-
} else {
|
|
75
|
-
// Incremental: start from running total + scan new entries
|
|
76
|
-
const rt = runningTotal!; // Safe: hasRunningTotal guard above
|
|
77
|
-
totalInput = rt.totalInput;
|
|
78
|
-
totalOutput = rt.totalOutput;
|
|
79
|
-
totalCacheRead = rt.totalCacheRead;
|
|
80
|
-
totalCacheWrite = rt.totalCacheWrite;
|
|
81
|
-
totalCost = rt.totalCost;
|
|
82
|
-
|
|
83
|
-
for (let i = startIdx; i < entries.length; i++) {
|
|
84
|
-
const sessionEntry = entries[i];
|
|
85
|
-
if (sessionEntry?.type === "message" && sessionEntry.message?.role === "assistant") {
|
|
86
|
-
const assistantMessage = sessionEntry.message as AssistantMessage;
|
|
87
|
-
totalInput += assistantMessage.usage.input;
|
|
88
|
-
totalOutput += assistantMessage.usage.output;
|
|
89
|
-
totalCacheRead += assistantMessage.usage.cacheRead;
|
|
90
|
-
totalCacheWrite += assistantMessage.usage.cacheWrite;
|
|
91
|
-
totalCost += assistantMessage.usage.cost.total;
|
|
92
|
-
}
|
|
178
|
+
for (const sessionEntry of entries) {
|
|
179
|
+
const u = extractEntryUsage(sessionEntry);
|
|
180
|
+
if (u) {
|
|
181
|
+
totalInput += u.input ?? 0;
|
|
182
|
+
totalOutput += u.output ?? 0;
|
|
183
|
+
totalCacheRead += u.cacheRead ?? 0;
|
|
184
|
+
totalCacheWrite += u.cacheWrite ?? 0;
|
|
185
|
+
totalCost += u.cost?.total ?? 0;
|
|
93
186
|
}
|
|
94
|
-
runningTotalEntryCount = entries.length;
|
|
95
187
|
}
|
|
96
188
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
189
|
+
runningTotal = { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost };
|
|
190
|
+
runningTotalEntryCount = entries.length;
|
|
191
|
+
lastFirstEntryId = entries[0]?.id;
|
|
192
|
+
lastAnchorEntryId = entries[entries.length - 1]?.id;
|
|
193
|
+
lastTailUsage = snapshotUsage(extractEntryUsage(entries[entries.length - 1]));
|
|
194
|
+
statsCache = runningTotal;
|
|
195
|
+
return runningTotal;
|
|
101
196
|
}
|
|
102
197
|
|
|
103
198
|
/** Clear the stats cache — call when a new message arrives. */
|
|
@@ -105,6 +200,16 @@ export function invalidateStatsCache(): void {
|
|
|
105
200
|
statsCache = undefined;
|
|
106
201
|
}
|
|
107
202
|
|
|
203
|
+
/** Reset all module-level stats state for clean isolation. */
|
|
204
|
+
export function resetStatsState(): void {
|
|
205
|
+
runningTotal = undefined;
|
|
206
|
+
runningTotalEntryCount = 0;
|
|
207
|
+
lastFirstEntryId = undefined;
|
|
208
|
+
lastAnchorEntryId = undefined;
|
|
209
|
+
lastTailUsage = undefined;
|
|
210
|
+
statsCache = undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
108
213
|
export interface ContextWindowInfo {
|
|
109
214
|
percent: string;
|
|
110
215
|
percentValue: number;
|