@vimoxshah/tokenflow 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/Dockerfile.team +20 -0
  3. package/README.md +30 -11
  4. package/bin/tokenflow.js +147 -12
  5. package/design/tokens.yaml +330 -0
  6. package/docs/architecture.md +5 -4
  7. package/docs/cli.md +204 -0
  8. package/docs/configuration.md +117 -2
  9. package/docs/design-system.md +187 -0
  10. package/docs/exports-and-budgets.md +85 -0
  11. package/docs/guard-codex.md +132 -0
  12. package/docs/ledger.md +144 -0
  13. package/docs/live-mode.md +40 -0
  14. package/docs/media/overview-aurora-dark.png +0 -0
  15. package/docs/media/receipts-aurora-dark.png +0 -0
  16. package/docs/providers-otel.md +179 -0
  17. package/docs/providers.md +54 -1
  18. package/docs/receipt-schema.md +74 -0
  19. package/docs/roadmap.md +182 -0
  20. package/docs/team-server.md +170 -0
  21. package/docs/ui-views.md +322 -0
  22. package/package.json +7 -2
  23. package/schemas/receipt.v0.json +160 -0
  24. package/scripts/build-dmg.sh +11 -2
  25. package/scripts/build-menubar-app.sh +58 -7
  26. package/scripts/design-build.js +475 -0
  27. package/src/analytics/anatomy.js +467 -0
  28. package/src/analytics/branch-compare.js +159 -0
  29. package/src/analytics/cache-health.js +141 -0
  30. package/src/analytics/live-view.js +266 -0
  31. package/src/analytics/receipt-schema.js +214 -0
  32. package/src/analytics/receipt.js +709 -0
  33. package/src/analytics/rhythm.js +184 -0
  34. package/src/analytics/whatif.js +263 -0
  35. package/src/commands/budget-scopes.js +133 -0
  36. package/src/commands/doctor-checks.js +400 -0
  37. package/src/commands/guard.js +531 -0
  38. package/src/commands/hooks.js +238 -0
  39. package/src/commands/pricing-diff.js +316 -0
  40. package/src/commands/receipt.js +226 -0
  41. package/src/commands/team-serve.js +407 -0
  42. package/src/commands/week.js +86 -0
  43. package/src/core/annotations.js +97 -0
  44. package/src/core/budget.js +33 -0
  45. package/src/core/bundle.js +45 -2
  46. package/src/core/ingest.js +33 -0
  47. package/src/core/live-status.js +227 -2
  48. package/src/core/policy.js +103 -0
  49. package/src/core/receipt-note.js +123 -0
  50. package/src/core/repo.js +64 -0
  51. package/src/core/sync.js +163 -26
  52. package/src/core/team.js +0 -0
  53. package/src/export/html-snapshot.js +28 -1
  54. package/src/export/menubar.js +21 -0
  55. package/src/export/receipt-card.js +210 -0
  56. package/src/export/week-card.js +185 -0
  57. package/src/providers/mock/index.js +383 -52
  58. package/src/providers/openai/index.js +31 -1
  59. package/src/providers/otel/index.js +656 -0
  60. package/src/server/routes/annotations.js +42 -0
  61. package/src/server/routes/cache-health.js +95 -0
  62. package/src/server/routes/index.js +54 -0
  63. package/src/server/routes/session.js +157 -0
  64. package/src/server/server.js +47 -1
  65. package/src/ui/app.js +541 -308
  66. package/src/ui/charts.js +95 -0
  67. package/src/ui/first-run.js +144 -0
  68. package/src/ui/index.html +4 -1
  69. package/src/ui/palette.js +335 -0
  70. package/src/ui/styles/anatomy.css +117 -0
  71. package/src/ui/styles/annotations.css +40 -0
  72. package/src/ui/styles/branches.css +99 -0
  73. package/src/ui/styles/cache.css +6 -0
  74. package/src/ui/styles/first-run.css +31 -0
  75. package/src/ui/styles/live.css +100 -0
  76. package/src/ui/styles/palette.css +85 -0
  77. package/src/ui/styles/rhythm.css +8 -0
  78. package/src/ui/styles/whatif.css +55 -0
  79. package/src/ui/styles.css +303 -196
  80. package/src/ui/views/anatomy.js +567 -0
  81. package/src/ui/views/annotations.js +121 -0
  82. package/src/ui/views/branches.js +304 -0
  83. package/src/ui/views/cache.js +232 -0
  84. package/src/ui/views/index.js +85 -0
  85. package/src/ui/views/live.js +683 -0
  86. package/src/ui/views/rhythm.js +206 -0
  87. package/src/ui/views/whatif.js +196 -0
