@mbeato/contextscope 0.1.2 → 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 (63) 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 +4 -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]__093.c8l._.js +1 -1
  31. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0el6bvo._.js +1 -1
  32. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0j40w4k._.js +3 -0
  33. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lbywin._.js +3 -0
  34. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p2sxww._.js +3 -0
  35. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ta~v~j._.js +1 -1
  36. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0wj0exw._.js +3 -0
  37. package/.next/standalone/.next/server/chunks/ssr/_0j0avc7._.js +3 -0
  38. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  39. package/.next/standalone/.next/server/pages/404.html +1 -1
  40. package/.next/standalone/.next/server/pages/500.html +1 -1
  41. package/.next/standalone/app/page.tsx +8 -2
  42. package/.next/standalone/app/sessions/page.tsx +18 -4
  43. package/.next/standalone/bin/cli.js +4 -0
  44. package/.next/standalone/lib/files.ts +8 -2
  45. package/.next/standalone/lib/hooks.ts +13 -10
  46. package/.next/standalone/lib/inventory.ts +18 -4
  47. package/.next/standalone/lib/model-prices.json +147 -0
  48. package/.next/standalone/lib/pricing.ts +80 -0
  49. package/.next/standalone/lib/sessions.ts +33 -6
  50. package/.next/standalone/lib/transcripts.ts +143 -32
  51. package/.next/standalone/package.json +2 -1
  52. package/.next/standalone/scripts/refresh-prices.mjs +58 -0
  53. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  54. package/.next/static/chunks/118uk9v3812u1.css +1 -0
  55. package/bin/cli.js +4 -0
  56. package/package.json +2 -1
  57. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0duau-w._.js +0 -3
  58. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ubqc9u._.js +0 -3
  59. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0xin6g6._.js +0 -3
  60. package/.next/static/chunks/13twdc6z5jomc.css +0 -1
  61. /package/.next/static/{yFvhyffva7D2ZI82gW6Fd → kaNzMZNco9qjeyEFA-gjr}/_buildManifest.js +0 -0
  62. /package/.next/static/{yFvhyffva7D2ZI82gW6Fd → kaNzMZNco9qjeyEFA-gjr}/_clientMiddlewareManifest.js +0 -0
  63. /package/.next/static/{yFvhyffva7D2ZI82gW6Fd → 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,19 +53,24 @@ 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
72
  const cache = new Map<string, TranscriptResult>();
73
+ const MAX_CACHE_ENTRIES = 5000;
44
74
 
45
75
  function inferProjectPath(dirName: string): string {
46
76
  return dirName.replace(/^-/, "/").replaceAll("-", "/");
@@ -64,12 +94,13 @@ async function parseFile(filePath: string, mtimeMs: number): Promise<TranscriptR
64
94
  cacheReadTokens: 0,
65
95
  cacheCreationTokens: 0,
66
96
  outputTokens: 0,
97
+ byModel: {},
98
+ usageRecords: [],
67
99
  invocations: [],
68
100
  toolCalls: {},
69
101
  toolErrors: 0,
70
102
  sidechainTurns: 0,
71
103
  };
72
- const modelSet = new Set<string>();
73
104
 
74
105
  return new Promise((resolve) => {
75
106
  const rl = createInterface({
@@ -78,7 +109,6 @@ async function parseFile(filePath: string, mtimeMs: number): Promise<TranscriptR
78
109
  });
79
110
  rl.on("line", (line) => {
80
111
  if (!line || line[0] !== "{") return;
81
- // Prefilter: usage events, tool_use events, and tool_result events (for errors).
82
112
  const hasUsage = line.includes('"usage"');
83
113
  const hasToolUse = line.includes('"tool_use"');
84
114
  const hasToolResult = line.includes('"tool_result"');
@@ -91,29 +121,49 @@ async function parseFile(filePath: string, mtimeMs: number): Promise<TranscriptR
91
121
  return;
92
122
  }
93
123
  const msg = rec.message as
94
- | { model?: string; usage?: Record<string, unknown>; content?: unknown }
124
+ | { id?: string; model?: string; usage?: Record<string, unknown>; content?: unknown }
95
125
  | undefined;
96
126
  const tsRaw = rec.timestamp;
97
127
  const tsMs = typeof tsRaw === "string" ? Date.parse(tsRaw) : NaN;
98
128
  const isSidechain = rec.isSidechain === true;
99
129
 
100
- // Usage aggregation (assistant messages)
101
130
  const usage = msg?.usage;
102
131
  if (usage) {
103
- result.inputTokens += Number(usage.input_tokens) || 0;
104
- result.cacheReadTokens += Number(usage.cache_read_input_tokens) || 0;
105
- result.cacheCreationTokens += Number(usage.cache_creation_input_tokens) || 0;
106
- result.outputTokens += Number(usage.output_tokens) || 0;
107
- result.turnCount += 1;
108
- if (isSidechain) result.sidechainTurns += 1;
109
- if (msg?.model) modelSet.add(msg.model);
110
- if (Number.isFinite(tsMs)) {
111
- if (!result.startTime || tsMs < result.startTime) result.startTime = tsMs;
112
- 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;
113
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
+ });
114
165
  }
115
166
 
116
- // Content scan: tool_use (counts + invocations) and tool_result (errors)
117
167
  if (Array.isArray(msg?.content)) {
118
168
  for (const c of msg.content) {
119
169
  if (!c || typeof c !== "object") continue;
@@ -143,14 +193,8 @@ async function parseFile(filePath: string, mtimeMs: number): Promise<TranscriptR
143
193
  }
144
194
  }
145
195
  });
146
- rl.on("close", () => {
147
- result.models = [...modelSet];
148
- resolve(result);
149
- });
150
- rl.on("error", () => {
151
- result.models = [...modelSet];
152
- resolve(result);
153
- });
196
+ rl.on("close", () => resolve(result));
197
+ rl.on("error", () => resolve(result));
154
198
  });
155
199
  }
156
200
 
@@ -158,7 +202,13 @@ async function getFileResult(filePath: string, mtimeMs: number): Promise<Transcr
158
202
  const cached = cache.get(filePath);
159
203
  if (cached && cached.mtimeMs === mtimeMs) return cached;
160
204
  const fresh = await parseFile(filePath, mtimeMs);
205
+ if (cached) cache.delete(filePath);
161
206
  cache.set(filePath, fresh);
207
+ while (cache.size > MAX_CACHE_ENTRIES) {
208
+ const oldest = cache.keys().next().value;
209
+ if (oldest === undefined) break;
210
+ cache.delete(oldest);
211
+ }
162
212
  return fresh;
163
213
  }
164
214
 
@@ -176,7 +226,67 @@ async function pMapLimit<T, R>(items: T[], limit: number, fn: (x: T) => Promise<
176
226
  return out;
177
227
  }
178
228
 
179
- /** 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. */
180
290
  export async function getAllTranscripts(daysBack: number = 30): Promise<TranscriptResult[]> {
181
291
  const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1000;
182
292
  let projDirs: import("node:fs").Dirent[];
@@ -208,5 +318,6 @@ export async function getAllTranscripts(daysBack: number = 30): Promise<Transcri
208
318
  }
209
319
  })
210
320
  );
211
- 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);
212
323
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mbeato/contextscope",
3
- "version": "0.1.2",
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}`);