@pi-archimedes/footer 2.8.0 → 2.9.1
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/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.1",
|
|
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.1"
|
|
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/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;
|