@shanepadgett/tau-agent 0.5.0 → 0.6.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.
@@ -1,9 +1,11 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
+ import { resolve } from "node:path";
3
4
  import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
4
5
  import { onTauEvent, type TauAgentEvents } from "../../shared/events.js";
5
6
  import type { ToolRowStateStore } from "../../shared/tool-row-state.js";
6
7
  import { Marker, type MarkerState } from "@shanepadgett/tau-tui";
8
+ import type { ReadCacheMetaV1 } from "./read-cache.ts";
7
9
 
8
10
  const AUTOREAD_MESSAGE_TYPE = "tau.autoread";
9
11
 
@@ -17,6 +19,7 @@ interface AutoreadDetails {
17
19
  batchId: string;
18
20
  status: AutoreadStatus;
19
21
  error?: string;
22
+ readCache?: ReadCacheMetaV1;
20
23
  }
21
24
 
22
25
  export function registerAutoread(pi: ExtensionAPI, rowState: ToolRowStateStore): void {
@@ -34,12 +37,26 @@ export function registerAutoread(pi: ExtensionAPI, rowState: ToolRowStateStore):
34
37
  batchId: event.batchId,
35
38
  } satisfies Omit<AutoreadDetails, "status" | "error">;
36
39
  try {
37
- const content = await readFile(join(event.cwd, file.path), "utf8");
40
+ const pathKey = resolve(event.cwd, file.path);
41
+ const bytes = await readFile(pathKey);
42
+ const content = bytes.toString("utf8");
43
+ const totalLines = content.split("\n").length;
44
+ const readCache = {
45
+ v: 1,
46
+ pathKey,
47
+ scopeKey: "full:n0",
48
+ servedHash: createHash("sha256").update(bytes).digest("hex"),
49
+ mode: "baseline",
50
+ baselineTokens: Math.ceil(content.length / 4),
51
+ returnedTokens: Math.ceil(content.length / 4),
52
+ totalLines,
53
+ summary: `${totalLines} lines`,
54
+ } satisfies ReadCacheMetaV1;
38
55
  pi.sendMessage({
39
56
  customType: AUTOREAD_MESSAGE_TYPE,
40
57
  content: `${file.path}\n${content}`,
41
58
  display: true,
42
- details: { ...details, status: "read" },
59
+ details: { ...details, status: "read", readCache },
43
60
  });
44
61
  } catch (error) {
45
62
  const message = error instanceof Error ? error.message : String(error);
@@ -4,14 +4,35 @@ import { registerAutoread } from "./autoread.ts";
4
4
  import { createFindTool } from "./find.ts";
5
5
  import { createGrepTool } from "./grep.ts";
6
6
  import { createLsTool } from "./ls.ts";
7
+ import { createReadCacheStore } from "./read-cache.ts";
8
+ import { createReadSnapshotStore } from "./read-snapshots.ts";
9
+ import { showReadStats } from "./read-stats.ts";
7
10
  import { createExploreReadTool } from "./read.ts";
8
11
 
9
12
  export default function exploreExtension(pi: ExtensionAPI): void {
10
13
  const rowState = createToolRowStateStore(pi, "explore.tool-row-state");
14
+ const readCache = createReadCacheStore();
15
+ const readSnapshots = createReadSnapshotStore();
11
16
  registerAutoread(pi, rowState);
12
17
  pi.registerTool(createLsTool(rowState));
13
18
  pi.registerTool(createFindTool(rowState));
14
19
  pi.registerTool(createGrepTool(rowState));
15
- pi.registerTool(createExploreReadTool(rowState));
16
- pi.on("session_start", () => rowState.clear());
20
+ pi.registerTool(createExploreReadTool(rowState, readCache, readSnapshots));
21
+ pi.registerCommand("read-stats", {
22
+ description: "Show estimated read token and cost savings for this session",
23
+ async handler(_args, ctx) {
24
+ if (ctx.mode !== "tui") {
25
+ ctx.ui.notify("Read stats require TUI mode", "error");
26
+ return;
27
+ }
28
+ await showReadStats(ctx);
29
+ },
30
+ });
31
+ pi.on("session_start", () => {
32
+ rowState.clear();
33
+ readCache.clear();
34
+ readSnapshots.clear();
35
+ });
36
+ pi.on("session_compact", () => readCache.clear());
37
+ pi.on("session_tree", () => readCache.clear());
17
38
  }
@@ -0,0 +1,209 @@
1
+ import { resolve } from "node:path";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ export type ReadCacheMode = "baseline" | "recovery" | "unchanged" | "diff";
5
+
6
+ export interface ReadCacheMetaV1 {
7
+ v: 1;
8
+ pathKey: string;
9
+ scopeKey: string;
10
+ servedHash: string;
11
+ baseHash?: string;
12
+ mode: ReadCacheMode;
13
+ baselineTokens: number;
14
+ returnedTokens: number;
15
+ totalLines: number;
16
+ summary: string;
17
+ }
18
+
19
+ interface ScopeTrust {
20
+ hash: string;
21
+ sequence: number;
22
+ }
23
+
24
+ interface ReplayState {
25
+ trust: Map<string, Map<string, ScopeTrust>>;
26
+ unlockedPaths: Set<string>;
27
+ sequence: number;
28
+ }
29
+
30
+ interface OverlayState {
31
+ trust: Map<string, Map<string, ScopeTrust>>;
32
+ sequence: number;
33
+ }
34
+
35
+ export interface ReadCacheDecision {
36
+ baseHash?: string;
37
+ recovery: boolean;
38
+ }
39
+
40
+ export interface ReadCacheStore {
41
+ decision(ctx: ExtensionContext, pathKey: string, scopeKey: string): ReadCacheDecision;
42
+ record(ctx: ExtensionContext, meta: ReadCacheMetaV1): void;
43
+ clear(): void;
44
+ }
45
+
46
+ export function createReadCacheStore(): ReadCacheStore {
47
+ const overlays = new Map<string, OverlayState>();
48
+
49
+ return {
50
+ decision(ctx, pathKey, scopeKey) {
51
+ const state = replay(ctx);
52
+ const overlay = overlays.get(overlayKey(ctx));
53
+ if (overlay) mergeTrust(state, overlay.trust);
54
+ return {
55
+ baseHash: state.trust.get(pathKey)?.get(scopeKey)?.hash,
56
+ recovery: state.unlockedPaths.has(pathKey),
57
+ };
58
+ },
59
+ record(ctx, meta) {
60
+ const key = overlayKey(ctx);
61
+ let overlay = overlays.get(key);
62
+ if (!overlay) {
63
+ overlay = { trust: new Map(), sequence: 1_000_000_000 };
64
+ overlays.set(key, overlay);
65
+ }
66
+ overlay.sequence += 1;
67
+ setTrust(overlay.trust, meta.pathKey, meta.scopeKey, meta.servedHash, overlay.sequence);
68
+ while (overlays.size > 32) {
69
+ const oldest = overlays.keys().next().value as string | undefined;
70
+ if (oldest === undefined) break;
71
+ overlays.delete(oldest);
72
+ }
73
+ },
74
+ clear() {
75
+ overlays.clear();
76
+ },
77
+ };
78
+ }
79
+
80
+ function parseReadCacheMeta(value: unknown): ReadCacheMetaV1 | undefined {
81
+ if (!isRecord(value) || value.v !== 1) return undefined;
82
+ if (typeof value.pathKey !== "string" || typeof value.scopeKey !== "string") return undefined;
83
+ if (typeof value.servedHash !== "string" || value.servedHash.length === 0) return undefined;
84
+ if (!isReadCacheMode(value.mode)) return undefined;
85
+ if (!isNonNegativeFinite(value.baselineTokens) || !isNonNegativeFinite(value.returnedTokens)) return undefined;
86
+ if (!isNonNegativeFinite(value.totalLines) || typeof value.summary !== "string") return undefined;
87
+ if (value.baseHash !== undefined && typeof value.baseHash !== "string") return undefined;
88
+ if ((value.mode === "unchanged" || value.mode === "diff") && !value.baseHash) return undefined;
89
+ return {
90
+ v: 1,
91
+ pathKey: value.pathKey,
92
+ scopeKey: value.scopeKey,
93
+ servedHash: value.servedHash,
94
+ baseHash: value.baseHash,
95
+ mode: value.mode,
96
+ baselineTokens: value.baselineTokens,
97
+ returnedTokens: value.returnedTokens,
98
+ totalLines: value.totalLines,
99
+ summary: value.summary,
100
+ };
101
+ }
102
+
103
+ export function readMetaFromMessage(message: unknown): ReadCacheMetaV1 | undefined {
104
+ if (!isRecord(message)) return undefined;
105
+ const isReadResult = message.role === "toolResult" && message.toolName === "read";
106
+ const isAutoread = message.customType === "tau.autoread";
107
+ if (!isReadResult && !isAutoread) return undefined;
108
+ if (!isRecord(message.details)) return undefined;
109
+ if (isAutoread && message.details.status !== "read") return undefined;
110
+ return parseReadCacheMeta(message.details.readCache);
111
+ }
112
+
113
+ function replay(ctx: ExtensionContext): ReplayState {
114
+ const state: ReplayState = { trust: new Map(), unlockedPaths: new Set(), sequence: 0 };
115
+ const manager = ctx.sessionManager;
116
+ if (!manager) return state;
117
+ const branch = manager.getBranch();
118
+ let start = 0;
119
+ for (let index = 0; index < branch.length; index += 1) {
120
+ const entry = branch[index];
121
+ if (isRecord(entry) && entry.type === "compaction") start = index + 1;
122
+ }
123
+
124
+ for (const entry of branch.slice(start)) {
125
+ if (!isRecord(entry) || entry.type !== "message" || !("message" in entry)) continue;
126
+ const message = entry.message;
127
+ const meta = readMetaFromMessage(message);
128
+ if (meta) {
129
+ state.sequence += 1;
130
+ applyReadMeta(state, meta);
131
+ continue;
132
+ }
133
+ for (const path of failedPatchPaths(message, ctx.cwd)) state.unlockedPaths.add(path);
134
+ }
135
+ return state;
136
+ }
137
+
138
+ function applyReadMeta(state: ReplayState, meta: ReadCacheMetaV1): void {
139
+ const existing = state.trust.get(meta.pathKey)?.get(meta.scopeKey);
140
+ if (meta.mode === "baseline" || meta.mode === "recovery") {
141
+ setTrust(state.trust, meta.pathKey, meta.scopeKey, meta.servedHash, state.sequence);
142
+ state.unlockedPaths.delete(meta.pathKey);
143
+ return;
144
+ }
145
+ if (!meta.baseHash || existing?.hash !== meta.baseHash) return;
146
+ if (meta.mode === "unchanged" && meta.servedHash !== meta.baseHash) return;
147
+ setTrust(state.trust, meta.pathKey, meta.scopeKey, meta.servedHash, state.sequence);
148
+ }
149
+
150
+ function failedPatchPaths(message: unknown, cwd: string): string[] {
151
+ if (!isRecord(message) || message.role !== "toolResult" || message.toolName !== "patch") return [];
152
+ if (
153
+ !isRecord(message.details) ||
154
+ message.details.status === "completed" ||
155
+ !Array.isArray(message.details.failures)
156
+ ) {
157
+ return [];
158
+ }
159
+ const paths: string[] = [];
160
+ for (const failure of message.details.failures) {
161
+ if (!isRecord(failure) || typeof failure.path !== "string" || failure.path.trim().length === 0) continue;
162
+ paths.push(resolve(cwd, failure.path));
163
+ }
164
+ return paths;
165
+ }
166
+
167
+ function setTrust(
168
+ trust: Map<string, Map<string, ScopeTrust>>,
169
+ pathKey: string,
170
+ scopeKey: string,
171
+ hash: string,
172
+ sequence: number,
173
+ ): void {
174
+ let scopes = trust.get(pathKey);
175
+ if (!scopes) {
176
+ scopes = new Map();
177
+ trust.set(pathKey, scopes);
178
+ }
179
+ scopes.set(scopeKey, { hash, sequence });
180
+ }
181
+
182
+ function mergeTrust(state: ReplayState, overlay: Map<string, Map<string, ScopeTrust>>): void {
183
+ for (const [pathKey, scopes] of overlay) {
184
+ for (const [scopeKey, candidate] of scopes) {
185
+ const current = state.trust.get(pathKey)?.get(scopeKey);
186
+ if (!current || candidate.sequence > current.sequence) {
187
+ setTrust(state.trust, pathKey, scopeKey, candidate.hash, candidate.sequence);
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ function overlayKey(ctx: ExtensionContext): string {
194
+ const manager = ctx.sessionManager;
195
+ if (!manager) return `test:${ctx.cwd}`;
196
+ return `${manager.getSessionId()}:${manager.getLeafId() ?? "root"}`;
197
+ }
198
+
199
+ function isReadCacheMode(value: unknown): value is ReadCacheMode {
200
+ return value === "baseline" || value === "recovery" || value === "unchanged" || value === "diff";
201
+ }
202
+
203
+ function isNonNegativeFinite(value: unknown): value is number {
204
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
205
+ }
206
+
207
+ function isRecord(value: unknown): value is Record<string, unknown> {
208
+ return typeof value === "object" && value !== null;
209
+ }
@@ -0,0 +1,44 @@
1
+ const MAX_SNAPSHOT_BYTES = 1024 * 1024;
2
+ const MAX_TOTAL_BYTES = 16 * 1024 * 1024;
3
+
4
+ interface Snapshot {
5
+ text: string;
6
+ bytes: number;
7
+ }
8
+
9
+ export interface ReadSnapshotStore {
10
+ get(hash: string): string | undefined;
11
+ set(hash: string, text: string, bytes: number): void;
12
+ clear(): void;
13
+ }
14
+
15
+ export function createReadSnapshotStore(): ReadSnapshotStore {
16
+ const snapshots = new Map<string, Snapshot>();
17
+ let totalBytes = 0;
18
+
19
+ return {
20
+ get(hash) {
21
+ const snapshot = snapshots.get(hash);
22
+ if (!snapshot) return undefined;
23
+ snapshots.delete(hash);
24
+ snapshots.set(hash, snapshot);
25
+ return snapshot.text;
26
+ },
27
+ set(hash, text, bytes) {
28
+ if (bytes > MAX_SNAPSHOT_BYTES || snapshots.has(hash)) return;
29
+ snapshots.set(hash, { text, bytes });
30
+ totalBytes += bytes;
31
+ while (totalBytes > MAX_TOTAL_BYTES) {
32
+ const oldestHash = snapshots.keys().next().value as string | undefined;
33
+ if (oldestHash === undefined) break;
34
+ const oldest = snapshots.get(oldestHash);
35
+ snapshots.delete(oldestHash);
36
+ totalBytes -= oldest?.bytes ?? 0;
37
+ }
38
+ },
39
+ clear() {
40
+ snapshots.clear();
41
+ totalBytes = 0;
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,154 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, type KeybindingsManager, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
3
+ import { bindingHint, Tabs, ToolPanel, type ToolPanelConfig } from "@shanepadgett/tau-tui";
4
+ import type { ReadCacheMode } from "./read-cache.ts";
5
+
6
+ export interface ReadSavingsSnapshot {
7
+ label: string;
8
+ secondary: string;
9
+ baselineTokens: number;
10
+ returnedTokens: number;
11
+ costSaved: number;
12
+ unchangedCost: number;
13
+ diffCost: number;
14
+ counts: Record<ReadCacheMode, number>;
15
+ }
16
+
17
+ export function createReadStatsPanel(
18
+ tui: TUI,
19
+ theme: Theme,
20
+ keybindings: KeybindingsManager,
21
+ done: (result: unknown) => void,
22
+ current: ReadSavingsSnapshot,
23
+ whole: ReadSavingsSnapshot,
24
+ ): Component {
25
+ return new ReadStatsPanel(tui, theme, keybindings, done, current, whole);
26
+ }
27
+
28
+ class ReadStatsPanel implements Component {
29
+ private readonly tui: TUI;
30
+ private readonly keybindings: KeybindingsManager;
31
+ private readonly done: (result: unknown) => void;
32
+ private readonly snapshots: readonly [ReadSavingsSnapshot, ReadSavingsSnapshot];
33
+ private readonly tabs: Tabs;
34
+ private readonly config: ToolPanelConfig;
35
+ private readonly panel: ToolPanel;
36
+
37
+ constructor(
38
+ tui: TUI,
39
+ theme: Theme,
40
+ keybindings: KeybindingsManager,
41
+ done: (result: unknown) => void,
42
+ current: ReadSavingsSnapshot,
43
+ whole: ReadSavingsSnapshot,
44
+ ) {
45
+ this.tui = tui;
46
+ this.keybindings = keybindings;
47
+ this.done = done;
48
+ this.snapshots = [current, whole];
49
+ this.tabs = new Tabs(
50
+ theme,
51
+ this.snapshots.map((snapshot) => ({
52
+ id: snapshot.label,
53
+ label: snapshot.label,
54
+ body: new SavingsBody(theme, snapshot),
55
+ })),
56
+ current.label,
57
+ );
58
+ this.config = {
59
+ title: "Read savings",
60
+ secondary: current.secondary,
61
+ body: this.tabs,
62
+ footer: {
63
+ kind: "hints",
64
+ hints: [...this.tabs.getKeyHints(), bindingHint("tui.select.cancel", "close")],
65
+ },
66
+ border: "box",
67
+ };
68
+ this.panel = new ToolPanel(theme, this.config);
69
+ }
70
+
71
+ handleInput(data: string): void {
72
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
73
+ this.done(undefined);
74
+ return;
75
+ }
76
+ this.tabs.handleInput(data);
77
+ const snapshot = this.snapshots.find((item) => item.label === this.tabs.getActiveId()) ?? this.snapshots[0];
78
+ this.config.secondary = snapshot.secondary;
79
+ this.tui.requestRender();
80
+ }
81
+
82
+ render(width: number): string[] {
83
+ return this.panel.render(width);
84
+ }
85
+
86
+ invalidate(): void {
87
+ this.panel.invalidate();
88
+ }
89
+ }
90
+
91
+ class SavingsBody implements Component {
92
+ private readonly theme: Theme;
93
+ private readonly snapshot: ReadSavingsSnapshot;
94
+
95
+ constructor(theme: Theme, snapshot: ReadSavingsSnapshot) {
96
+ this.theme = theme;
97
+ this.snapshot = snapshot;
98
+ }
99
+
100
+ render(width: number): string[] {
101
+ const avoided = Math.max(0, this.snapshot.baselineTokens - this.snapshot.returnedTokens);
102
+ const reduction = this.snapshot.baselineTokens > 0 ? avoided / this.snapshot.baselineTokens : 0;
103
+ return [
104
+ this.theme.bold("Tokens"),
105
+ metricRow("Without cache", formatTokens(this.snapshot.baselineTokens), width),
106
+ metricRow("With cache", formatTokens(this.snapshot.returnedTokens), width),
107
+ metricRow(
108
+ "Saved",
109
+ this.theme.fg("success", `${formatTokens(avoided)} tokens (${Math.round(reduction * 100)}%)`),
110
+ width,
111
+ ),
112
+ "",
113
+ this.theme.bold("Cost saved"),
114
+ metricRow("Total", this.theme.fg("success", this.theme.bold(formatCost(this.snapshot.costSaved))), width),
115
+ metricRow("Unchanged", formatCost(this.snapshot.unchangedCost), width),
116
+ metricRow("Changes only", formatCost(this.snapshot.diffCost), width),
117
+ "",
118
+ this.theme.bold("Read results"),
119
+ countRow("Full", this.snapshot.counts.baseline, "Unchanged", this.snapshot.counts.unchanged, width),
120
+ countRow("Changes", this.snapshot.counts.diff, "Recovery", this.snapshot.counts.recovery, width),
121
+ "",
122
+ truncateToWidth(
123
+ this.theme.fg("dim", "Estimates include repeated chat history and prompt caching."),
124
+ width,
125
+ "",
126
+ ),
127
+ ];
128
+ }
129
+
130
+ invalidate(): void {}
131
+ }
132
+
133
+ function formatTokens(value: number): string {
134
+ if (value >= 1_000_000) return `~${(value / 1_000_000).toFixed(2)}M`;
135
+ if (value >= 1_000) return `~${Math.round(value / 1_000)}k`;
136
+ return `~${Math.max(0, Math.round(value))}`;
137
+ }
138
+
139
+ function formatCost(value: number): string {
140
+ if (value > 0 && value < 0.01) return "<$0.01";
141
+ return `~$${Math.max(0, value).toFixed(2)}`;
142
+ }
143
+
144
+ function metricRow(label: string, value: string, width: number): string {
145
+ return truncateToWidth(`${label.padEnd(16)}${value}`, width, "");
146
+ }
147
+
148
+ function countRow(left: string, leftCount: number, right: string, rightCount: number, width: number): string {
149
+ return truncateToWidth(
150
+ `${left.padEnd(11)}${String(leftCount).padEnd(6)}${right.padEnd(11)}${rightCount}`,
151
+ width,
152
+ "",
153
+ );
154
+ }
@@ -0,0 +1,120 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { readMetaFromMessage, type ReadCacheMode } from "./read-cache.ts";
3
+ import { createReadStatsPanel, type ReadSavingsSnapshot } from "./read-stats-panel.ts";
4
+
5
+ interface ContextTotals {
6
+ baseline: number;
7
+ returned: number;
8
+ unchangedSaved: number;
9
+ diffSaved: number;
10
+ }
11
+
12
+ export async function showReadStats(ctx: ExtensionCommandContext): Promise<void> {
13
+ const entries = ctx.sessionManager.getEntries();
14
+ const branchIds = new Set(ctx.sessionManager.getBranch().map((entry) => entry.id));
15
+ const current = calculateSnapshot(entries, branchIds, "Current chat");
16
+ const whole = calculateSnapshot(entries, undefined, "Whole session");
17
+ await ctx.ui.custom(
18
+ (tui, theme, keybindings, done) => createReadStatsPanel(tui, theme, keybindings, done, current, whole),
19
+ {
20
+ overlay: true,
21
+ overlayOptions: { anchor: "center", width: "54%", minWidth: 50, maxHeight: "85%", margin: 2 },
22
+ },
23
+ );
24
+ }
25
+
26
+ function calculateSnapshot(
27
+ entries: readonly unknown[],
28
+ includedIds: ReadonlySet<string> | undefined,
29
+ label: string,
30
+ ): ReadSavingsSnapshot {
31
+ const states = new Map<string, ContextTotals>();
32
+ const counts: Record<ReadCacheMode, number> = { baseline: 0, recovery: 0, unchanged: 0, diff: 0 };
33
+ let baselineTokens = 0;
34
+ let returnedTokens = 0;
35
+ let costSaved = 0;
36
+ let unchangedCost = 0;
37
+ let diffCost = 0;
38
+ let readCount = 0;
39
+
40
+ for (const entry of entries) {
41
+ if (!isRecord(entry) || typeof entry.id !== "string") continue;
42
+ const parent = typeof entry.parentId === "string" ? states.get(entry.parentId) : undefined;
43
+ const state: ContextTotals = parent
44
+ ? { ...parent }
45
+ : { baseline: 0, returned: 0, unchangedSaved: 0, diffSaved: 0 };
46
+ if (entry.type === "compaction") {
47
+ state.baseline = 0;
48
+ state.returned = 0;
49
+ state.unchangedSaved = 0;
50
+ state.diffSaved = 0;
51
+ }
52
+
53
+ if (entry.type === "message" && "message" in entry) {
54
+ const meta = readMetaFromMessage(entry.message);
55
+ if (meta) {
56
+ state.baseline += meta.baselineTokens;
57
+ state.returned += meta.returnedTokens;
58
+ const saved = Math.max(0, meta.baselineTokens - meta.returnedTokens);
59
+ if (meta.mode === "unchanged") state.unchangedSaved += saved;
60
+ if (meta.mode === "diff") state.diffSaved += saved;
61
+ if (!includedIds || includedIds.has(entry.id)) {
62
+ counts[meta.mode] += 1;
63
+ readCount += 1;
64
+ }
65
+ }
66
+
67
+ if ((!includedIds || includedIds.has(entry.id)) && isAssistantMessage(entry.message)) {
68
+ baselineTokens += state.baseline;
69
+ returnedTokens += state.returned;
70
+ const inputTokens =
71
+ entry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite;
72
+ const inputCost =
73
+ entry.message.usage.cost.input +
74
+ entry.message.usage.cost.cacheRead +
75
+ entry.message.usage.cost.cacheWrite;
76
+ const costPerToken = inputTokens > 0 ? inputCost / inputTokens : 0;
77
+ unchangedCost += state.unchangedSaved * costPerToken;
78
+ diffCost += state.diffSaved * costPerToken;
79
+ costSaved += (state.baseline - state.returned) * costPerToken;
80
+ }
81
+ }
82
+ states.set(entry.id, state);
83
+ }
84
+
85
+ return {
86
+ label,
87
+ secondary: `${readCount} read${readCount === 1 ? "" : "s"} ${label === "Current chat" ? "in this chat" : "across everything done in this session"}`,
88
+ baselineTokens,
89
+ returnedTokens,
90
+ costSaved,
91
+ unchangedCost,
92
+ diffCost,
93
+ counts,
94
+ };
95
+ }
96
+
97
+ function isAssistantMessage(value: unknown): value is {
98
+ role: "assistant";
99
+ usage: {
100
+ input: number;
101
+ cacheRead: number;
102
+ cacheWrite: number;
103
+ cost: { input: number; cacheRead: number; cacheWrite: number };
104
+ };
105
+ } {
106
+ if (!isRecord(value) || value.role !== "assistant" || !isRecord(value.usage)) return false;
107
+ if (!isRecord(value.usage.cost)) return false;
108
+ return (
109
+ typeof value.usage.input === "number" &&
110
+ typeof value.usage.cacheRead === "number" &&
111
+ typeof value.usage.cacheWrite === "number" &&
112
+ typeof value.usage.cost.input === "number" &&
113
+ typeof value.usage.cost.cacheRead === "number" &&
114
+ typeof value.usage.cost.cacheWrite === "number"
115
+ );
116
+ }
117
+
118
+ function isRecord(value: unknown): value is Record<string, unknown> {
119
+ return typeof value === "object" && value !== null;
120
+ }