@mbeato/contextscope 0.1.3 → 0.1.4

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.
Files changed (55) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  4. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  11. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.rsc +3 -3
  13. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  19. package/.next/standalone/.next/server/app/context/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/items/page.js +3 -2
  21. package/.next/standalone/.next/server/app/items/page.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/items/page_client-reference-manifest.js +1 -1
  23. package/.next/standalone/.next/server/app/page.js +3 -2
  24. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  26. package/.next/standalone/.next/server/app/sessions/page.js +3 -2
  27. package/.next/standalone/.next/server/app/sessions/page.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  29. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08l_yt3._.js +3 -0
  30. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0j40w4k._.js +3 -0
  31. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lbywin._.js +3 -0
  32. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p2sxww._.js +3 -0
  33. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0wj0exw._.js +3 -0
  34. package/.next/standalone/.next/server/chunks/ssr/_0j0avc7._.js +1 -1
  35. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  36. package/.next/standalone/.next/server/pages/404.html +1 -1
  37. package/.next/standalone/.next/server/pages/500.html +1 -1
  38. package/.next/standalone/app/page.tsx +8 -2
  39. package/.next/standalone/app/sessions/page.tsx +10 -2
  40. package/.next/standalone/lib/model-prices.json +147 -0
  41. package/.next/standalone/lib/pricing.ts +80 -0
  42. package/.next/standalone/lib/sessions.ts +33 -6
  43. package/.next/standalone/lib/transcripts.ts +137 -35
  44. package/.next/standalone/package.json +2 -1
  45. package/.next/standalone/scripts/refresh-prices.mjs +58 -0
  46. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  47. package/.next/static/chunks/118uk9v3812u1.css +1 -0
  48. package/package.json +2 -1
  49. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08jw6yr._.js +0 -3
  50. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0duau-w._.js +0 -3
  51. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ubqc9u._.js +0 -3
  52. package/.next/static/chunks/13525lf.7uo9s.css +0 -1
  53. /package/.next/static/{BT6H4g8OlEYi9snYU0PI- → kaNzMZNco9qjeyEFA-gjr}/_buildManifest.js +0 -0
  54. /package/.next/static/{BT6H4g8OlEYi9snYU0PI- → kaNzMZNco9qjeyEFA-gjr}/_clientMiddlewareManifest.js +0 -0
  55. /package/.next/static/{BT6H4g8OlEYi9snYU0PI- → kaNzMZNco9qjeyEFA-gjr}/_ssgManifest.js +0 -0
