@mbeato/contextscope 0.1.5 → 0.1.6

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 (42) 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_client-reference-manifest.js +1 -1
  21. package/.next/standalone/.next/server/app/page.js +2 -2
  22. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  24. package/.next/standalone/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  25. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0q3rzj9._.js +3 -0
  26. package/.next/standalone/.next/server/chunks/ssr/app_page_tsx_0fwe3kl._.js +3 -0
  27. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  28. package/.next/standalone/.next/server/pages/404.html +1 -1
  29. package/.next/standalone/.next/server/pages/500.html +1 -1
  30. package/.next/standalone/app/page.tsx +290 -257
  31. package/.next/standalone/bin/cli.js +22 -6
  32. package/.next/standalone/package.json +1 -1
  33. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  34. package/.next/static/chunks/0pb14~6.l8.f9.css +1 -0
  35. package/bin/cli.js +22 -6
  36. package/package.json +1 -1
  37. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lbywin._.js +0 -3
  38. package/.next/standalone/.next/server/chunks/ssr/_0j0avc7._.js +0 -3
  39. package/.next/static/chunks/118uk9v3812u1.css +0 -1
  40. /package/.next/static/{I-W1iV4XdkTRyjis8Ros1 → uI28k7hBjH7vKGE3jdIaM}/_buildManifest.js +0 -0
  41. /package/.next/static/{I-W1iV4XdkTRyjis8Ros1 → uI28k7hBjH7vKGE3jdIaM}/_clientMiddlewareManifest.js +0 -0
  42. /package/.next/static/{I-W1iV4XdkTRyjis8Ros1 → uI28k7hBjH7vKGE3jdIaM}/_ssgManifest.js +0 -0
@@ -6,16 +6,15 @@
6
6
  * `text-[10px] uppercase tracking-widest text-zinc-500` label above a
7
7
  * `text-3xl/text-2xl font-semibold tabular-nums tracking-tight` number,
8
8
  * mirroring Stripe's "Gross volume / $7.8K" treatment on every metric.
9
- * 2. Primary receipt card + 3 secondary receipt cards layout: the
10
- * per-turn baseline lives in a centered `max-w-md rounded-lg border
11
- * px-8 py-6` card; the other 3 blocks sit in a `grid grid-cols-3 gap-3`
12
- * row below using the same border treatment scaled to `px-4 py-4`.
13
- * 3. Mode-switcher chip row pinned to the top of the primary card
14
- * (`30D · 7D · 90D`) emulating Stripe's `1W 4W 1Y MTD QTD YTD ALL`
15
- * pill strip active chip filled, inactive chips bare text.
16
- * Cards are flat `bg-white dark:bg-zinc-900` with hairline borders;
17
- * no shadow, no backdrop-blur, no glassmorphism (per DESIGN.md).
9
+ * 2. Primary receipt card + 3 secondary receipt cards layout.
10
+ * 3. Mode-switcher chip row pinned to the top of the primary card.
11
+ * Cards are flat `bg-white dark:bg-zinc-900` with hairline borders.
12
+ *
13
+ * Suspense boundaries: each receipt is its own async server component so
14
+ * fast sections (inventory ~300ms) paint immediately while slow sections
15
+ * (sessions/context ~7s on cold) fill in progressively.
18
16
  */
17
+ import { Suspense } from "react";
19
18
  import Link from "next/link";
20
19
  import { getInventory, summarize } from "@/lib/inventory";
21
20
  import { getUsage, lookupUsage } from "@/lib/usage";
@@ -38,25 +37,225 @@ function shortNumber(n: number): string {
38
37
  return String(n);
39
38
  }
40
39
 