@@ -7,6 +7,36 @@
7
7
  * banner whenever any demo record is in scope. It only activates when asked
8
8
  * for explicitly (TOKENFLOW_DEMO=1 or `providers: [mock]`), so it can never
9
9
  * contaminate a real dataset by accident.
10
+ *
11
+ * ## Shape of the synthetic corpus
12
+ *
13
+ * Real Claude Code usage is bimodal: most sessions are a handful of quick
14
+ * turns, a few run for hours and hold most of the spend. This generator
15
+ * mirrors that on purpose, in four deterministic phases, all drawing from two
16
+ * independent seeded rng streams (see `fetchUsage` for why there are two) so
17
+ * a given seed always reproduces the same corpus:
18
+ *
19
+ * A. one short "seed" session per (repository, branch) pair, so every
20
+ * branch exists in the receipts even on an unlucky draw elsewhere;
21
+ * B. a handful of long, cache-heavy sessions concentrated on ONE branch of
22
+ * ONE repository (`HOT_REPO`/`HOT_BRANCH`), so that branch's receipt
23
+ * dwarfs its repo's median — the "one branch is way more expensive
24
+ * than the rest" pattern a real receipt should be able to show;
25
+ * C. a calendar backfill across `days` days with weekday/weekend/trend
26
+ * texture (mostly short sessions, occasionally a long one), for volume
27
+ * and time-of-day/day-of-week coverage;
28
+ * D. a few "live" sessions whose last turn lands within minutes of
29
+ * generation time, one of them expensive enough to trip a guard
30
+ * warning at a $25 session cap, so the Live view and menu bar have
31
+ * something current to show.
32
+ *
33
+ * Structural requirements (branch coverage, the outlier branch, "about a
34
+ * quarter of long sessions carry subagent turns", the guard-tripping live
35
+ * session, the one unpriced model) are guaranteed BY CONSTRUCTION rather
36
+ * than left to chance — a probability close to a target still drifts across
37
+ * different calendar dates (weekends/trend consume a different number of rng
38
+ * draws each day), which would make a downstream test flaky. Only the day
39
+ * to day *volume* and calendar texture are left to the rng.
10
40
  */
11
41
  import { createProvider } from '../../core/registry.js';
12
42
  import { MEASUREMENT, INTERFACE } from '../../core/schema.js';
@@ -22,6 +52,14 @@ function rng(seed) {
22
52
  };
23
53
  }
24
54
 
