@pi-archimedes/footer 2.8.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/footer",
3
- "version": "2.8.0",
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.8.0"
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.85.1",
30
- "@earendil-works/pi-tui": "^0.85.1",
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": {
@@ -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
- type: "message";
26
- message: AssistantMessage | UserMessage;
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
- function makeAssistantEntry(usage: MessageUsage): SessionEntry {
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(
@@ -1,15 +1,11 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
- interface MessageUsage {
4
- input: number;
5
- output: number;
6
- cacheRead: number;
7
- cacheWrite: number;
8
- cost: { total: number };
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
- // Cache TTL: 500ms — stats don't change more frequently than message_end events
24
- const STATS_CACHE_TTL_MS = 500;
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
- interface StatsCacheEntry {
27
- value: TokenUsageStats;
28
- timestamp: number;
29
- entryCount: number;
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
- let statsCache: StatsCacheEntry | undefined;
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
- // Return cached result if entry count hasn't changed and cache is fresh
42
- if (
43
- statsCache &&
44
- statsCache.entryCount === entries.length &&
45
- Date.now() - statsCache.timestamp < STATS_CACHE_TTL_MS
46
- ) {
47
- return statsCache.value;
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
- // If we have a running total, only scan new entries
57
- const hasRunningTotal = runningTotal !== undefined && runningTotalEntryCount < entries.length;
58
- const startIdx = hasRunningTotal ? runningTotalEntryCount : 0;
59
-
60
- if (startIdx === 0) {
61
- // Full scan — no running total yet
62
- for (const sessionEntry of entries) {
63
- if (sessionEntry?.type === "message" && sessionEntry.message?.role === "assistant") {
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
- const result: TokenUsageStats = { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost };
98
- runningTotal = result;
99
- statsCache = { value: result, timestamp: Date.now(), entryCount: entries.length };
100
- return result;
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;