41
- export default async function Cockpit() {
42
- const [items, usage, sessions, contextFiles, hooks] = await Promise.all([
43
- getInventory(),
44
- getUsage(DAYS),
45
- getSessions(DAYS),
46
- getContextFiles(),
47
- getHooks(),
48
- ]);
40
+ // ─── Skeletons ─────────────────────────────────────────────────────────────
49
41
 
42
+ function Pulse({ className = "" }: { className?: string }) {
43
+ return <div className={`animate-pulse bg-zinc-200/70 dark:bg-zinc-800/70 rounded ${className}`} />;
44
+ }
45
+
46
+ function PrimaryReceiptSkeleton() {
47
+ return (
48
+ <div className="w-full max-w-md rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
49
+ <div className="flex items-center justify-between px-4 pt-3 pb-2 border-b border-zinc-200 dark:border-zinc-800">
50
+ <Pulse className="h-2.5 w-20" />
51
+ <Pulse className="h-2.5 w-12" />
52
+ </div>
53
+ <div className="px-8 py-6 text-center space-y-2">
54
+ <Pulse className="h-2.5 w-24 mx-auto" />
55
+ <Pulse className="h-9 w-40 mx-auto" />
56
+ <Pulse className="h-2.5 w-44 mx-auto" />
57
+ </div>
58
+ <div className="grid grid-cols-4 border-t border-zinc-200 dark:border-zinc-800 divide-x divide-zinc-200 dark:divide-zinc-800">
59
+ {[0, 1, 2, 3].map((i) => (
60
+ <div key={i} className="px-3 py-2.5 space-y-1.5">
61
+ <Pulse className="h-2 w-12" />
62
+ <Pulse className="h-3.5 w-8" />
63
+ </div>
64
+ ))}
65
+ </div>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ function SecondaryReceiptSkeleton({ title }: { title: string }) {
71
+ return (
72
+ <div className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-4 flex flex-col h-full">
73
+ <div className="flex items-baseline justify-between">
74
+ <div className="text-[10px] uppercase tracking-widest text-zinc-500">{title}</div>
75
+ <Pulse className="h-2.5 w-12" />
76
+ </div>
77
+ <Pulse className="mt-1.5 h-7 w-32" />
78
+ <Pulse className="mt-2 h-2.5 w-20" />
79
+ <div className="mt-3 pt-3 border-t border-zinc-200 dark:border-zinc-800 space-y-1.5 flex-1">
80
+ <Pulse className="h-3 w-full" />
81
+ <Pulse className="h-3 w-5/6" />
82
+ <Pulse className="h-3 w-4/6" />
83
+ </div>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ // ─── Receipts (one async server component per receipt) ─────────────────────
89
+
90
+ async function PrimaryReceipt() {
91
+ const items = await getInventory();
50
92
  const inv = summarize(items);
93
+ return (
94
+ <div className="w-full max-w-md rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
95
+ <div className="flex items-center justify-between px-4 pt-3 pb-2 border-b border-zinc-200 dark:border-zinc-800">
96
+ <span className="text-[10px] uppercase tracking-widest text-zinc-500">
97
+ {DAYS}-day window
98
+ </span>
99
+ <Link
100
+ href="/items"
101
+ className="text-[10px] uppercase tracking-widest text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors"
102
+ >
103
+ items →
104
+ </Link>
105
+ </div>
106
+ <div className="px-8 py-6 text-center">
107
+ <div className="text-[10px] uppercase tracking-widest text-zinc-500">
108
+ per-turn baseline
109
+ </div>
110
+ <div className="mt-2 text-4xl font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-50">
111
+ {fmt.format(inv.totalPerTurnTokens)}
112
+ <span className="ml-1.5 text-sm text-zinc-500 font-normal">tok</span>
113
+ </div>
114
+ <div className="text-[10px] uppercase tracking-widest text-zinc-500 mt-1">
115
+ loaded into every system prompt
116
+ </div>
117
+ </div>
118
+ <div className="grid grid-cols-4 border-t border-zinc-200 dark:border-zinc-800 divide-x divide-zinc-200 dark:divide-zinc-800">
119
+ <MiniStat label="items" value={fmt.format(inv.totalItems)} />
120
+ <MiniStat label="skills" value={fmt.format(inv.byKind.skill.count)} />
121
+ <MiniStat label="agents" value={fmt.format(inv.byKind.agent.count)} />
122
+ <MiniStat label="commands" value={fmt.format(inv.byKind.command.count)} />
123
+ </div>
124
+ </div>
125
+ );
126
+ }
127
+
128
+ async function BurnReceipt() {
129
+ const sessions = await getSessions(DAYS);
51
130
  const sess = summarizeSessions(sessions);
131
+ const maxBurn = Math.max(1, ...sess.dailyBurn.map((d) => d.tokens));
132
+ return (
133
+ <div className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-4 flex flex-col h-full">
134
+ <div className="flex items-baseline justify-between">
135
+ <div className="text-[10px] uppercase tracking-widest text-zinc-500">
136
+ {DAYS}-day burn
137
+ </div>
138
+ <Link
139
+ href="/sessions"
140
+ className="text-[10px] uppercase tracking-widest text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors"
141
+ >
142
+ sessions →
143
+ </Link>
144
+ </div>
145
+ <div className="mt-1.5 text-2xl font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-50">
146
+ {shortNumber(sess.totalTokens)}
147
+ <span className="ml-1 text-xs text-zinc-500 font-normal">tok</span>
148
+ <span className="ml-2 text-xs text-zinc-500 font-normal">·</span>
149
+ <span className="ml-2 text-base font-semibold tabular-nums text-zinc-900 dark:text-zinc-50">
150
+ {formatUsd(sess.totalCostUsd)}
151
+ </span>
152
+ <span className="ml-1 text-xs text-zinc-500 font-normal">api</span>
153
+ </div>
154
+ {sess.dailyBurn.length > 0 && (
155
+ <div className="mt-3">
156
+ <div className="flex items-end gap-px h-8">
157
+ {sess.dailyBurn.map((d) => {
158
+ const pct = Math.max(2, (d.tokens / maxBurn) * 100);
159
+ return (
160
+ <div
161
+ key={d.date}
162
+ className="flex-1 bg-zinc-300 dark:bg-zinc-700"
163
+ style={{ height: `${pct}%` }}
164
+ title={`${d.date}: ${shortNumber(d.tokens)} · ${formatUsd(d.cost)}`}
165
+ />
166
+ );
167
+ })}
168
+ </div>
169
+ <div className="flex items-center justify-between mt-1 text-[10px] uppercase tracking-widest text-zinc-500 tabular-nums">
170
+ <span>{sess.dailyBurn[0].date.slice(5)}</span>
171
+ <span>{sess.dailyBurn.length} active days</span>
172
+ <span>{sess.dailyBurn[sess.dailyBurn.length - 1].date.slice(5)}</span>
173
+ </div>
174
+ </div>
175
+ )}
176
+ <div className="mt-3 pt-3 border-t border-zinc-200 dark:border-zinc-800 grid grid-cols-3 text-center">
177
+ <div>
178
+ <div className="text-[10px] uppercase tracking-widest text-zinc-500">sess</div>
179
+ <div className="text-xs font-semibold tabular-nums mt-0.5">{fmt.format(sess.count)}</div>
180
+ </div>
181
+ <div>
182
+ <div className="text-[10px] uppercase tracking-widest text-zinc-500">median</div>
183
+ <div className="text-xs font-semibold tabular-nums mt-0.5">{shortNumber(sess.medianSessionTokens)}</div>
184
+ </div>
185
+ <div>
186
+ <div className="text-[10px] uppercase tracking-widest text-zinc-500">p95</div>
187
+ <div className="text-xs font-semibold tabular-nums mt-0.5">{shortNumber(sess.p95SessionTokens)}</div>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ );
192
+ }
52
193
 
194
+ async function CandidatesReceipt() {
195
+ const [items, usage] = await Promise.all([getInventory(), getUsage(DAYS)]);
53
196
  const annotated = items.map((it) => ({ ...it, ...lookupUsage(it, usage) }));
54
197
  const candidates = annotated
55
198
  .filter((a) => !a.disabled && a.invocations === 0 && a.source === "user")
56
199
  .sort((a, b) => b.perTurnTokens - a.perTurnTokens)
57
200
  .slice(0, 5);
58
201
  const candidateSavings = candidates.reduce((acc, c) => acc + c.perTurnTokens, 0);
202
+ return (
203
+ <div className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-4 flex flex-col h-full">
204
+ <div className="flex items-baseline justify-between">
205
+ <div className="text-[10px] uppercase tracking-widest text-zinc-500">
206
+ disable candidates
207
+ </div>
208
+ <Link
209
+ href="/items"
210
+ className="text-[10px] uppercase tracking-widest text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors"
211
+ >
212
+ all →
213
+ </Link>
214
+ </div>
215
+ <div className="mt-1.5 text-2xl font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-50">
216
+ {fmt.format(candidateSavings)}
217
+ <span className="ml-1 text-xs text-zinc-500 font-normal">tok/turn</span>
218
+ </div>
219
+ <div className="text-[10px] uppercase tracking-widest text-zinc-500">
220
+ {candidates.length > 0 ? `top ${candidates.length} unused` : "none in window"}
221
+ </div>
222
+ <ul className="mt-3 pt-3 border-t border-zinc-200 dark:border-zinc-800 text-xs space-y-1 flex-1">
223
+ {candidates.length === 0 ? (
224
+ <li className="text-zinc-500">every loaded user item invoked in {DAYS}d.</li>
225
+ ) : (
226
+ candidates.map((c) => (
227
+ <li key={c.filePath} className="flex items-center gap-2">
228
+ <span className="w-10 text-right tabular-nums text-zinc-500">
229
+ {fmt.format(c.perTurnTokens)}
230
+ </span>
231
+ <span className="flex-1 truncate text-zinc-700 dark:text-zinc-300">{c.name}</span>
232
+ <span className="text-[10px] uppercase tracking-widest text-zinc-500 w-12">
233
+ {c.kind}
234
+ </span>
235
+ <form
236
+ action={async () => {
237
+ "use server";
238
+ await toggleUserItem(c.filePath);
239
+ }}
240
+ >
241
+ <button
242
+ type="submit"
243
+ className="text-[10px] uppercase tracking-widest text-zinc-600 dark:text-zinc-400 border border-zinc-300 dark:border-zinc-700 hover:text-red-700 dark:hover:text-red-400 hover:border-red-300 dark:hover:border-red-800 hover:bg-red-50 dark:hover:bg-red-950/40 rounded px-1.5 py-0.5 transition-colors cursor-pointer"
244
+ title={`Disable ${c.name}`}
245
+ >
246
+ off
247
+ </button>
248
+ </form>
249
+ </li>
250
+ ))
251
+ )}
252
+ </ul>
253
+ </div>
254
+ );
255
+ }
59
256
 
257
+ async function ContextReceipt() {
258
+ const [contextFiles, hooks] = await Promise.all([getContextFiles(), getHooks()]);
60
259
  const globalClaudeMd = contextFiles.find((f) => f.category === "claude-md-global");
61
260
  const biggestMemoryMd = contextFiles
62
261
  .filter((f) => f.category === "memory-md")
@@ -64,15 +263,72 @@ export default async function Cockpit() {
64
263
  const sessionStartHookTokens = hooks
65
264
  .filter((h) => h.event === "SessionStart" && h.status === "measured")
66
265
  .reduce((acc, h) => acc + h.perTurnTokens, 0);
67
-
68
- const maxBurn = Math.max(1, ...sess.dailyBurn.map((d) => d.tokens));
69
266
  const contextTotal =
70
- (globalClaudeMd?.tokens ?? 0) +
71
- (biggestMemoryMd?.tokens ?? 0) +
72
- sessionStartHookTokens;
267
+ (globalClaudeMd?.tokens ?? 0) + (biggestMemoryMd?.tokens ?? 0) + sessionStartHookTokens;
73
268
  const contextRowCount =
74
269
  (globalClaudeMd ? 1 : 0) + (biggestMemoryMd ? 1 : 0) + (sessionStartHookTokens > 0 ? 1 : 0);
270
+ return (
271
+ <div className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-4 flex flex-col h-full">
272
+ <div className="flex items-baseline justify-between">
273
+ <div className="text-[10px] uppercase tracking-widest text-zinc-500">
274
+ context overhead
275
+ </div>
276
+ <Link
277
+ href="/context"
278
+ className="text-[10px] uppercase tracking-widest text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors"
279
+ >
280
+ context →
281
+ </Link>
282
+ </div>
283
+ <div className="mt-1.5 text-2xl font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-50">
284
+ {fmt.format(contextTotal)}
285
+ <span className="ml-1 text-xs text-zinc-500 font-normal">tok</span>
286
+ </div>
287
+ <div className="text-[10px] uppercase tracking-widest text-zinc-500">
288
+ top {contextRowCount} sticky {contextRowCount === 1 ? "source" : "sources"}
289
+ </div>
290
+ <ul className="mt-3 pt-3 border-t border-zinc-200 dark:border-zinc-800 text-xs space-y-1 flex-1">
291
+ {globalClaudeMd && (
292
+ <li className="flex items-center gap-2">
293
+ <span className="w-10 text-right tabular-nums text-zinc-500">
294
+ {fmt.format(globalClaudeMd.tokens)}
295
+ </span>
296
+ <span className="flex-1 truncate text-zinc-700 dark:text-zinc-300">
297
+ <code className="text-[11px]">~/.claude/CLAUDE.md</code>
298
+ </span>
299
+ <span className="text-[10px] uppercase tracking-widest text-zinc-500">every</span>
300
+ </li>
301
+ )}
302
+ {biggestMemoryMd && (
303
+ <li className="flex items-center gap-2">
304
+ <span className="w-10 text-right tabular-nums text-zinc-500">
305
+ {fmt.format(biggestMemoryMd.tokens)}
306
+ </span>
307
+ <span className="flex-1 truncate text-zinc-700 dark:text-zinc-300">
308
+ <code className="text-[11px]">{biggestMemoryMd.name}</code>
309
+ </span>
310
+ <span className="text-[10px] uppercase tracking-widest text-zinc-500">mem</span>
311
+ </li>
312
+ )}
313
+ {sessionStartHookTokens > 0 && (
314
+ <li className="flex items-center gap-2">
315
+ <span className="w-10 text-right tabular-nums text-zinc-500">
316
+ {fmt.format(sessionStartHookTokens)}
317
+ </span>
318
+ <span className="flex-1 truncate text-zinc-700 dark:text-zinc-300">
319
+ SessionStart hooks
320
+ </span>
321
+ <span className="text-[10px] uppercase tracking-widest text-zinc-500">sticky</span>
322
+ </li>
323
+ )}
324
+ </ul>
325
+ </div>
326
+ );
327
+ }
328
+
329
+ // ─── Page ──────────────────────────────────────────────────────────────────
75
330
 
331
+ export default function Cockpit() {
76
332
  return (
77
333
  <main className="flex-1 font-mono bg-zinc-50 dark:bg-zinc-950">
78
334
  <div className="max-w-6xl mx-auto px-6 py-10">
@@ -88,244 +344,22 @@ export default async function Cockpit() {
88
344
  </code>
89
345
  </header>
90
346
 
91
- {/* PRIMARY RECEIPT — per-turn baseline */}
92
347
  <section className="flex justify-center mb-6">
93
- <div className="w-full max-w-md rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
94
- <div className="flex items-center justify-between px-4 pt-3 pb-2 border-b border-zinc-200 dark:border-zinc-800">
95
- <span className="text-[10px] uppercase tracking-widest text-zinc-500">
96
- {DAYS}-day window
97
- </span>
98
- <Link
99
- href="/items"
100
- className="text-[10px] uppercase tracking-widest text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors"
101
- >
102
- items →
103
- </Link>
104
- </div>
105
-
106
- <div className="px-8 py-6 text-center">
107
- <div className="text-[10px] uppercase tracking-widest text-zinc-500">
108
- per-turn baseline
109
- </div>
110
- <div className="mt-2 text-4xl font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-50">
111
- {fmt.format(inv.totalPerTurnTokens)}
112
- <span className="ml-1.5 text-sm text-zinc-500 font-normal">tok</span>
113
- </div>
114
- <div className="text-[10px] uppercase tracking-widest text-zinc-500 mt-1">
115
- loaded into every system prompt
116
- </div>
117
- </div>
118
-
119
- {/* Breakdown row — Stripe's tiny Payments / Customers numerals */}
120
- <div className="grid grid-cols-4 border-t border-zinc-200 dark:border-zinc-800 divide-x divide-zinc-200 dark:divide-zinc-800">
121
- <MiniStat label="items" value={fmt.format(inv.totalItems)} />
122
- <MiniStat label="skills" value={fmt.format(inv.byKind.skill.count)} />
123
- <MiniStat label="agents" value={fmt.format(inv.byKind.agent.count)} />
124
- <MiniStat label="commands" value={fmt.format(inv.byKind.command.count)} />
125
- </div>
126
- </div>
348
+ <Suspense fallback={<PrimaryReceiptSkeleton />}>
349
+ <PrimaryReceipt />
350
+ </Suspense>
127
351
  </section>
128
352
 
129
- {/* SECONDARY RECEIPTS — 3-up horizontal row */}
130
353
  <section className="grid grid-cols-1 md:grid-cols-3 gap-3">
131
- {/* 30-day burn */}
132
- <div className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-4 flex flex-col">
133
- <div className="flex items-baseline justify-between">
134
- <div className="text-[10px] uppercase tracking-widest text-zinc-500">
135
- {DAYS}-day burn
136
- </div>
137
- <Link
138
- href="/sessions"
139
- className="text-[10px] uppercase tracking-widest text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors"
140
- >
141
- sessions →
142
- </Link>
143
- </div>
144
- <div className="mt-1.5 text-2xl font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-50">
145
- {shortNumber(sess.totalTokens)}
146
- <span className="ml-1 text-xs text-zinc-500 font-normal">tok</span>
147
- <span className="ml-2 text-xs text-zinc-500 font-normal">·</span>
148
- <span className="ml-2 text-base font-semibold tabular-nums text-zinc-900 dark:text-zinc-50">
149
- {formatUsd(sess.totalCostUsd)}
150
- </span>
151
- <span className="ml-1 text-xs text-zinc-500 font-normal">api</span>
152
- </div>
153
-
154
- {sess.dailyBurn.length > 0 && (
155
- <div className="mt-3">
156
- <div className="flex items-end gap-px h-8">
157
- {sess.dailyBurn.map((d) => {
158
- const pct = Math.max(2, (d.tokens / maxBurn) * 100);
159
- return (
160
- <div
161
- key={d.date}
162
- className="flex-1 bg-zinc-300 dark:bg-zinc-700"
163
- style={{ height: `${pct}%` }}
164
- title={`${d.date}: ${shortNumber(d.tokens)} · ${formatUsd(d.cost)}`}
165
- />
166
- );
167
- })}
168
- </div>
169
- <div className="flex items-center justify-between mt-1 text-[10px] uppercase tracking-widest text-zinc-500 tabular-nums">
170
- <span>{sess.dailyBurn[0].date.slice(5)}</span>
171
- <span>{sess.dailyBurn.length} active days</span>
172
- <span>{sess.dailyBurn[sess.dailyBurn.length - 1].date.slice(5)}</span>
173
- </div>
174
- </div>
175
- )}
176
-
177
- <div className="mt-3 pt-3 border-t border-zinc-200 dark:border-zinc-800 grid grid-cols-3 text-center">
178
- <div>
179
- <div className="text-[10px] uppercase tracking-widest text-zinc-500">
180
- sess
181
- </div>
182
- <div className="text-xs font-semibold tabular-nums mt-0.5">
183
- {fmt.format(sess.count)}
184
- </div>
185
- </div>
186
- <div>
187
- <div className="text-[10px] uppercase tracking-widest text-zinc-500">
188
- median
189
- </div>
190
- <div className="text-xs font-semibold tabular-nums mt-0.5">
191
- {shortNumber(sess.medianSessionTokens)}
192
- </div>
193
- </div>
194
- <div>
195
- <div className="text-[10px] uppercase tracking-widest text-zinc-500">
196
- p95
197
- </div>
198
- <div className="text-xs font-semibold tabular-nums mt-0.5">
199
- {shortNumber(sess.p95SessionTokens)}
200
- </div>
201
- </div>
202
- </div>
203
- </div>
204
-
205
- {/* Disable candidates */}
206
- <div className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-4 flex flex-col">
207
- <div className="flex items-baseline justify-between">
208
- <div className="text-[10px] uppercase tracking-widest text-zinc-500">
209
- disable candidates
210
- </div>
211
- <Link
212
- href="/items"
213
- className="text-[10px] uppercase tracking-widest text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors"
214
- >
215
- all →
216
- </Link>
217
- </div>
218
- <div className="mt-1.5 text-2xl font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-50">
219
- {fmt.format(candidateSavings)}
220
- <span className="ml-1 text-xs text-zinc-500 font-normal">tok/turn</span>
221
- </div>
222
- <div className="text-[10px] uppercase tracking-widest text-zinc-500">
223
- {candidates.length > 0
224
- ? `top ${candidates.length} unused`
225
- : "none in window"}
226
- </div>
227
-
228
- <ul className="mt-3 pt-3 border-t border-zinc-200 dark:border-zinc-800 text-xs space-y-1 flex-1">
229
- {candidates.length === 0 ? (
230
- <li className="text-zinc-500">
231
- every loaded user item invoked in {DAYS}d.
232
- </li>
233
- ) : (
234
- candidates.map((c) => (
235
- <li key={c.filePath} className="flex items-center gap-2">
236
- <span className="w-10 text-right tabular-nums text-zinc-500">
237
- {fmt.format(c.perTurnTokens)}
238
- </span>
239
- <span className="flex-1 truncate text-zinc-700 dark:text-zinc-300">
240
- {c.name}
241
- </span>
242
- <span className="text-[10px] uppercase tracking-widest text-zinc-500 w-12">
243
- {c.kind}
244
- </span>
245
- <form
246
- action={async () => {
247
- "use server";
248
- await toggleUserItem(c.filePath);
249
- }}
250
- >
251
- <button
252
- type="submit"
253
- className="text-[10px] uppercase tracking-widest text-zinc-600 dark:text-zinc-400 border border-zinc-300 dark:border-zinc-700 hover:text-red-700 dark:hover:text-red-400 hover:border-red-300 dark:hover:border-red-800 hover:bg-red-50 dark:hover:bg-red-950/40 rounded px-1.5 py-0.5 transition-colors cursor-pointer"
254
- title={`Disable ${c.name}`}
255
- >
256
- off
257
- </button>
258
- </form>
259
- </li>
260
- ))
261
- )}
262
- </ul>
263
- </div>
264
-
265
- {/* Context overhead */}
266
- <div className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-4 flex flex-col">
267
- <div className="flex items-baseline justify-between">
268
- <div className="text-[10px] uppercase tracking-widest text-zinc-500">
269
- context overhead
270
- </div>
271
- <Link
272
- href="/context"
273
- className="text-[10px] uppercase tracking-widest text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors"
274
- >
275
- context →
276
- </Link>
277
- </div>
278
- <div className="mt-1.5 text-2xl font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-50">
279
- {fmt.format(contextTotal)}
280
- <span className="ml-1 text-xs text-zinc-500 font-normal">tok</span>
281
- </div>
282
- <div className="text-[10px] uppercase tracking-widest text-zinc-500">
283
- top {contextRowCount} sticky{" "}
284
- {contextRowCount === 1 ? "source" : "sources"}
285
- </div>
286
-
287
- <ul className="mt-3 pt-3 border-t border-zinc-200 dark:border-zinc-800 text-xs space-y-1 flex-1">
288
- {globalClaudeMd && (
289
- <li className="flex items-center gap-2">
290
- <span className="w-10 text-right tabular-nums text-zinc-500">
291
- {fmt.format(globalClaudeMd.tokens)}
292
- </span>
293
- <span className="flex-1 truncate text-zinc-700 dark:text-zinc-300">
294
- <code className="text-[11px]">~/.claude/CLAUDE.md</code>
295
- </span>
296
- <span className="text-[10px] uppercase tracking-widest text-zinc-500">
297
- every
298
- </span>
299
- </li>
300
- )}
301
- {biggestMemoryMd && (
302
- <li className="flex items-center gap-2">
303
- <span className="w-10 text-right tabular-nums text-zinc-500">
304
- {fmt.format(biggestMemoryMd.tokens)}
305
- </span>
306
- <span className="flex-1 truncate text-zinc-700 dark:text-zinc-300">
307
- <code className="text-[11px]">{biggestMemoryMd.name}</code>
308
- </span>
309
- <span className="text-[10px] uppercase tracking-widest text-zinc-500">
310
- mem
311
- </span>
312
- </li>
313
- )}
314
- {sessionStartHookTokens > 0 && (
315
- <li className="flex items-center gap-2">
316
- <span className="w-10 text-right tabular-nums text-zinc-500">
317
- {fmt.format(sessionStartHookTokens)}
318
- </span>
319
- <span className="flex-1 truncate text-zinc-700 dark:text-zinc-300">
320
- SessionStart hooks
321
- </span>
322
- <span className="text-[10px] uppercase tracking-widest text-zinc-500">
323
- sticky
324
- </span>
325
- </li>
326
- )}
327
- </ul>
328
- </div>
354
+ <Suspense fallback={<SecondaryReceiptSkeleton title={`${DAYS}-day burn`} />}>
355
+ <BurnReceipt />
356
+ </Suspense>
357
+ <Suspense fallback={<SecondaryReceiptSkeleton title="disable candidates" />}>
358
+ <CandidatesReceipt />
359
+ </Suspense>
360
+ <Suspense fallback={<SecondaryReceiptSkeleton title="context overhead" />}>
361
+ <ContextReceipt />
362
+ </Suspense>
329
363
  </section>
330
364
 
331
365
  <footer className="mt-8 pt-4 border-t border-zinc-200 dark:border-zinc-800 text-[10px] uppercase tracking-widest text-zinc-500 flex items-center justify-between">
@@ -343,4 +377,3 @@ export default async function Cockpit() {
343
377
  </main>
344
378
  );
345
379
  }
346
-
@@ -148,27 +148,43 @@ async function main() {
148
148
  stdio: "inherit",
149
149
  });
150
150
 
151
- // Open browser once server is ready. Sequential polling (await between
152
- // attempts) so we never have concurrent fetches racing to call open().
151
+ process.stdout.write(`contextscope running on ${url}\n`);
152
+
153
+ // Wait for server to respond, warm the cache by hitting `/` (which triggers
154
+ // the heavy transcript scans), then open the browser. Shifts the cold wait
155
+ // from "blank skeleton in browser" to "scanning... in terminal" so the page
156
+ // renders almost instantly when the browser opens.
153
157
  if (!noOpen) {
154
158
  const { default: open } = await import("open");
155
159
  (async () => {
160
+ // Probe readiness with a path that doesn't trigger the scans.
161
+ let ready = false;
156
162
  for (let i = 0; i < 50; i++) {
157
163
  try {
158
- const res = await fetch(url, { method: "HEAD" });
164
+ const res = await fetch(`${url}/_not-found`, { method: "HEAD" });
159
165
  if (res.status < 500) {
160
- await open(url).catch(() => {});
161
- return;
166
+ ready = true;
167
+ break;
162
168
  }
163
169
  } catch {
164
170
  // not yet ready
165
171
  }
166
172
  await new Promise((r) => setTimeout(r, 200));
167
173
  }
174
+ if (!ready) return;
175
+ process.stdout.write(` scanning ~/.claude/projects ...`);
176
+ const start = Date.now();
177
+ try {
178
+ await fetch(url);
179
+ } catch {
180
+ // best-effort warm-up; open browser anyway
181
+ }
182
+ process.stdout.write(` ${((Date.now() - start) / 1000).toFixed(1)}s\n`);
183
+ await open(url).catch(() => {});
168
184
  })();
169
185
  }
170
186
 
171
- process.stdout.write(`contextscope running on ${url}\n (Ctrl+C to stop)\n`);
187
+ process.stdout.write(` (Ctrl+C to stop)\n`);
172
188
 
173
189
  const shutdown = (code = 0) => {
174
190
  if (!child.killed) child.kill("SIGTERM");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mbeato/contextscope",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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": {