@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.
- package/CHANGELOG.md +228 -0
- package/Dockerfile.team +20 -0
- package/README.md +30 -11
- package/bin/tokenflow.js +147 -12
- package/design/tokens.yaml +330 -0
- package/docs/architecture.md +5 -4
- package/docs/cli.md +204 -0
- package/docs/configuration.md +117 -2
- package/docs/design-system.md +187 -0
- package/docs/exports-and-budgets.md +85 -0
- package/docs/guard-codex.md +132 -0
- package/docs/ledger.md +144 -0
- package/docs/live-mode.md +40 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/receipts-aurora-dark.png +0 -0
- package/docs/providers-otel.md +179 -0
- package/docs/providers.md +54 -1
- package/docs/receipt-schema.md +74 -0
- package/docs/roadmap.md +182 -0
- package/docs/team-server.md +170 -0
- package/docs/ui-views.md +322 -0
- package/package.json +7 -2
- package/schemas/receipt.v0.json +160 -0
- package/scripts/build-dmg.sh +11 -2
- package/scripts/build-menubar-app.sh +58 -7
- package/scripts/design-build.js +475 -0
- package/src/analytics/anatomy.js +467 -0
- package/src/analytics/branch-compare.js +159 -0
- package/src/analytics/cache-health.js +141 -0
- package/src/analytics/live-view.js +266 -0
- package/src/analytics/receipt-schema.js +214 -0
- package/src/analytics/receipt.js +709 -0
- package/src/analytics/rhythm.js +184 -0
- package/src/analytics/whatif.js +263 -0
- package/src/commands/budget-scopes.js +133 -0
- package/src/commands/doctor-checks.js +400 -0
- package/src/commands/guard.js +531 -0
- package/src/commands/hooks.js +238 -0
- package/src/commands/pricing-diff.js +316 -0
- package/src/commands/receipt.js +226 -0
- package/src/commands/team-serve.js +407 -0
- package/src/commands/week.js +86 -0
- package/src/core/annotations.js +97 -0
- package/src/core/budget.js +33 -0
- package/src/core/bundle.js +45 -2
- package/src/core/ingest.js +33 -0
- package/src/core/live-status.js +227 -2
- package/src/core/policy.js +103 -0
- package/src/core/receipt-note.js +123 -0
- package/src/core/repo.js +64 -0
- package/src/core/sync.js +163 -26
- package/src/core/team.js +0 -0
- package/src/export/html-snapshot.js +28 -1
- package/src/export/menubar.js +21 -0
- package/src/export/receipt-card.js +210 -0
- package/src/export/week-card.js +185 -0
- package/src/providers/mock/index.js +383 -52
- package/src/providers/openai/index.js +31 -1
- package/src/providers/otel/index.js +656 -0
- package/src/server/routes/annotations.js +42 -0
- package/src/server/routes/cache-health.js +95 -0
- package/src/server/routes/index.js +54 -0
- package/src/server/routes/session.js +157 -0
- package/src/server/server.js +47 -1
- package/src/ui/app.js +541 -308
- package/src/ui/charts.js +95 -0
- package/src/ui/first-run.js +144 -0
- package/src/ui/index.html +4 -1
- package/src/ui/palette.js +335 -0
- package/src/ui/styles/anatomy.css +117 -0
- package/src/ui/styles/annotations.css +40 -0
- package/src/ui/styles/branches.css +99 -0
- package/src/ui/styles/cache.css +6 -0
- package/src/ui/styles/first-run.css +31 -0
- package/src/ui/styles/live.css +100 -0
- package/src/ui/styles/palette.css +85 -0
- package/src/ui/styles/rhythm.css +8 -0
- package/src/ui/styles/whatif.css +55 -0
- package/src/ui/styles.css +303 -196
- package/src/ui/views/anatomy.js +567 -0
- package/src/ui/views/annotations.js +121 -0
- package/src/ui/views/branches.js +304 -0
- package/src/ui/views/cache.js +232 -0
- package/src/ui/views/index.js +85 -0
- package/src/ui/views/live.js +683 -0
- package/src/ui/views/rhythm.js +206 -0
- 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
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 <
|
|
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
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
}
|