@@ -0,0 +1,147 @@
1
+ {
2
+ "_source": "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
3
+ "_refreshedAt": "2026-05-27T17:24:45.345Z",
4
+ "_description": "Anthropic model prices in USD per token (input, output, cache_read, cache_creation 5min default). Refresh with `npm run refresh-prices`.",
5
+ "models": {
6
+ "claude-3-7-sonnet-20250219": {
7
+ "input": 0.000003,
8
+ "output": 0.000015,
9
+ "cache_read": 3e-7,
10
+ "cache_creation_5m": 0.00000375,
11
+ "cache_creation_1h": 0.000006
12
+ },
13
+ "claude-3-haiku-20240307": {
14
+ "input": 2.5e-7,
15
+ "output": 0.00000125,
16
+ "cache_read": 3e-8,
17
+ "cache_creation_5m": 3e-7,
18
+ "cache_creation_1h": 0.000006
19
+ },
20
+ "claude-3-opus-20240229": {
21
+ "input": 0.000015,
22
+ "output": 0.000075,
23
+ "cache_read": 0.0000015,
24
+ "cache_creation_5m": 0.00001875,
25
+ "cache_creation_1h": 0.000006
26
+ },
27
+ "claude-4-opus-20250514": {
28
+ "input": 0.000015,
29
+ "output": 0.000075,
30
+ "cache_read": 0.0000015,
31
+ "cache_creation_5m": 0.00001875,
32
+ "cache_creation_1h": 0.0000375
33
+ },
34
+ "claude-4-sonnet-20250514": {
35
+ "input": 0.000003,
36
+ "output": 0.000015,
37
+ "cache_read": 3e-7,
38
+ "cache_creation_5m": 0.00000375,
39
+ "cache_creation_1h": 0.0000075
40
+ },
41
+ "claude-haiku-4-5": {
42
+ "input": 0.000001,
43
+ "output": 0.000005,
44
+ "cache_read": 1e-7,
45
+ "cache_creation_5m": 0.00000125,
46
+ "cache_creation_1h": 0.000002
47
+ },
48
+ "claude-haiku-4-5-20251001": {
49
+ "input": 0.000001,
50
+ "output": 0.000005,
51
+ "cache_read": 1e-7,
52
+ "cache_creation_5m": 0.00000125,
53
+ "cache_creation_1h": 0.000002
54
+ },
55
+ "claude-opus-4-1": {
56
+ "input": 0.000015,
57
+ "output": 0.000075,
58
+ "cache_read": 0.0000015,
59
+ "cache_creation_5m": 0.00001875,
60
+ "cache_creation_1h": 0.00003
61
+ },
62
+ "claude-opus-4-1-20250805": {
63
+ "input": 0.000015,
64
+ "output": 0.000075,
65
+ "cache_read": 0.0000015,
66
+ "cache_creation_5m": 0.00001875,
67
+ "cache_creation_1h": 0.00003
68
+ },
69
+ "claude-opus-4-20250514": {
70
+ "input": 0.000015,
71
+ "output": 0.000075,
72
+ "cache_read": 0.0000015,
73
+ "cache_creation_5m": 0.00001875,
74
+ "cache_creation_1h": 0.00003
75
+ },
76
+ "claude-opus-4-5": {
77
+ "input": 0.000005,
78
+ "output": 0.000025,
79
+ "cache_read": 5e-7,
80
+ "cache_creation_5m": 0.00000625,
81
+ "cache_creation_1h": 0.00001
82
+ },
83
+ "claude-opus-4-5-20251101": {
84
+ "input": 0.000005,
85
+ "output": 0.000025,
86
+ "cache_read": 5e-7,
87
+ "cache_creation_5m": 0.00000625,
88
+ "cache_creation_1h": 0.00001
89
+ },
90
+ "claude-opus-4-6": {
91
+ "input": 0.000005,
92
+ "output": 0.000025,
93
+ "cache_read": 5e-7,
94
+ "cache_creation_5m": 0.00000625,
95
+ "cache_creation_1h": 0.00001
96
+ },
97
+ "claude-opus-4-6-20260205": {
98
+ "input": 0.000005,
99
+ "output": 0.000025,
100
+ "cache_read": 5e-7,
101
+ "cache_creation_5m": 0.00000625,
102
+ "cache_creation_1h": 0.00001
103
+ },
104
+ "claude-opus-4-7": {
105
+ "input": 0.000005,
106
+ "output": 0.000025,
107
+ "cache_read": 5e-7,
108
+ "cache_creation_5m": 0.00000625,
109
+ "cache_creation_1h": 0.00001
110
+ },
111
+ "claude-opus-4-7-20260416": {
112
+ "input": 0.000005,
113
+ "output": 0.000025,
114
+ "cache_read": 5e-7,
115
+ "cache_creation_5m": 0.00000625,
116
+ "cache_creation_1h": 0.00001
117
+ },
118
+ "claude-sonnet-4-20250514": {
119
+ "input": 0.000003,
120
+ "output": 0.000015,
121
+ "cache_read": 3e-7,
122
+ "cache_creation_5m": 0.00000375,
123
+ "cache_creation_1h": 0.000006
124
+ },
125
+ "claude-sonnet-4-5": {
126
+ "input": 0.000003,
127
+ "output": 0.000015,
128
+ "cache_read": 3e-7,
129
+ "cache_creation_5m": 0.00000375,
130
+ "cache_creation_1h": 0.0000075
131
+ },
132
+ "claude-sonnet-4-5-20250929": {
133
+ "input": 0.000003,
134
+ "output": 0.000015,
135
+ "cache_read": 3e-7,
136
+ "cache_creation_5m": 0.00000375,
137
+ "cache_creation_1h": 0.0000075
138
+ },
139
+ "claude-sonnet-4-6": {
140
+ "input": 0.000003,
141
+ "output": 0.000015,
142
+ "cache_read": 3e-7,
143
+ "cache_creation_5m": 0.00000375,
144
+ "cache_creation_1h": 0.0000075
145
+ }
146
+ }
147
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Per-message API cost calculator.
3
+ *
4
+ * Prices come from lib/model-prices.json (refreshed from LiteLLM — see
5
+ * scripts/refresh-prices.mjs). They're Anthropic's PUBLIC API rates — the
6
+ * "you'd pay this on the API" number, not what CC subscribers actually pay.
7
+ *
8
+ * Cache-creation cost uses the 5-minute (default) tier. CC sometimes uses the
9
+ * 1-hour tier; transcripts don't tell us which, so we use the more conservative
10
+ * lower price (5min). Real cost may be marginally higher.
11
+ *
12
+ * Unknown models log once and return 0 — never silently fudge.
13
+ */
14
+ import prices from "./model-prices.json";
15
+
16
+ type ModelPrice = {
17
+ input: number;
18
+ output: number;
19
+ cache_read: number;
20
+ cache_creation_5m: number;
21
+ cache_creation_1h: number;
22
+ };
23
+
24
+ const MODELS = prices.models as Record<string, ModelPrice>;
25
+
26
+ // Aliases for short model names that appear in some CC transcripts.
27
+ // Map each to the latest released model of that tier.
28
+ const ALIASES: Record<string, string> = {
29
+ opus: "claude-opus-4-7",
30
+ sonnet: "claude-sonnet-4-6",
31
+ haiku: "claude-haiku-4-5",
32
+ };
33
+
34
+ const warned = new Set<string>();
35
+
36
+ export type Usage = {
37
+ input: number;
38
+ output: number;
39
+ cacheRead: number;
40
+ cacheCreation5m: number;
41
+ cacheCreation1h: number;
42
+ };
43
+
44
+ function resolveModel(model: string): ModelPrice | null {
45
+ if (!model || model === "<synthetic>") return null;
46
+ if (MODELS[model]) return MODELS[model];
47
+ if (ALIASES[model] && MODELS[ALIASES[model]]) return MODELS[ALIASES[model]];
48
+ // Strip [variant] suffix (e.g. claude-opus-4-7[1m] → claude-opus-4-7)
49
+ const noSuffix = model.replace(/\[[^\]]+\]$/, "");
50
+ if (noSuffix !== model && MODELS[noSuffix]) return MODELS[noSuffix];
51
+ // Strip date suffix (-YYYYMMDD) and retry
52
+ const noDate = noSuffix.replace(/-\d{8}$/, "");
53
+ if (MODELS[noDate]) return MODELS[noDate];
54
+ if (!warned.has(model)) {
55
+ warned.add(model);
56
+ console.warn(`[contextscope] no price found for model "${model}" — treating as $0. Add to lib/model-prices.json or alias.`);
57
+ }
58
+ return null;
59
+ }
60
+
61
+ export function costForUsage(model: string, u: Usage): number {
62
+ const p = resolveModel(model);
63
+ if (!p) return 0;
64
+ return (
65
+ u.input * p.input +
66
+ u.output * p.output +
67
+ u.cacheRead * p.cache_read +
68
+ u.cacheCreation5m * p.cache_creation_5m +
69
+ u.cacheCreation1h * p.cache_creation_1h
70
+ );
71
+ }
72
+
73
+ export function formatUsd(n: number): string {
74
+ if (n >= 100) return `$${n.toFixed(0)}`;
75
+ if (n >= 10) return `$${n.toFixed(1)}`;
76
+ if (n >= 1) return `$${n.toFixed(2)}`;
77
+ if (n >= 0.01) return `$${n.toFixed(2)}`;
78
+ if (n > 0) return `<$0.01`;
79
+ return `$0`;
80
+ }
@@ -1,4 +1,5 @@
1
1
  import { getAllTranscripts, type TranscriptResult } from "./transcripts";