55
+ /**
56
+ * Model catalogue. `cacheHeavy` models (Claude via Claude Code) are the ones
57
+ * that carry the growing-context / churn / subagent story; the rest are
58
+ * short, low-cache interactive calls, as in real mixed-tool usage.
59
+ * `gpt-6-mini-preview` is deliberately absent from `BUILTIN_PRICES`
60
+ * (src/core/pricing.js), so at least one model in the demo has no configured
61
+ * price and the coverage labels have something real to report.
62
+ */
25
63
  const MODELS = [
26
64
  { model: 'claude-opus-4-1-20250805', client: 'claude-code', iface: INTERFACE.CLI, weight: 34, out: 0.06, cacheHeavy: true },
27
65
  { model: 'claude-sonnet-4-20250514', client: 'claude-code', iface: INTERFACE.CLI, weight: 26, out: 0.08, cacheHeavy: true },
@@ -30,8 +68,160 @@ const MODELS = [
30
68
  { model: 'o3', client: 'codex', iface: INTERFACE.CLI, weight: 9, out: 0.18, cacheHeavy: false, reasoning: 0.55 },
31
69
  { model: 'deepseek-chat', client: 'cline', iface: INTERFACE.CLI, weight: 6, out: 0.14, cacheHeavy: false },
32
70
  { model: 'gemini-2.0-flash', client: 'api-script', iface: INTERFACE.API, weight: 3, out: 0.20, cacheHeavy: false },
71
+ { model: 'gpt-6-mini-preview', client: 'codex', iface: INTERFACE.IDE, weight: 5, out: 0.15, cacheHeavy: false },
33
72
  ];
34
- const PROJECTS = ['billing-service', 'web-app', 'infra-terraform', 'data-pipeline', 'docs'];
73
+ const CACHE_HEAVY_MODELS = MODELS.filter((m) => m.cacheHeavy);
74
+ const UNPRICED_MODEL = 'gpt-6-mini-preview';
75
+
76
+ /** 5 synthetic repositories: within the 4-6 the demo is asked to cover. */
77
+ const REPOS = ['billing-service', 'web-app', 'infra-terraform', 'data-pipeline', 'docs'];
78
+ /** Every repo gets `main` plus these PR-like feature branches. */
79
+ const FEATURE_BRANCHES = ['feat/receipts-view', 'fix/guard-cache', 'chore/tokens'];
80
+ /** The repo/branch that carries a deliberately outsized share of the spend. */
81
+ const HOT_REPO = 'billing-service';
82
+ const HOT_BRANCH = 'feat/receipts-view';
83
+ const HOT_EXTRA_BRANCHES = ['feat/cache-warmup', 'chore/pricing-refresh'];
84
+
85
+ /** @returns {string[]} the branches that exist on `repo`. */
86
+ function branchesFor(repo) {
87
+ return repo === HOT_REPO
88
+ ? ['main', ...FEATURE_BRANCHES, ...HOT_EXTRA_BRANCHES]
89
+ : ['main', ...FEATURE_BRANCHES];
90
+ }
91
+
92
+ /** A working-directory path that is deliberately NOT a git repo, so the
93
+ * CLI's cwd->repo resolver falls back to the `repository` field. */
94
+ function cwdOf(repo) {
95
+ return `/Users/demo/src/${repo}`;
96
+ }
97
+
98
+ /** Weighted pick from a `{weight}`-bearing list. */
99
+ function pickWeighted(list, r) {
100
+ const total = list.reduce((a, m) => a + m.weight, 0);
101
+ let v = r() * total;
102
+ for (const m of list) {
103
+ v -= m.weight;
104
+ if (v <= 0) return m;
105
+ }
106
+ return list[list.length - 1];
107
+ }
108
+
109
+ /**
110
+ * Generate the turns of one synthetic session.
111
+ *
112
+ * Context grows turn over turn for cache-heavy models: a real cache write on
113
+ * the first turn (the initial system-prompt cache), then again every
114
+ * `churnInterval` (60-120) turns — a "churn" event, as when the system
115
+ * prompt changes mid-session — a large write following a large read. Some
116
+ * sessions carry `cache_refresh_tokens` as a subset of the write (the
117
+ * long-TTL cache); others never do, matching real deployments where only
118
+ * some conversations opt into a long-TTL cache.
119
+ *
120
+ * Timestamps are anchored either at the session's first turn (`anchor.mode
121
+ * === 'start'`, used for backfilled history) or at its LAST turn (`anchor.mode
122
+ * === 'end'`, used for the "still running" live sessions) — anchoring at the
123
+ * end is what lets a caller pin "last turn N seconds ago" exactly, regardless
124
+ * of how the random per-turn gaps sum up.
125
+ *
126
+ * @param {object} opt
127
+ * @param {() => number} opt.r seeded rng — callers pass the `rGen` stream, so
128
+ * a session's internal size never perturbs the caller's structural draws
129
+ * @param {string} opt.sid session id
130
+ * @param {string} opt.repo repository (== project)
131
+ * @param {string|null} opt.branch git branch; 'HEAD' or null are valid (unattributed)
132
+ * @param {object} opt.spec one entry from MODELS
133
+ * @param {number} opt.turns requested turn count (may be truncated, see `cutoffMs`)
134
+ * @param {{mode:'start'|'end', ms:number}} opt.anchor where turn 0 (start) or the
135
+ * last turn (end) lands
136
+ * @param {number} [opt.cutoffMs] for `anchor.mode==='start'` only: never emit a
137
+ * turn whose timestamp would land at or after this instant — keeps a
138
+ * backfilled session from spilling into the live window.
139
+ * @param {{start:number, count:number}|null} [opt.subagentBlock] contiguous
140
+ * turn range to mark `category: 'subagent'`
141
+ * @param {boolean} [opt.refreshEnabled] whether this session's cache writes
142
+ * carry a `cache_refresh_tokens` subset
143
+ * @param {number} [opt.scale] multiplier on cache read/write growth, for the
144
+ * deliberately oversized branch/live sessions
145
+ * @returns {object[]} partial usage records, one per turn
146
+ */
147
+ function genSession({
148
+ r, sid, repo, branch, spec, turns, anchor, cutoffMs = Infinity,
149
+ subagentBlock = null, refreshEnabled = false, scale = 1,
150
+ }) {
151
+ const heavy = !!spec.cacheHeavy;
152
+ const churnInterval = 60 + Math.floor(r() * 61); // 60..120 turns
153
+
154
+ // Pass 1: per-turn gaps (20-90s), as offsets relative to the session's
155
+ // first turn — computed before we know the anchor so an "end" anchor can
156
+ // shift the whole session to make its LAST turn land exactly where asked.
157
+ let deltas = [0];
158
+ for (let i = 1; i < turns; i++) deltas.push(deltas[i - 1] + Math.round((20 + r() * 70) * 1000));
159
+
160
+ let tsOf;
161
+ if (anchor.mode === 'end') {
162
+ const total = deltas[deltas.length - 1];
163
+ tsOf = (i) => anchor.ms - (total - deltas[i]);
164
+ } else {
165
+ tsOf = (i) => anchor.ms + deltas[i];
166
+ let cut = deltas.length;
167
+ for (let i = 0; i < deltas.length; i++) {
168
+ if (tsOf(i) >= cutoffMs) { cut = i; break; }
169
+ }
170
+ if (cut < deltas.length) { deltas = deltas.slice(0, cut); turns = cut; }
171
+ }
172
+ if (turns <= 0) return [];
173
+
174
+ const recs = [];
175
+ const cwd = cwdOf(repo);
176
+ let cacheRead = heavy ? Math.round((3000 + r() * 3000) * scale) : Math.round(150 + r() * 250);
177
+
178
+ for (let i = 0; i < turns; i++) {
179
+ const isChurn = heavy && i > 0 && i % churnInterval === 0;
180
+ let cacheWrite = 0;
181
+ if (i === 0) {
182
+ cacheWrite = heavy ? Math.round((4000 + r() * 5000) * scale) : 0;
183
+ } else if (isChurn) {
184
+ // A system-prompt change: a large write, and the read this same turn
185
+ // already reflects the freshly-cached prefix.
186
+ cacheWrite = Math.round((15000 + r() * 20000) * scale);
187
+ cacheRead += Math.round(cacheWrite * (0.4 + r() * 0.3));
188
+ }
189
+ if (heavy && i > 0) cacheRead += Math.round((120 + r() * 260) * scale);
190
+
191
+ const cacheRefresh = refreshEnabled && cacheWrite > 0 ? Math.round(cacheWrite * (0.3 + r() * 0.3)) : 0;
192
+ const input = heavy ? Math.round(80 + r() * 220) : Math.round(150 + r() * 350);
193
+ const output = Math.round((input + cacheRead) * spec.out * (0.4 + r() * 0.6));
194
+ const reasoning = spec.reasoning ? Math.round(output * spec.reasoning) : null;
195
+ const inSub = !!subagentBlock && i >= subagentBlock.start && i < subagentBlock.start + subagentBlock.count;
196
+
197
+ recs.push({
198
+ id: `mock-${sid}-${i}`,
199
+ timestamp: new Date(tsOf(i)).toISOString(),
200
+ model: spec.model,
201
+ client: spec.client,
202
+ application: spec.client,
203
+ interface: spec.iface,
204
+ input_tokens: input,
205
+ output_tokens: output,
206
+ cache_read_tokens: cacheRead,
207
+ cache_write_tokens: cacheWrite,
208
+ cache_refresh_tokens: cacheRefresh,
209
+ reasoning_tokens: reasoning,
210
+ session_id: sid,
211
+ conversation_id: sid,
212
+ request_id: `req-${sid}-${i}`,
213
+ project: repo,
214
+ repository: repo,
215
+ git_branch: branch,
216
+ category: inSub ? 'subagent' : 'main',
217
+ machine: 'demo-machine',
218
+ user: 'demo',
219
+ duration_ms: Math.round(600 + output / 30 + r() * 2000),
220
+ metadata: { demo: true, cwd },
221
+ });
222
+ }
223
+ return recs;
224
+ }
35
225
 
36
226
  export default createProvider({
37
227
  id: 'mock',
@@ -48,70 +238,211 @@ export default createProvider({
48
238
  },
49
239
 
50
240
  async fetchUsage(ctx, emit) {
51
- const days = Number(ctx?.config?.sources?.mock?.days ?? 160);
241
+ const days = Math.max(2, Number(ctx?.config?.sources?.mock?.days ?? 160));
52
242
  const seed = Number(ctx?.config?.sources?.mock?.seed ?? 20260814);
53
- const r = rng(seed);
54
- const end = new Date();
243
+ // Test-only override so structural assertions can be checked against a
244
+ // pinned calendar; the CLI never sets this, so real usage always gets
245
+ // `new Date()`.
246
+ const nowOverride = ctx?.config?.sources?.mock?.now;
247
+ const nowMs = nowOverride ? new Date(nowOverride).getTime() : Date.now();
248
+ const end = new Date(nowMs);
55
249
  end.setUTCHours(0, 0, 0, 0);
250
+ const endMs = end.getTime();
251
+ const cutoffMs = nowMs - 15 * 60000; // backfilled sessions never spill into the live window
252
+ // Two independent streams from the same seed. `r` decides STRUCTURE (which
253
+ // days get a session, how many turns, which repo/branch/model) and always
254
+ // consumes the same small, fixed number of draws per decision. `rGen` is
255
+ // the only thing genSession ever touches, for the per-turn token/timing
256
+ // math — which legitimately takes anywhere from 5 to 600 turns' worth of
257
+ // draws. Sharing one stream between the two would mean a single big
258
+ // session shifts every structural decision after it, so which days end up
259
+ // active would depend on the position that session happened to land at —
260
+ // and since day-level structure depends on the REAL weekday/weekend
261
+ // pattern (via `now`), the whole corpus size would swing wildly depending
262
+ // on what day `tokenflow demo` happens to run.
263
+ const r = rng(seed);
264
+ const rGen = rng((seed ^ 0x9e3779b9) >>> 0);
265
+
56
266
  let records = 0;
267
+ let longSeq = 0;
268
+ const emitAll = (recs) => { for (const p of recs) { emit(p); records++; } };
269
+ const dayStartMs = (offsetDays) => endMs - offsetDays * 86400000;
270
+ const clampOffset = (want) => Math.min(Math.max(1, days - 1), Math.max(1, want));
271
+
272
+ // Roughly 1 in 4 long sessions carries subagent turns, deterministically
273
+ // (the 1st, 5th, 9th, ... long session across the whole run), with a
274
+ // contiguous block whose share is drawn inside [0.35, 0.65] — comfortably
275
+ // inside the [0.3, 0.7] band a downstream check asks for.
276
+ const nextSubagentBlock = (turns) => {
277
+ const idx = longSeq++;
278
+ if (idx % 4 !== 0) return null;
279
+ const share = 0.35 + r() * 0.3;
280
+ const count = Math.max(1, Math.min(turns - 1, Math.round(turns * share)));
281
+ const start = Math.max(1, Math.min(turns - count, Math.floor(turns * (0.1 + r() * 0.2))));
282
+ return { start, count };
283
+ };
284
+
285
+ // ---- Phase A: one seed session per (repository, branch) ---------------
286
+ // Guarantees every branch of every repo shows up in the receipts, no
287
+ // matter how the rest of the draw goes.
288
+ let unpricedForced = false;
289
+ for (const repo of REPOS) {
290
+ for (const branch of branchesFor(repo)) {
291
+ const offset = clampOffset(2 + Math.floor(r() * Math.max(1, days - 3)));
292
+ const hour = 9 + Math.floor(r() * 10);
293
+ const startMs = dayStartMs(offset) + hour * 3600000 + Math.floor(r() * 3600000);
294
+ const turns = 5 + Math.floor(35 * r() * r()); // skewed toward the low end, 5-40
295
+ let spec;
296
+ if (!unpricedForced && repo === REPOS[3] && branch === 'chore/tokens') {
297
+ spec = MODELS.find((m) => m.model === UNPRICED_MODEL);
298
+ unpricedForced = true;
299
+ } else {
300
+ spec = pickWeighted(MODELS, r);
301
+ }
302
+ const sid = `demo-seed-${repo}-${branch.replace(/\//g, '-')}`;
303
+ emitAll(genSession({
304
+ r: rGen, sid, repo, branch, spec, turns,
305
+ anchor: { mode: 'start', ms: startMs }, cutoffMs,
306
+ refreshEnabled: r() < 0.5,
307
+ }));
308
+ }
309
+ }
57
310
 
58
- const total = MODELS.reduce((a, m) => a + m.weight, 0);
59
- for (let dayIdx = days - 1; dayIdx >= 0; dayIdx--) {
60
- const d = new Date(end.getTime() - dayIdx * 86400000);
311
+ // ---- Phase B: long, cache-heavy sessions concentrated on one branch ---
312
+ // These, plus the guard-trip live session in Phase D, are what make
313
+ // HOT_BRANCH's receipt dwarf its repo's median branch.
314
+ for (let i = 0; i < 3; i++) {
315
+ const offset = clampOffset(5 + Math.floor(r() * Math.max(1, days - 6)));
316
+ const hour = 9 + Math.floor(r() * 10);
317
+ const startMs = dayStartMs(offset) + hour * 3600000 + Math.floor(r() * 3600000);
318
+ const turns = 200 + Math.floor(400 * r() * r()); // skewed toward the low end, 200-600
319
+ const spec = pickWeighted(CACHE_HEAVY_MODELS, r);
320
+ const subagentBlock = nextSubagentBlock(turns);
321
+ emitAll(genSession({
322
+ r: rGen, sid: `demo-hot-${i}`, repo: HOT_REPO, branch: HOT_BRANCH, spec, turns,
323
+ anchor: { mode: 'start', ms: startMs }, cutoffMs,
324
+ subagentBlock, refreshEnabled: true, scale: 1.2,
325
+ }));
326
+ }
327
+
328
+ // ---- Phase C: calendar backfill for volume + weekday/hour texture -----
329
+ // "Roughly 1 in 14 backfilled sessions is long" is structural (every
330
+ // 14th, by a counter), not `r() < 0.07`: a Bernoulli draw over the ~110
331
+ // sessions a run creates has a standard deviation of a few long sessions
332
+ // either way, and each one is worth ~15-20x a short session's records —
333
+ // exactly the kind of small probability swing that would make the total
334
+ // record count drift outside its target band depending on which days a
335
+ // long session happened to land on.
336
+ let backfillSeq = 0;
337
+ for (let dayIdx = days - 1; dayIdx >= 1; dayIdx--) {
338
+ const d = new Date(endMs - dayIdx * 86400000);
61
339
  const dow = (d.getUTCDay() + 6) % 7;
62
340
  const weekend = dow >= 5;
63
- // A gentle upward trend plus weekday seasonality plus noise.
341
+ // A gentle upward trend plus weekday seasonality plus noise, same
342
+ // shape as the original generator so the calendar still reads right.
64
343
  const trend = 0.55 + 0.9 * ((days - dayIdx) / days);
65
- const dayFactor = (weekend ? 0.28 : 1) * trend * (0.65 + r() * 0.7);
66
- if (r() < (weekend ? 0.45 : 0.06)) continue; // a few genuinely idle days
67
- const requests = Math.round(40 * dayFactor);
68
- const sessions = Math.max(1, Math.round(requests / (6 + r() * 6)));
344
+ const dayFactor = (weekend ? 0.3 : 1) * trend * (0.6 + r() * 0.8);
345
+ // Every day draws exactly the same three rolls regardless of which
346
+ // branch they take: an early `continue` would make idle days consume
347
+ // fewer rng draws than active ones, and since weekday/weekend/idle
348
+ // outcomes depend on the REAL calendar date `now` resolves to, that
349
+ // would desync the whole rest of the run's rng position differently
350
+ // on every different day the demo happens to run — a few genuinely
351
+ // idle days is still the goal, it just can't change how many draws
352
+ // the day consumes.
353
+ const idleRoll = r();
354
+ const idle = idleRoll < (weekend ? 0.4 : 0.06);
355
+ let sessionsToday = 0;
356
+ if (!idle) {
357
+ sessionsToday = r() < 0.74 * dayFactor ? 1 : 0;
358
+ if (r() < 0.135 * dayFactor) sessionsToday += 1;
359
+ } else {
360
+ r(); r(); // keep the per-day draw count identical to the active path
361
+ }
69
362
 
70
- for (let s = 0; s < sessions; s++) {
363
+ for (let s = 0; s < sessionsToday; s++) {
71
364
  // Bimodal working hours: a morning block and an evening block.
72
365
  const evening = r() < 0.38;
73
- const baseHour = evening ? 19 + Math.floor(r() * 4) : 9 + Math.floor(r() * 5);
74
- const sid = `demo-${d.toISOString().slice(0, 10)}-${s}`;
75
- const project = PROJECTS[Math.floor(r() * PROJECTS.length)];
76
- let pickV = r() * total;
77
- let spec = MODELS[0];
78
- for (const m of MODELS) { pickV -= m.weight; if (pickV <= 0) { spec = m; break; } }
79
-
80
- const perSession = Math.max(1, Math.round(requests / sessions));
81
- for (let i = 0; i < perSession; i++) {
82
- const hour = Math.min(23, baseHour + Math.floor(r() * 2));
83
- const ts = new Date(d.getTime() + hour * 3600000 + Math.floor(r() * 3600000));
84
- const scale = 1 + r() * 9;
85
- const cacheRead = spec.cacheHeavy ? Math.round(9000 * scale * (1 + r())) : Math.round(400 * scale);
86
- const cacheWrite = spec.cacheHeavy ? Math.round(2400 * scale * r()) : 0;
87
- const input = Math.round(180 * scale * (0.4 + r()));
88
- const output = Math.round((input + cacheRead) * spec.out * (0.5 + r()));
89
- emit({
90
- id: `mock-${sid}-${i}`,
91
- timestamp: ts.toISOString(),
92
- model: spec.model,
93
- client: spec.client,
94
- application: spec.client,
95
- interface: spec.iface,
96
- input_tokens: input,
97
- output_tokens: output,
98
- cache_read_tokens: cacheRead,
99
- cache_write_tokens: cacheWrite,
100
- cache_refresh_tokens: spec.cacheHeavy ? Math.round(cacheWrite * 0.35) : 0,
101
- reasoning_tokens: spec.reasoning ? Math.round(output * spec.reasoning) : null,
102
- session_id: sid,
103
- project,
104
- repository: project,
105
- git_branch: r() < 0.5 ? 'main' : `feat/${project}-${Math.floor(r() * 90)}`,
106
- category: 'main',
107
- machine: 'demo-machine',
108
- user: 'demo',
109
- metadata: { demo: true },
110
- });
111
- records++;
366
+ const hour = evening ? 19 + Math.floor(r() * 4) : 9 + Math.floor(r() * 5);
367
+ const startMs = dayStartMs(dayIdx) + hour * 3600000 + Math.floor(r() * 3600000);
368
+ const isLong = backfillSeq++ % 14 === 13; // ~1 in 14, deterministically
369
+ const turns = isLong
370
+ ? 200 + Math.floor(400 * r() * r())
371
+ : 5 + Math.floor(35 * r() * r());
372
+ const repo = REPOS[Math.floor(r() * REPOS.length)];
373
+ const list = branchesFor(repo);
374
+ let branch;
375
+ if (isLong) {
376
+ branch = repo === HOT_REPO ? HOT_BRANCH : (r() < 0.6 ? 'main' : list[1 + Math.floor(r() * (list.length - 1))]);
377
+ } else {
378
+ branch = list[Math.floor(r() * list.length)];
112
379
  }
380
+ const spec = isLong ? pickWeighted(CACHE_HEAVY_MODELS, r) : pickWeighted(MODELS, r);
381
+ const subagentBlock = isLong ? nextSubagentBlock(turns) : null;
382
+ const sid = `demo-bf-${d.toISOString().slice(0, 10)}-${s}`;
383
+ emitAll(genSession({
384
+ r: rGen, sid, repo, branch, spec, turns,
385
+ anchor: { mode: 'start', ms: startMs }, cutoffMs,
386
+ subagentBlock, refreshEnabled: r() < 0.5,
387
+ }));
113
388
  }
114
389
  }
390
+
391
+ // ---- Special branch identities ----------------------------------------
392
+ // Exactly one detached-HEAD session and one with no branch at all — both
393
+ // land in "(unattributed)" in the receipts, as real detached checkouts do.
394
+ {
395
+ const offset = clampOffset(Math.min(days - 1, 6));
396
+ const startMs = dayStartMs(offset) + 10 * 3600000;
397
+ const spec = pickWeighted(MODELS, r);
398
+ emitAll(genSession({
399
+ r: rGen, sid: 'demo-head-session', repo: 'web-app', branch: 'HEAD', spec,
400
+ turns: 5 + Math.floor(10 * r()), anchor: { mode: 'start', ms: startMs }, cutoffMs,
401
+ }));
402
+ }
403
+ {
404
+ const offset = clampOffset(Math.min(days - 1, 4));
405
+ const startMs = dayStartMs(offset) + 14 * 3600000;
406
+ const spec = pickWeighted(MODELS, r);
407
+ emitAll(genSession({
408
+ r: rGen, sid: 'demo-nobranch-session', repo: 'infra-terraform', branch: null, spec,
409
+ turns: 5 + Math.floor(10 * r()), anchor: { mode: 'start', ms: startMs }, cutoffMs,
410
+ }));
411
+ }
412
+
413
+ // ---- Phase D: live sessions, still running -----------------------------
414
+ // Anchored at their LAST turn so it lands within minutes of "now",
415
+ // regardless of how many turns came before it.
416
+ {
417
+ const turns = 8 + Math.floor(6 * r());
418
+ const lastMs = nowMs - Math.round((30 + r() * 300) * 1000); // within ~5.5 min
419
+ emitAll(genSession({
420
+ r: rGen, sid: 'demo-live-short', repo: REPOS[1], branch: 'feat/receipts-view',
421
+ spec: pickWeighted(MODELS, r), turns, anchor: { mode: 'end', ms: lastMs },
422
+ }));
423
+ }
424
+ {
425
+ const turns = 25 + Math.floor(10 * r());
426
+ const lastMs = nowMs - Math.round((30 + r() * 300) * 1000);
427
+ emitAll(genSession({
428
+ r: rGen, sid: 'demo-live-medium', repo: REPOS[2], branch: 'chore/tokens',
429
+ spec: pickWeighted(CACHE_HEAVY_MODELS, r), turns, anchor: { mode: 'end', ms: lastMs },
430
+ refreshEnabled: true,
431
+ }));
432
+ }
433
+ {
434
+ // The guard-trip session: enough turns of a cache-heavy model, growing
435
+ // context, to comfortably clear a $25/session guard cap.
436
+ const turns = 260;
437
+ const lastMs = nowMs - Math.round((30 + r() * 270) * 1000); // within ~5 min
438
+ const spec = MODELS.find((m) => m.model === 'claude-opus-4-1-20250805');
439
+ const subagentBlock = nextSubagentBlock(turns);
440
+ emitAll(genSession({
441
+ r: rGen, sid: 'demo-live-guard', repo: HOT_REPO, branch: HOT_BRANCH, spec, turns,
442
+ anchor: { mode: 'end', ms: lastMs }, subagentBlock, refreshEnabled: true, scale: 1.6,
443
+ }));
444
+ }
445
+
115
446
  return { records, notes: ['synthetic demo data — clearly labelled in the UI'] };
116
447
  },
117
448
  });
@@ -45,6 +45,16 @@
45
45
  * A `model_provider` that is not a known model vendor is recorded as a
46
46
  * **gateway** (a proxy/router) rather than as the vendor, so "who served this"
47
47
  * and "who made this model" stay separate dimensions.
48
+ *
49
+ * ## Repository identity from `session_meta.git`
50
+ *
51
+ * Recent CLIs (cli_version 0.149+ observed) include a `git` block on
52
+ * `session_meta`: `{ commit_hash, branch, repository_url }`. `branch` becomes
53
+ * `git_branch`; `repository_url` is reduced to its basename with any `.git`
54
+ * suffix stripped and becomes `repository` (falling back to the cwd basename,
55
+ * same as `project`, when `git` is absent — the pre-existing behaviour).
56
+ * Only the derived name is kept: the commit hash and the URL itself are never
57
+ * stored, and no message content is read.
48
58
  */
49
59
  import fs from 'node:fs';
50
60
  import path from 'node:path';
@@ -79,6 +89,19 @@ function expand(p) {
79
89
  return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
80
90
  }
81
91
 
92
+ /**
93
+ * "https://example.invalid/org/my-repo.git" -> "my-repo". Never returns or
94
+ * stores the URL itself — only the derived name is kept.
95
+ * @param {unknown} url
96
+ * @returns {string|null}
97
+ */
98
+ export function repoNameFromUrl(url) {
99
+ if (!url || typeof url !== 'string') return null;
100
+ const base = path.posix.basename(url.replace(/\/+$/, ''));
101
+ if (!base) return null;
102
+ return base.replace(/\.git$/i, '') || null;
103
+ }
104
+
82
105
  /**
83
106
  * Accumulate a turn's `last_token_usage` series into disjoint totals.
84
107
  *
@@ -225,7 +248,8 @@ export default createProvider({
225
248
  conversation_id: s.threadId || null,
226
249
  request_id: turn.id,
227
250
  project,
228
- repository: project,
251
+ repository: s.gitRepository || project,
252
+ git_branch: s.gitBranch || null,
229
253
  category: s.threadSource === 'subagent' ? 'subagent' : 'main',
230
254
  client: 'codex',
231
255
  application: 'Codex',
@@ -275,6 +299,12 @@ export default createProvider({
275
299
  s.threadSource = p.thread_source || s.threadSource || null;
276
300
  s.cliVersion = p.cli_version || s.cliVersion || null;
277
301
  s.routeProvider = p.model_provider || s.routeProvider || null;
302
+ // Never store the commit hash or the repository URL — only what a
303
+ // branch name and a derived repo name need.
304
+ if (p.git) {
305
+ s.gitBranch = p.git.branch || s.gitBranch || null;
306
+ s.gitRepository = repoNameFromUrl(p.git.repository_url) || s.gitRepository || null;
307
+ }
278
308
  if (p.model) s.model = p.model;
279
309
  return;
280
310
  }