2
+ import { costForUsage } from "./pricing";
2
3
 
3
4
  export type Session = {
4
5
  sessionId: string;
@@ -14,11 +15,26 @@ export type Session = {
14
15
  cacheCreationTokens: number;
15
16
  outputTokens: number;
16
17
  totalTokens: number;
18
+ costUsd: number;
17
19
  toolCalls: Record<string, number>;
18
20
  toolErrors: number;
19
21
  sidechainTurns: number;
20
22
  };
21
23
 
24
+ function computeCost(t: TranscriptResult): number {
25
+ let cost = 0;
26
+ for (const [model, u] of Object.entries(t.byModel)) {
27
+ cost += costForUsage(model, {
28
+ input: u.inputTokens,
29
+ output: u.outputTokens,
30
+ cacheRead: u.cacheReadTokens,
31
+ cacheCreation5m: u.cacheCreation5mTokens,
32
+ cacheCreation1h: u.cacheCreation1hTokens,
33
+ });
34
+ }
35
+ return cost;
36
+ }
37
+
22
38
  function toSession(t: TranscriptResult): Session {
23
39
  const totalTokens =
24
40
  t.inputTokens + t.cacheReadTokens + t.cacheCreationTokens + t.outputTokens;
@@ -36,6 +52,7 @@ function toSession(t: TranscriptResult): Session {
36
52
  cacheCreationTokens: t.cacheCreationTokens,
37
53
  outputTokens: t.outputTokens,
38
54
  totalTokens,
55
+ costUsd: computeCost(t),
39
56
  toolCalls: t.toolCalls,
40
57
  toolErrors: t.toolErrors,
41
58
  sidechainTurns: t.sidechainTurns,
@@ -55,14 +72,15 @@ export type SessionsSummary = {
55
72
  totalTokens: number;
56
73
  totalOutputTokens: number;
57
74
  totalInputPlusCache: number;
75
+ totalCostUsd: number;
58
76
  cacheHitRatio: number;
59
77
  outputInputRatio: number;
60
78
  averageSessionTokens: number;
61
79
  medianSessionTokens: number;
62
80
  p95SessionTokens: number;
63
81
  longSessions: Session[];
64
- dailyBurn: { date: string; tokens: number }[];
65
- byProject: { project: string; projectPath: string; count: number; tokens: number; turns: number }[];
82
+ dailyBurn: { date: string; tokens: number; cost: number }[];
83
+ byProject: { project: string; projectPath: string; count: number; tokens: number; cost: number; turns: number }[];
66
84
  totalToolCalls: Record<string, number>;
67
85
  totalToolErrors: number;
68
86
  totalSidechainTurns: number;
@@ -78,6 +96,7 @@ export function summarizeSessions(sessions: Session[]): SessionsSummary {
78
96
  totalTokens: 0,
79
97
  totalOutputTokens: 0,
80
98
  totalInputPlusCache: 0,
99
+ totalCostUsd: 0,
81
100
  cacheHitRatio: 0,
82
101
  outputInputRatio: 0,
83
102
  averageSessionTokens: 0,
@@ -110,7 +129,7 @@ export function summarizeSessions(sessions: Session[]): SessionsSummary {
110
129
 
111
130
  const longSessions = sessions.filter((s) => s.totalTokens >= LONG_SESSION_THRESHOLD);
112
131
 
113
- const byDay = new Map<string, number>();
132
+ const byDay = new Map<string, { tokens: number; cost: number }>();
114
133
  for (const s of sessions) {
115
134
  const ts = s.endTime || s.startTime;
116
135
  if (!ts) continue;
@@ -118,27 +137,34 @@ export function summarizeSessions(sessions: Session[]): SessionsSummary {
118
137
  const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(
119
138
  d.getUTCDate()
120
139
  ).padStart(2, "0")}`;
121
- byDay.set(key, (byDay.get(key) ?? 0) + s.totalTokens);
140
+ const cur = byDay.get(key) ?? { tokens: 0, cost: 0 };
141
+ cur.tokens += s.totalTokens;
142
+ cur.cost += s.costUsd;
143
+ byDay.set(key, cur);
122
144
  }
123
145
  const dailyBurn = [...byDay.entries()]
124
- .map(([date, tokens]) => ({ date, tokens }))
146
+ .map(([date, v]) => ({ date, tokens: v.tokens, cost: v.cost }))
125
147
  .sort((a, b) => a.date.localeCompare(b.date));
126
148
 
127
149
  // By project
128
150
  const projectMap = new Map<
129
151
  string,
130
- { project: string; projectPath: string; count: number; tokens: number; turns: number }
152
+ { project: string; projectPath: string; count: number; tokens: number; cost: number; turns: number }
131
153
  >();
154
+ let totalCostUsd = 0;
132
155
  for (const s of sessions) {
156
+ totalCostUsd += s.costUsd;
133
157
  const cur = projectMap.get(s.project) ?? {
134
158
  project: s.project,
135
159
  projectPath: s.projectPath,
136
160
  count: 0,
137
161
  tokens: 0,
162
+ cost: 0,
138
163
  turns: 0,
139
164
  };
140
165
  cur.count += 1;
141
166
  cur.tokens += s.totalTokens;
167
+ cur.cost += s.costUsd;
142
168
  cur.turns += s.turnCount;
143
169
  projectMap.set(s.project, cur);
144
170
  }
@@ -163,6 +189,7 @@ export function summarizeSessions(sessions: Session[]): SessionsSummary {
163
189
  totalTokens: total,
164
190
  totalOutputTokens: outSum,
165
191
  totalInputPlusCache: inputPlusCache,
192
+ totalCostUsd,
166
193
  cacheHitRatio,
167
194
  outputInputRatio,
168
195
  averageSessionTokens: Math.round(total / sessions.length),
@@ -6,7 +6,10 @@
6
6
  * - invocations (Skill / Agent tool_use events) -> consumed by lib/usage.ts
7
7
  * - session stats (turn count, token usage) -> consumed by lib/sessions.ts
8
8
  *
9
- * Subsequent page loads reuse cached results when mtime is unchanged.
9
+ * CC creates a new JSONL when you `--continue` a session, which replays the
10
+ * prior turns. To match ccusage's totals we dedup messages by `msg.id:requestId`
11
+ * across all files at aggregate time. Per-file records are cached unchanged
12
+ * (preserves mtime cache); dedupAndSum runs on every getAllTranscripts call.
10
13
  */
11
14
  import { readdir, stat } from "node:fs/promises";
12
15
  import { createReadStream } from "node:fs";
@@ -18,6 +21,28 @@ const PROJECTS_DIR = join(homedir(), ".claude", "projects");
18
21
 
19
22
  export type Invocation = { kind: "skill" | "agent"; name: string; ts: number };
20
23
 
24
+ export type ModelUsage = {
25
+ inputTokens: number;
26
+ cacheReadTokens: number;
27
+ cacheCreation5mTokens: number;
28
+ cacheCreation1hTokens: number;
29
+ outputTokens: number;
30
+ };
31
+
32
+ export type UsageRecord = {
33
+ // `${message.id}:${requestId}` — same key ccusage uses. Empty string if msg
34
+ // lacked an id (treat as un-dedupable; count it).
35
+ dedupKey: string;
36
+ model: string;
37
+ ts: number;
38
+ input: number;
39
+ cacheRead: number;
40
+ cacheCreation5m: number;
41
+ cacheCreation1h: number;
42
+ output: number;
43
+ isSidechain: boolean;
44
+ };
45
+
21
46
  export type TranscriptResult = {
22
47
  filePath: string;
23
48
  mtimeMs: number;
@@ -28,20 +53,22 @@ export type TranscriptResult = {
28
53
  endTime: number;
29
54
  turnCount: number;
30
55
  models: string[];
56
+ // Aggregate totals — sums after dedup (when produced via getAllTranscripts).
57
+ // On raw parseFile output these are pre-dedup; getAllTranscripts rebuilds them.
31
58
  inputTokens: number;
32
59
  cacheReadTokens: number;
33
- cacheCreationTokens: number;
60
+ cacheCreationTokens: number; // total: 5m + 1h
34
61
  outputTokens: number;
62
+ byModel: Record<string, ModelUsage>;
63
+ // Per-message records — kept on the cached result so dedup at aggregate time
64
+ // is exact across resumed sessions.
65
+ usageRecords: UsageRecord[];
35
66
  invocations: Invocation[];
36
- toolCalls: Record<string, number>; // tool name -> count
37
- toolErrors: number; // total tool_result with is_error: true
38
- sidechainTurns: number; // turns with isSidechain: true (subagent context)
67
+ toolCalls: Record<string, number>;
68
+ toolErrors: number;
69
+ sidechainTurns: number;
39
70
  };
40
71
 
41
- // Module-level cache: persists across server-component renders within the same
42
- // Next.js dev/prod process. Keyed by filePath; invalidated on mtime change.
43
- // Capped via simple FIFO eviction (Map preserves insertion order) so a
44
- // long-running process scanning thousands of transcripts can't grow unbounded.
45
72
  const cache = new Map<string, TranscriptResult>();
46
73
  const MAX_CACHE_ENTRIES = 5000;
47
74
 
@@ -67,12 +94,13 @@ async function parseFile(filePath: string, mtimeMs: number): Promise<TranscriptR
67
94
  cacheReadTokens: 0,
68
95
  cacheCreationTokens: 0,
69
96
  outputTokens: 0,
97
+ byModel: {},
98
+ usageRecords: [],
70
99
  invocations: [],
71
100
  toolCalls: {},
72
101
  toolErrors: 0,
73
102
  sidechainTurns: 0,
74
103
  };
75
- const modelSet = new Set<string>();
76
104
 
77
105
  return new Promise((resolve) => {
78
106
  const rl = createInterface({
@@ -81,7 +109,6 @@ async function parseFile(filePath: string, mtimeMs: number): Promise<TranscriptR
81
109
  });
82
110
  rl.on("line", (line) => {
83
111
  if (!line || line[0] !== "{") return;
84
- // Prefilter: usage events, tool_use events, and tool_result events (for errors).
85
112
  const hasUsage = line.includes('"usage"');
86
113
  const hasToolUse = line.includes('"tool_use"');
87
114
  const hasToolResult = line.includes('"tool_result"');
@@ -94,29 +121,49 @@ async function parseFile(filePath: string, mtimeMs: number): Promise<TranscriptR
94
121
  return;
95
122
  }
96
123
  const msg = rec.message as
97
- | { model?: string; usage?: Record<string, unknown>; content?: unknown }
124
+ | { id?: string; model?: string; usage?: Record<string, unknown>; content?: unknown }
98
125
  | undefined;
99
126
  const tsRaw = rec.timestamp;
100
127
  const tsMs = typeof tsRaw === "string" ? Date.parse(tsRaw) : NaN;
101
128
  const isSidechain = rec.isSidechain === true;
102
129
 
103
- // Usage aggregation (assistant messages)
104
130
  const usage = msg?.usage;
105
131
  if (usage) {
106
- result.inputTokens += Number(usage.input_tokens) || 0;
107
- result.cacheReadTokens += Number(usage.cache_read_input_tokens) || 0;
108
- result.cacheCreationTokens += Number(usage.cache_creation_input_tokens) || 0;
109
- result.outputTokens += Number(usage.output_tokens) || 0;
110
- result.turnCount += 1;
111
- if (isSidechain) result.sidechainTurns += 1;
112
- if (msg?.model) modelSet.add(msg.model);
113
- if (Number.isFinite(tsMs)) {
114
- if (!result.startTime || tsMs < result.startTime) result.startTime = tsMs;
115
- if (tsMs > result.endTime) result.endTime = tsMs;
132
+ const msgId = typeof msg?.id === "string" ? msg.id : "";
133
+ const requestId = typeof rec.requestId === "string" ? rec.requestId : "";
134
+ const dedupKey = msgId ? `${msgId}:${requestId}` : "";
135
+ // cache_creation may have an ephemeral_{5m,1h}_input_tokens breakdown
136
+ // (priced separately at $6.25/M vs $10/M for opus-4-7). Older transcripts
137
+ // lack the sub-object — fall back to treating the total as 5min.
138
+ const ccTotal = Number(usage.cache_creation_input_tokens) || 0;
139
+ const ccBreakdown = (usage.cache_creation ?? null) as
140
+ | { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
141
+ | null;
142
+ let cc5m = 0;
143
+ let cc1h = 0;
144
+ if (ccBreakdown && typeof ccBreakdown === "object") {
145
+ cc5m = Number(ccBreakdown.ephemeral_5m_input_tokens) || 0;
146
+ cc1h = Number(ccBreakdown.ephemeral_1h_input_tokens) || 0;
147
+ // If breakdown is present but sum doesn't match the parent total,
148
+ // trust the parent and attribute the diff to 5min (conservative).
149
+ const diff = ccTotal - (cc5m + cc1h);
150
+ if (diff > 0) cc5m += diff;
151
+ } else {
152
+ cc5m = ccTotal;
116
153
  }
154
+ result.usageRecords.push({
155
+ dedupKey,
156
+ model: msg?.model || "<synthetic>",
157
+ ts: Number.isFinite(tsMs) ? tsMs : 0,
158
+ input: Number(usage.input_tokens) || 0,
159
+ cacheRead: Number(usage.cache_read_input_tokens) || 0,
160
+ cacheCreation5m: cc5m,
161
+ cacheCreation1h: cc1h,
162
+ output: Number(usage.output_tokens) || 0,
163
+ isSidechain,
164
+ });
117
165
  }
118
166
 
119
- // Content scan: tool_use (counts + invocations) and tool_result (errors)
120
167
  if (Array.isArray(msg?.content)) {
121
168
  for (const c of msg.content) {
122
169
  if (!c || typeof c !== "object") continue;
@@ -146,14 +193,8 @@ async function parseFile(filePath: string, mtimeMs: number): Promise<TranscriptR
146
193
  }
147
194
  }
148
195
  });
149
- rl.on("close", () => {
150
- result.models = [...modelSet];
151
- resolve(result);
152
- });
153
- rl.on("error", () => {
154
- result.models = [...modelSet];
155
- resolve(result);
156
- });
196
+ rl.on("close", () => resolve(result));
197
+ rl.on("error", () => resolve(result));
157
198
  });
158
199
  }
159
200
 
@@ -161,7 +202,7 @@ async function getFileResult(filePath: string, mtimeMs: number): Promise<Transcr
161
202
  const cached = cache.get(filePath);
162
203
  if (cached && cached.mtimeMs === mtimeMs) return cached;
163
204
  const fresh = await parseFile(filePath, mtimeMs);
164
- if (cached) cache.delete(filePath); // re-insert at tail so FIFO eviction stays correct
205
+ if (cached) cache.delete(filePath);
165
206
  cache.set(filePath, fresh);
166
207
  while (cache.size > MAX_CACHE_ENTRIES) {
167
208
  const oldest = cache.keys().next().value;
@@ -185,7 +226,67 @@ async function pMapLimit<T, R>(items: T[], limit: number, fn: (x: T) => Promise<
185
226
  return out;
186
227
  }
187
228
 
188
- /** Scan all transcripts modified in the last N days. Cached per (filePath, mtime). */
229
+ /**
230
+ * Walk raw per-file results oldest first; attribute each unique message UUID
231
+ * to the earliest file that mentions it. Returns new TranscriptResult objects
232
+ * with totals/byModel/timestamps recomputed from the surviving records.
233
+ *
234
+ * Important: do NOT mutate the cached `raws` objects — clone fields we rewrite.
235
+ */
236
+ function dedupAndSum(raws: TranscriptResult[]): TranscriptResult[] {
237
+ const sorted = [...raws].sort((a, b) => a.mtimeMs - b.mtimeMs);
238
+ const seen = new Set<string>();
239
+ return sorted.map((r) => {
240
+ const t: TranscriptResult = {
241
+ ...r,
242
+ startTime: 0,
243
+ endTime: 0,
244
+ turnCount: 0,
245
+ inputTokens: 0,
246
+ cacheReadTokens: 0,
247
+ cacheCreationTokens: 0,
248
+ outputTokens: 0,
249
+ byModel: {},
250
+ models: [],
251
+ sidechainTurns: 0,
252
+ };
253
+ const modelSet = new Set<string>();
254
+ for (const u of r.usageRecords) {
255
+ if (u.dedupKey) {
256
+ if (seen.has(u.dedupKey)) continue;
257
+ seen.add(u.dedupKey);
258
+ }
259
+ t.inputTokens += u.input;
260
+ t.cacheReadTokens += u.cacheRead;
261
+ t.cacheCreationTokens += u.cacheCreation5m + u.cacheCreation1h;
262
+ t.outputTokens += u.output;
263
+ t.turnCount += 1;
264
+ if (u.isSidechain) t.sidechainTurns += 1;
265
+ modelSet.add(u.model);
266
+ const bm = t.byModel[u.model] ?? {
267
+ inputTokens: 0,
268
+ cacheReadTokens: 0,
269
+ cacheCreation5mTokens: 0,
270
+ cacheCreation1hTokens: 0,
271
+ outputTokens: 0,
272
+ };
273
+ bm.inputTokens += u.input;
274
+ bm.cacheReadTokens += u.cacheRead;
275
+ bm.cacheCreation5mTokens += u.cacheCreation5m;
276
+ bm.cacheCreation1hTokens += u.cacheCreation1h;
277
+ bm.outputTokens += u.output;
278
+ t.byModel[u.model] = bm;
279
+ if (u.ts > 0) {
280
+ if (!t.startTime || u.ts < t.startTime) t.startTime = u.ts;
281
+ if (u.ts > t.endTime) t.endTime = u.ts;
282
+ }
283
+ }
284
+ t.models = [...modelSet];
285
+ return t;
286
+ });
287
+ }
288
+
289
+ /** Scan all transcripts modified in the last N days. Deduped across resumed sessions. */
189
290
  export async function getAllTranscripts(daysBack: number = 30): Promise<TranscriptResult[]> {
190
291
  const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1000;
191
292
  let projDirs: import("node:fs").Dirent[];
@@ -217,5 +318,6 @@ export async function getAllTranscripts(daysBack: number = 30): Promise<Transcri
217
318
  }
218
319
  })
219
320
  );
220
- return pMapLimit(candidates, 16, (c) => getFileResult(c.filePath, c.mtimeMs));
321
+ const raws = await pMapLimit(candidates, 16, (c) => getFileResult(c.filePath, c.mtimeMs));
322
+ return dedupAndSum(raws);
221
323
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mbeato/contextscope",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Local dashboard auditing Claude Code's per-turn token context (skills, agents, commands, CLAUDE.md, MEMORY.md, hooks, MCP) with toggle-based disable and session analytics.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  "start": "next start",
13
13
  "prod": "next build && next start",
14
14
  "lint": "eslint",
15
+ "refresh-prices": "node scripts/refresh-prices.mjs",
15
16
  "prepublishOnly": "next build",
16
17
  "release": "test -f .env.publish || (echo 'missing .env.publish — copy .env.publish.example and add your NPM_TOKEN' && exit 1) && set -a && . ./.env.publish && set +a && npm publish"
17
18
  },
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Refresh lib/model-prices.json from LiteLLM's authoritative price list.
4
+ *
5
+ * Pulls https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json,
6
+ * keeps only Anthropic models with the cost fields we use, and writes a pruned
7
+ * JSON to lib/model-prices.json. Run before each contextscope release so npm
8
+ * users get current prices.
9
+ *
10
+ * Usage: node scripts/refresh-prices.mjs
11
+ */
12
+ import { writeFile } from "node:fs/promises";
13
+ import { fileURLToPath } from "node:url";
14
+ import { dirname, join } from "node:path";
15
+
16
+ const SRC = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
17
+ const HERE = dirname(fileURLToPath(import.meta.url));
18
+ const DST = join(HERE, "..", "lib", "model-prices.json");
19
+
20
+ const res = await fetch(SRC);
21
+ if (!res.ok) {
22
+ console.error(`fetch failed: ${res.status} ${res.statusText}`);
23
+ process.exit(1);
24
+ }
25
+ const all = await res.json();
26
+
27
+ const pruned = {};
28
+ for (const [name, m] of Object.entries(all)) {
29
+ if (m?.litellm_provider !== "anthropic") continue;
30
+ if (typeof m.input_cost_per_token !== "number") continue;
31
+ const cc5m = m.cache_creation_input_token_cost ?? 0;
32
+ pruned[name] = {
33
+ input: m.input_cost_per_token,
34
+ output: m.output_cost_per_token ?? 0,
35
+ cache_read: m.cache_read_input_token_cost ?? 0,
36
+ cache_creation_5m: cc5m,
37
+ // Anthropic's documented 1hr cache rate is 2x the 5min rate.
38
+ // Fall back to 2x when LiteLLM doesn't list it explicitly.
39
+ cache_creation_1h: m.cache_creation_input_token_cost_above_1hr ?? cc5m * 2,
40
+ };
41
+ }
42
+
43
+ const sorted = Object.fromEntries(Object.entries(pruned).sort(([a], [b]) => a.localeCompare(b)));
44
+ await writeFile(
45
+ DST,
46
+ JSON.stringify(
47
+ {
48
+ _source: SRC,
49
+ _refreshedAt: new Date().toISOString(),
50
+ _description: "Anthropic model prices in USD per token (input, output, cache_read, cache_creation 5min default). Refresh with `npm run refresh-prices`.",
51
+ models: sorted,
52
+ },
53
+ null,
54
+ 2
55
+ ) + "\n",
56
+ "utf8"
57
+ );
58
+ console.log(`wrote ${Object.keys(sorted).length} anthropic models → ${DST}`);