standout 0.5.34 → 0.5.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/cli.js +7435 -565
  2. package/package.json +9 -3
  3. package/dist/ai-usage.d.ts +0 -164
  4. package/dist/ai-usage.js +0 -1306
  5. package/dist/api.d.ts +0 -10
  6. package/dist/api.js +0 -94
  7. package/dist/browser-session.d.ts +0 -3
  8. package/dist/browser-session.js +0 -227
  9. package/dist/cli.d.ts +0 -2
  10. package/dist/cursor.d.ts +0 -57
  11. package/dist/cursor.js +0 -562
  12. package/dist/dev-env.d.ts +0 -28
  13. package/dist/dev-env.js +0 -264
  14. package/dist/gather.d.ts +0 -82
  15. package/dist/gather.js +0 -922
  16. package/dist/heap.d.ts +0 -1
  17. package/dist/heap.js +0 -25
  18. package/dist/identity.d.ts +0 -8
  19. package/dist/identity.js +0 -29
  20. package/dist/linkedin-scrape.d.ts +0 -2
  21. package/dist/linkedin-scrape.js +0 -56
  22. package/dist/mcp.d.ts +0 -1
  23. package/dist/mcp.js +0 -25
  24. package/dist/payload.d.ts +0 -8
  25. package/dist/payload.js +0 -163
  26. package/dist/prompt.d.ts +0 -1
  27. package/dist/prompt.js +0 -247
  28. package/dist/proxy.d.ts +0 -1
  29. package/dist/proxy.js +0 -27
  30. package/dist/redact.d.ts +0 -1
  31. package/dist/redact.js +0 -37
  32. package/dist/tools.d.ts +0 -4
  33. package/dist/tools.js +0 -182
  34. package/dist/twitter-scrape.d.ts +0 -1
  35. package/dist/twitter-scrape.js +0 -48
  36. package/dist/wrapped/aggregate.d.ts +0 -6
  37. package/dist/wrapped/aggregate.js +0 -811
  38. package/dist/wrapped/chrono-critters.d.ts +0 -8
  39. package/dist/wrapped/chrono-critters.js +0 -32
  40. package/dist/wrapped/index.d.ts +0 -3
  41. package/dist/wrapped/index.js +0 -2
  42. package/dist/wrapped/mascots.d.ts +0 -2
  43. package/dist/wrapped/mascots.js +0 -35
  44. package/dist/wrapped/preview.d.ts +0 -1
  45. package/dist/wrapped/preview.js +0 -97
  46. package/dist/wrapped/render.d.ts +0 -14
  47. package/dist/wrapped/render.js +0 -1025
  48. package/dist/wrapped/tiers.d.ts +0 -9
  49. package/dist/wrapped/tiers.js +0 -73
  50. package/dist/wrapped/types.d.ts +0 -189
  51. package/dist/wrapped/types.js +0 -4
  52. package/dist/wrapped-client.d.ts +0 -16
  53. package/dist/wrapped-client.js +0 -166
  54. package/dist/wrapped-share.d.ts +0 -1
  55. package/dist/wrapped-share.js +0 -6
package/dist/ai-usage.js DELETED
@@ -1,1306 +0,0 @@
1
- import { createReadStream, existsSync, readdirSync, realpathSync, statSync, } from "fs";
2
- import { join } from "path";
3
- import { homedir } from "os";
4
- import { createInterface } from "readline";
5
- import { gatherCursor } from "./cursor.js";
6
- import { redactSecrets } from "./redact.js";
7
- const MAX_PROMPT_SAMPLES = 50;
8
- const MAX_PROMPT_LEN = 200;
9
- // Conversation collection (rolling 30-day window only) — feeds the prompt-insight
10
- // cards and the server-side collaboration-style analysis. All bounded + truncated.
11
- const MAX_PROMPTS_PER_SESSION = 8;
12
- const MAX_EXCHANGES = 500;
13
- // Display samples stay short; exchanges fed to the collaboration analysis keep
14
- // (effectively) the full prompt — secrets are redacted first, so this is safe.
15
- const MAX_ASSISTANT_LEN = 800;
16
- const MAX_EXCHANGE_PROMPT_LEN = 2000;
17
- const MAX_PROMPT_FREQUENCY = 12;
18
- // Style-analysis corpus: collect a bounded reservoir, then evenly sample down.
19
- const MAX_CONVO_RESERVOIR = 5000;
20
- const CONVO_SAMPLE_TARGET = 160;
21
- const MAX_CONVO_PER_SESSION = 6;
22
- const CONVO_SAMPLE_LEN = 400;
23
- // Heuristic for "did the user push back / correct the AI" — an active-vs-passive signal.
24
- const CORRECTION_RE = /\b(no|nope|actually|wrong|incorrect|instead|revert|undo|don'?t|stop|not what|that'?s not)\b/i;
25
- // Trivial continuations/acknowledgements + pasted shell commands aren't real
26
- // "asks" — exclude them from the most-common-ask ranking (still counted as turns).
27
- const TRIVIAL_ASK_RE = /^(y|n|yes|yep|yeah|ya|ok|okay|k|sure|go|go ahead|do it|continue|please continue|next|thanks|thank you|thx|ty|nice|cool|perfect|great|good|done|ship it|lgtm|👍|\.|\?)\b[\s.!]*$/i;
28
- const SHELL_COMMAND_RE = /^(npx|npm|pnpm|yarn|bun|git|cd|ls|cat|node|python|pip|brew|sudo|docker|curl|echo|mkdir|rm|mv|cp)\b/i;
29
- export function isLowSignalAsk(sample) {
30
- const t = sample.trim();
31
- if (t.length < 12)
32
- return true;
33
- if (TRIVIAL_ASK_RE.test(t))
34
- return true;
35
- if (SHELL_COMMAND_RE.test(t))
36
- return true;
37
- return false;
38
- }
39
- const USAGE_WINDOW_DAYS = 30;
40
- const USAGE_WINDOW_MS = USAGE_WINDOW_DAYS * 24 * 60 * 60 * 1000;
41
- // Idle gaps longer than this don't count as active time (lunch, overnight, a
42
- // session left open). There's no per-session ceiling — long focused sessions count fully.
43
- const MAX_ACTIVE_GAP_MINUTES = 15;
44
- // Parallel workspaces: Conductor workspaces AND git worktrees (the main way the
45
- // user runs many agents at once). Counted from the raw cwd before compactCwd
46
- // collapses worktrees into their repo.
47
- const PARALLEL_WORKSPACE_RE = /\/(conductor\/workspaces|\.worktrees|\.claude\/worktrees)\//;
48
- const SINGLE_EVENT_SESSION_MINUTES = 1;
49
- const INJECTED_PROMPT_PREFIXES = [
50
- "AGENTS.md instructions for",
51
- "# AGENTS.md",
52
- "<system-reminder>",
53
- "<system_instruction>",
54
- "<environment_context>",
55
- "<command-message>",
56
- "<command-name>",
57
- "Caveat: The messages below were generated",
58
- "Please continue the conversation",
59
- // Conductor auto-injected template — not user's words
60
- "Please review the changes in this workspace",
61
- // Codex/agent system instructions that land in the user role
62
- "Respond directly to the user's prompt",
63
- "Respond directly to the user",
64
- ];
65
- function isInjectedPrompt(text) {
66
- const head = text.trimStart().slice(0, 200);
67
- return INJECTED_PROMPT_PREFIXES.some((p) => head.startsWith(p));
68
- }
69
- function stripInjectedWrappers(text) {
70
- let out = text;
71
- // Repeatedly strip paired XML-ish wrappers (non-greedy) until stable
72
- let prev;
73
- do {
74
- prev = out;
75
- out = out.replace(/<([a-zA-Z][\w-]*)>[\s\S]*?<\/\1>/g, " ");
76
- } while (out !== prev && out.length < prev.length);
77
- // Strip any remaining stray opening or closing tags
78
- out = out.replace(/<\/?[a-zA-Z][\w-]*>/g, " ");
79
- return out;
80
- }
81
- const EXT_TO_LANG = {
82
- ts: "typescript",
83
- tsx: "typescript",
84
- js: "javascript",
85
- jsx: "javascript",
86
- mjs: "javascript",
87
- cjs: "javascript",
88
- py: "python",
89
- go: "go",
90
- rs: "rust",
91
- java: "java",
92
- kt: "kotlin",
93
- swift: "swift",
94
- rb: "ruby",
95
- php: "php",
96
- cpp: "cpp",
97
- cc: "cpp",
98
- hpp: "cpp",
99
- h: "c",
100
- c: "c",
101
- cs: "csharp",
102
- scala: "scala",
103
- sh: "shell",
104
- bash: "shell",
105
- zsh: "shell",
106
- sql: "sql",
107
- tf: "terraform",
108
- hcl: "terraform",
109
- yaml: "yaml",
110
- yml: "yaml",
111
- json: "json",
112
- md: "markdown",
113
- mdx: "markdown",
114
- css: "css",
115
- scss: "sass",
116
- sass: "sass",
117
- vue: "vue",
118
- svelte: "svelte",
119
- dart: "dart",
120
- lua: "lua",
121
- r: "r",
122
- ex: "elixir",
123
- exs: "elixir",
124
- erl: "erlang",
125
- clj: "clojure",
126
- };
127
- const FRAMEWORK_SIGNALS = [
128
- { match: /next\.config\.(js|ts|mjs)/, name: "nextjs" },
129
- { match: /nuxt\.config\.(js|ts)/, name: "nuxt" },
130
- { match: /remix\.config\.(js|ts)/, name: "remix" },
131
- { match: /astro\.config\.(js|ts|mjs)/, name: "astro" },
132
- { match: /svelte\.config\.(js|ts)/, name: "sveltekit" },
133
- { match: /angular\.json/, name: "angular" },
134
- { match: /vite\.config\.(js|ts|mjs)/, name: "vite" },
135
- { match: /webpack\.config\.(js|ts)/, name: "webpack" },
136
- { match: /prisma\/schema\.prisma/, name: "prisma" },
137
- { match: /drizzle\.config\.(js|ts)/, name: "drizzle" },
138
- { match: /tailwind\.config\.(js|ts|mjs)/, name: "tailwind" },
139
- { match: /tsconfig\.json/, name: "typescript" },
140
- { match: /jest\.config\.(js|ts)/, name: "jest" },
141
- { match: /vitest\.config\.(js|ts)/, name: "vitest" },
142
- { match: /playwright\.config\.(js|ts)/, name: "playwright" },
143
- { match: /Cargo\.toml/, name: "rust" },
144
- { match: /pyproject\.toml/, name: "python" },
145
- { match: /requirements\.txt/, name: "python" },
146
- { match: /Pipfile/, name: "python" },
147
- { match: /go\.mod/, name: "go" },
148
- { match: /Gemfile/, name: "ruby" },
149
- { match: /pom\.xml/, name: "maven" },
150
- { match: /build\.gradle/, name: "gradle" },
151
- { match: /Dockerfile/, name: "docker" },
152
- { match: /docker-compose\.(yml|yaml)/, name: "docker" },
153
- { match: /fastapi/i, name: "fastapi" },
154
- { match: /django/i, name: "django" },
155
- { match: /rails/i, name: "rails" },
156
- { match: /\.github\/workflows/, name: "github-actions" },
157
- { match: /terraform/i, name: "terraform" },
158
- { match: /supabase/i, name: "supabase" },
159
- { match: /vercel\.(json|ts)/, name: "vercel" },
160
- ];
161
- // Generated / vendored / build paths don't reflect what the user actually works
162
- // in — exclude them from the framework usage count so the stack weights toward
163
- // hand-authored code (e.g. Prisma migrations don't inflate "prisma").
164
- const GENERATED_PATH_RE = /(^|\/)(node_modules|dist|build|out|coverage|vendor|\.next|\.turbo|\.cache|__generated__|generated|migrations?)(\/|$)|\.generated\.|[-.]lock\.(json|ya?ml)$|package-lock\.json$/i;
165
- export function emptyRaw() {
166
- return {
167
- sessions: new Map(),
168
- toolCounts: new Map(),
169
- filePaths: new Set(),
170
- hourCounts: new Array(24).fill(0),
171
- weekendEvents: 0,
172
- totalEvents: 0,
173
- userMessageEvents: 0,
174
- userMessageWeekendEvents: 0,
175
- promptSamples: [],
176
- promptSamplesSeen: new Set(),
177
- activeDays: new Set(),
178
- promptCounts: new Map(),
179
- exchanges: [],
180
- userTurns: 0,
181
- correctionTurns: 0,
182
- convoSamples: [],
183
- convoTurns: 0,
184
- questionTurns: 0,
185
- promptCharsTotal: 0,
186
- seenMessageIds: new Set(),
187
- };
188
- }
189
- function inWindow(ts, opts) {
190
- return ((opts.windowStartMs === undefined || ts >= opts.windowStartMs) &&
191
- (opts.windowEndMs === undefined || ts < opts.windowEndMs));
192
- }
193
- // Build a dedup key that ignores trailing punctuation and case. Copy/paste
194
- // kickoff prompts (e.g. "run localhost and we are working on the /org part")
195
- // often appear identically across many parallel Conductor workspaces.
196
- function dedupKey(prompt) {
197
- return prompt
198
- .toLowerCase()
199
- .replace(/[\s\p{P}]+$/u, "")
200
- .slice(0, 160);
201
- }
202
- function pushPromptSample(raw, sample) {
203
- if (raw.promptSamples.length >= MAX_PROMPT_SAMPLES)
204
- return;
205
- const key = dedupKey(sample);
206
- if (raw.promptSamplesSeen.has(key))
207
- return;
208
- raw.promptSamplesSeen.add(key);
209
- raw.promptSamples.push(sample);
210
- }
211
- function recordPromptFrequency(raw, sample) {
212
- const key = dedupKey(sample);
213
- const existing = raw.promptCounts.get(key);
214
- if (existing)
215
- existing.count += 1;
216
- else
217
- raw.promptCounts.set(key, { text: sample, count: 1 });
218
- }
219
- function sanitizeAssistantText(text) {
220
- if (!text)
221
- return null;
222
- let out = stripInjectedWrappers(text.trim())
223
- .replace(/```[\s\S]*?```/g, " ")
224
- .replace(/\s+/g, " ")
225
- .trim();
226
- if (out.length < 8)
227
- return null;
228
- out = redactSecrets(out);
229
- if (out.length > MAX_ASSISTANT_LEN)
230
- out = out.slice(0, MAX_ASSISTANT_LEN) + "…";
231
- return out;
232
- }
233
- // Records one user prompt for the conversation corpus: bumps frequency, counts
234
- // the turn (and whether it reads as a correction), and parks it as the pending
235
- // prompt so the next assistant reply can be paired into an exchange.
236
- function recordConversationUserTurn(target, sessionId, sample, longSample) {
237
- const count = target.sessionPromptCounts.get(sessionId) ?? 0;
238
- if (count >= MAX_PROMPTS_PER_SESSION)
239
- return;
240
- target.sessionPromptCounts.set(sessionId, count + 1);
241
- // Real turns (for interaction signals) include everything; the "most common
242
- // ask" ranking excludes trivial acknowledgements + pasted shell commands.
243
- if (!isLowSignalAsk(sample))
244
- recordPromptFrequency(target.raw, sample);
245
- target.raw.userTurns += 1;
246
- if (CORRECTION_RE.test(sample))
247
- target.raw.correctionTurns += 1;
248
- // The exchange (fed to the collaboration LLM) keeps the fuller prompt.
249
- target.pendingPrompt.set(sessionId, longSample);
250
- }
251
- // Conversation-style signal: not subject to the per-session ask cap above, since
252
- // the reflexive mid-session "ok"/"go" turns are themselves part of the style.
253
- function recordConvoStyle(raw, text, ts, session) {
254
- raw.convoTurns += 1;
255
- raw.promptCharsTotal += text.length;
256
- if (text.includes("?"))
257
- raw.questionTurns += 1;
258
- if (raw.convoSamples.length < MAX_CONVO_RESERVOIR && Number.isFinite(ts)) {
259
- raw.convoSamples.push({
260
- text: text.length > CONVO_SAMPLE_LEN ? text.slice(0, CONVO_SAMPLE_LEN) : text,
261
- ts,
262
- session,
263
- });
264
- }
265
- }
266
- function recordConversationAssistantTurn(target, sessionId, text) {
267
- const pending = target.pendingPrompt.get(sessionId);
268
- if (!pending)
269
- return;
270
- if (target.raw.exchanges.length >= MAX_EXCHANGES)
271
- return;
272
- const reply = sanitizeAssistantText(text);
273
- if (!reply)
274
- return;
275
- target.raw.exchanges.push({ user: pending, assistant: reply });
276
- target.pendingPrompt.delete(sessionId);
277
- }
278
- function sanitizePrompt(raw, maxLen = MAX_PROMPT_LEN) {
279
- if (!raw)
280
- return null;
281
- // Drop messages that are nothing BUT injected content (AGENTS.md dumps, etc.)
282
- if (isInjectedPrompt(raw) && !/\n\n[\S]/.test(raw))
283
- return null;
284
- let text = stripInjectedWrappers(raw.trim());
285
- text = text
286
- .replace(/```[\s\S]*?```/g, " ")
287
- .replace(/^#\s*AGENTS\.md[^\n]*\n/i, "")
288
- .replace(/^Implement the following plan[:.]?\s*/i, "")
289
- .replace(/^#+\s*/gm, "")
290
- .replace(/\s+/g, " ")
291
- .trim();
292
- if (!text || text.length < 8)
293
- return null;
294
- text = redactSecrets(text);
295
- if (text.length > maxLen) {
296
- text = text.slice(0, maxLen) + "…";
297
- }
298
- return text;
299
- }
300
- function registerFile(raw, filePath) {
301
- if (!filePath || typeof filePath !== "string")
302
- return;
303
- if (raw.filePaths.size > 5000)
304
- return;
305
- raw.filePaths.add(filePath);
306
- }
307
- function recordSessionMeta(raw, sessionId, ts, cwd, model, version) {
308
- const minute = Math.floor(ts / 60_000);
309
- const existing = raw.sessions.get(sessionId);
310
- if (existing) {
311
- if (ts < existing.firstTs)
312
- existing.firstTs = ts;
313
- if (ts > existing.lastTs)
314
- existing.lastTs = ts;
315
- existing.eventTimestamps.add(minute);
316
- if (!existing.cwd && cwd)
317
- existing.cwd = cwd;
318
- if (model)
319
- existing.models.add(model);
320
- if (!existing.version && version)
321
- existing.version = version;
322
- }
323
- else {
324
- raw.sessions.set(sessionId, {
325
- firstTs: ts,
326
- lastTs: ts,
327
- eventTimestamps: new Set([minute]),
328
- cwd,
329
- models: model ? new Set([model]) : new Set(),
330
- modelTurnCounts: new Map(),
331
- inputTokens: 0,
332
- outputTokens: 0,
333
- cacheReadTokens: 0,
334
- cacheWriteTokens: 0,
335
- version,
336
- });
337
- }
338
- }
339
- function recordTurn(raw, sessionId, model, tokens) {
340
- const session = raw.sessions.get(sessionId);
341
- if (!session)
342
- return;
343
- if (model) {
344
- session.modelTurnCounts.set(model, (session.modelTurnCounts.get(model) ?? 0) + 1);
345
- }
346
- session.inputTokens += tokens.input ?? 0;
347
- session.outputTokens += tokens.output ?? 0;
348
- session.cacheReadTokens += tokens.cacheRead ?? 0;
349
- session.cacheWriteTokens += tokens.cacheWrite ?? 0;
350
- }
351
- function recordTemporal(raw, ts) {
352
- const d = new Date(ts);
353
- if (isNaN(d.getTime()))
354
- return;
355
- raw.totalEvents += 1;
356
- raw.hourCounts[d.getHours()] += 1;
357
- const day = d.getDay();
358
- if (day === 0 || day === 6)
359
- raw.weekendEvents += 1;
360
- raw.activeDays.add(d.toISOString().slice(0, 10));
361
- }
362
- // Weekend share is computed from messages the USER actually sent (not every log
363
- // line) so it reflects when they work, not assistant/tool log volume. Local tz.
364
- function recordUserMessageTemporal(raw, ts) {
365
- const d = new Date(ts);
366
- if (isNaN(d.getTime()))
367
- return;
368
- raw.userMessageEvents += 1;
369
- const day = d.getDay();
370
- if (day === 0 || day === 6)
371
- raw.userMessageWeekendEvents += 1;
372
- }
373
- // Worktree setups symlink each worktree's ~/.claude/projects dir back to one
374
- // physical dir, so readdir/stat (which follow symlinks) surface the same session
375
- // file under dozens of paths. Collapse to physical files so nothing is counted
376
- // more than once.
377
- export function dedupeByRealpath(files) {
378
- files.sort((a, b) => b.mtime - a.mtime);
379
- const seen = new Set();
380
- const out = [];
381
- for (const f of files) {
382
- let real;
383
- try {
384
- real = realpathSync(f.path);
385
- }
386
- catch {
387
- real = f.path;
388
- }
389
- if (seen.has(real))
390
- continue;
391
- seen.add(real);
392
- out.push(f.path);
393
- }
394
- return out;
395
- }
396
- function listClaudeCodeFiles() {
397
- const base = join(homedir(), ".claude", "projects");
398
- if (!existsSync(base))
399
- return [];
400
- const files = [];
401
- try {
402
- for (const projectDir of readdirSync(base)) {
403
- const full = join(base, projectDir);
404
- let entries;
405
- try {
406
- entries = readdirSync(full);
407
- }
408
- catch {
409
- continue;
410
- }
411
- for (const entry of entries) {
412
- if (!entry.endsWith(".jsonl"))
413
- continue;
414
- const filePath = join(full, entry);
415
- try {
416
- const stat = statSync(filePath);
417
- files.push({ path: filePath, mtime: stat.mtimeMs });
418
- }
419
- catch {
420
- // skip
421
- }
422
- }
423
- }
424
- }
425
- catch {
426
- return [];
427
- }
428
- return dedupeByRealpath(files);
429
- }
430
- function listCodexFiles() {
431
- const base = join(homedir(), ".codex", "sessions");
432
- if (!existsSync(base))
433
- return [];
434
- const files = [];
435
- const walk = (dir, depth) => {
436
- if (depth > 4)
437
- return;
438
- let entries;
439
- try {
440
- entries = readdirSync(dir);
441
- }
442
- catch {
443
- return;
444
- }
445
- for (const entry of entries) {
446
- const full = join(dir, entry);
447
- let stat;
448
- try {
449
- stat = statSync(full);
450
- }
451
- catch {
452
- continue;
453
- }
454
- if (stat.isDirectory()) {
455
- walk(full, depth + 1);
456
- }
457
- else if (entry.endsWith(".jsonl") && entry.startsWith("rollout-")) {
458
- files.push({ path: full, mtime: stat.mtimeMs });
459
- }
460
- }
461
- };
462
- walk(base, 0);
463
- return dedupeByRealpath(files);
464
- }
465
- async function streamLines(filePath, onLine) {
466
- const stream = createReadStream(filePath, { encoding: "utf-8" });
467
- const rl = createInterface({ input: stream, crlfDelay: Infinity });
468
- let n = 0;
469
- for await (const line of rl) {
470
- if (line)
471
- onLine(line);
472
- // Per-line work runs as a microtask drain, which starves macrotask timers
473
- // (the loading spinner). Yield a macrotask periodically so it keeps
474
- // animating during big scans.
475
- if ((++n & 2047) === 0)
476
- await new Promise((r) => setImmediate(r));
477
- }
478
- }
479
- export async function parseClaudeCodeFile(filePath, raw, opts = {}) {
480
- await parseClaudeCodeFileTargets(filePath, [{ raw, opts }]);
481
- }
482
- function makeTargets(targetInputs) {
483
- return targetInputs.map((target) => ({
484
- raw: target.raw,
485
- opts: target.opts ?? {},
486
- seenUserPromptThisSession: false,
487
- collectConversation: target.collectConversation ?? false,
488
- sessionPromptCounts: new Map(),
489
- pendingPrompt: new Map(),
490
- }));
491
- }
492
- export async function parseClaudeCodeFileTargets(filePath, targetInputs) {
493
- const targets = makeTargets(targetInputs);
494
- // Session id derivable from filename: <uuid>.jsonl
495
- const fileName = filePath.split("/").pop() || "";
496
- const fallbackSessionId = fileName.replace(/\.jsonl$/, "");
497
- let sessionId = fallbackSessionId;
498
- await streamLines(filePath, (line) => {
499
- let d;
500
- try {
501
- d = JSON.parse(line);
502
- }
503
- catch {
504
- return;
505
- }
506
- const ts = typeof d.timestamp === "string" ? Date.parse(d.timestamp) : NaN;
507
- if (typeof d.sessionId === "string")
508
- sessionId = d.sessionId;
509
- const cwd = typeof d.cwd === "string" ? d.cwd : null;
510
- const version = typeof d.version === "string" ? d.version : null;
511
- const type = d.type;
512
- const message = d.message;
513
- for (const target of targets) {
514
- const shouldCount = !isNaN(ts) && inWindow(ts, target.opts);
515
- if (shouldCount)
516
- recordTemporal(target.raw, ts);
517
- if (type === "user" && message) {
518
- const content = message.content;
519
- // Skip tool_result payloads (those are arrays of objects)
520
- if (shouldCount && typeof content === "string") {
521
- // Count real (non-injected) user messages for the weekend share.
522
- if (!isInjectedPrompt(content))
523
- recordUserMessageTemporal(target.raw, ts);
524
- const sample = sanitizePrompt(content);
525
- if (sample) {
526
- if (!target.seenUserPromptThisSession) {
527
- pushPromptSample(target.raw, sample);
528
- target.seenUserPromptThisSession = true;
529
- }
530
- // Exclude injected/system templates from the conversation corpus so
531
- // "most common ask" reflects real user prompts, not boilerplate.
532
- if (target.collectConversation && !isInjectedPrompt(content)) {
533
- const longSample = sanitizePrompt(content, MAX_EXCHANGE_PROMPT_LEN) ?? sample;
534
- recordConversationUserTurn(target, sessionId, sample, longSample);
535
- // Style counters are local-only (no LLM) — measure the full prompt,
536
- // not the 200-char display sample, so a late "?" still counts.
537
- const fullSample = sanitizePrompt(content, Number.POSITIVE_INFINITY) ?? sample;
538
- recordConvoStyle(target.raw, fullSample, ts, sessionId);
539
- }
540
- }
541
- }
542
- if (shouldCount) {
543
- recordSessionMeta(target.raw, sessionId, ts, cwd, null, version);
544
- }
545
- }
546
- else if (type === "assistant" && message) {
547
- const model = typeof message.model === "string" ? message.model : null;
548
- if (shouldCount) {
549
- recordSessionMeta(target.raw, sessionId, ts, cwd, model, version);
550
- }
551
- const usage = message.usage;
552
- const messageId = typeof message.id === "string" ? message.id : null;
553
- // One assistant message spans multiple JSONL lines (one per content
554
- // block), each repeating the same usage; resumed sessions also re-emit
555
- // prior messages. Count each message's tokens exactly once.
556
- if (shouldCount && usage) {
557
- const seen = messageId
558
- ? target.raw.seenMessageIds.has(messageId)
559
- : false;
560
- if (!seen) {
561
- if (messageId)
562
- target.raw.seenMessageIds.add(messageId);
563
- recordTurn(target.raw, sessionId, model, {
564
- input: usage.input_tokens,
565
- output: usage.output_tokens,
566
- cacheRead: usage.cache_read_input_tokens,
567
- cacheWrite: usage.cache_creation_input_tokens,
568
- });
569
- }
570
- }
571
- const content = message.content;
572
- if (shouldCount && Array.isArray(content)) {
573
- const textParts = [];
574
- for (const item of content) {
575
- if (item && typeof item === "object") {
576
- const itemType = item.type;
577
- if (itemType === "text" && target.collectConversation) {
578
- const text = item.text;
579
- if (typeof text === "string")
580
- textParts.push(text);
581
- }
582
- if (itemType === "tool_use") {
583
- const toolName = item.name;
584
- if (toolName) {
585
- target.raw.toolCounts.set(toolName, (target.raw.toolCounts.get(toolName) || 0) + 1);
586
- }
587
- const input = item
588
- .input;
589
- if (input) {
590
- registerFile(target.raw, input.file_path);
591
- registerFile(target.raw, input.path);
592
- registerFile(target.raw, input.notebook_path);
593
- }
594
- }
595
- }
596
- }
597
- if (target.collectConversation && textParts.length > 0) {
598
- recordConversationAssistantTurn(target, sessionId, textParts.join(" "));
599
- }
600
- }
601
- }
602
- else if (shouldCount) {
603
- recordSessionMeta(target.raw, sessionId, ts, cwd, null, version);
604
- }
605
- }
606
- });
607
- }
608
- export async function parseCodexFile(filePath, raw, opts = {}) {
609
- await parseCodexFileTargets(filePath, [{ raw, opts }]);
610
- }
611
- async function parseCodexFileTargets(filePath, targetInputs) {
612
- const targets = makeTargets(targetInputs);
613
- let sessionId = filePath.split("/").pop() || filePath;
614
- let cliVersion = null;
615
- let cwd = null;
616
- let model = null;
617
- await streamLines(filePath, (line) => {
618
- let d;
619
- try {
620
- d = JSON.parse(line);
621
- }
622
- catch {
623
- return;
624
- }
625
- const ts = typeof d.timestamp === "string" ? Date.parse(d.timestamp) : NaN;
626
- const type = d.type;
627
- const payload = d.payload;
628
- if (type === "session_meta" && payload) {
629
- if (typeof payload.id === "string")
630
- sessionId = payload.id;
631
- if (typeof payload.cwd === "string")
632
- cwd = payload.cwd;
633
- if (typeof payload.cli_version === "string")
634
- cliVersion = payload.cli_version;
635
- if (typeof payload.model === "string")
636
- model = payload.model;
637
- // Codex doesn't always set a `model` field — sniff the base_instructions
638
- // for "based on <model>" so we can label the provider/model in stats.
639
- if (!model) {
640
- const bi = payload.base_instructions;
641
- const head = bi?.text?.slice(0, 200) ?? "";
642
- const m = head.match(/based on (GPT-?\d+(?:[\.-]\d+)?(?:-mini|-nano|-turbo|-pro)?)/i);
643
- if (m)
644
- model = m[1].toLowerCase().replace(/\s+/g, "-");
645
- else if (typeof payload.model_provider === "string") {
646
- // Fall back to a generic provider label
647
- model = `${payload.model_provider}-default`;
648
- }
649
- }
650
- }
651
- for (const target of targets) {
652
- const shouldCount = !isNaN(ts) && inWindow(ts, target.opts);
653
- if (shouldCount)
654
- recordTemporal(target.raw, ts);
655
- if (shouldCount) {
656
- recordSessionMeta(target.raw, sessionId, ts, cwd, model, cliVersion);
657
- }
658
- if (type === "event_msg" && payload) {
659
- // Codex emits cumulative token totals; overwrite the session running totals.
660
- const info = payload.info;
661
- const totals = info?.total_token_usage;
662
- if (shouldCount && totals && target.raw.sessions.has(sessionId)) {
663
- const session = target.raw.sessions.get(sessionId);
664
- // Codex `total_token_usage.input_tokens` INCLUDES cached_input_tokens.
665
- // Store FRESH input (input - cached) + cache separately so it matches
666
- // how Claude Code reports (fresh input + separate cache).
667
- const cached = totals.cached_input_tokens ?? 0;
668
- const totalIn = totals.input_tokens ?? 0;
669
- session.inputTokens = Math.max(0, totalIn - cached);
670
- session.outputTokens = totals.output_tokens ?? session.outputTokens;
671
- session.cacheReadTokens = cached;
672
- if (model) {
673
- session.modelTurnCounts.set(model, (session.modelTurnCounts.get(model) ?? 0) + 1);
674
- }
675
- }
676
- }
677
- else if (type === "response_item" && payload) {
678
- const pType = payload.type;
679
- const role = payload.role;
680
- if ((pType === "function_call" || pType === "local_shell_call") &&
681
- shouldCount) {
682
- const toolName = payload.name ||
683
- (pType === "local_shell_call" ? "shell" : "function");
684
- target.raw.toolCounts.set(toolName, (target.raw.toolCounts.get(toolName) || 0) + 1);
685
- // Try to extract file path from arguments
686
- const argsRaw = payload.arguments;
687
- if (typeof argsRaw === "string") {
688
- try {
689
- const args = JSON.parse(argsRaw);
690
- registerFile(target.raw, args.path);
691
- registerFile(target.raw, args.file_path);
692
- if (typeof args.cmd === "string") {
693
- // Pull file-looking tokens from shell commands
694
- const tokens = args.cmd.match(/[\w./-]+\.[a-zA-Z0-9]{1,6}\b/g);
695
- if (tokens)
696
- tokens
697
- .slice(0, 5)
698
- .forEach((t) => registerFile(target.raw, t));
699
- }
700
- }
701
- catch {
702
- // skip
703
- }
704
- }
705
- }
706
- else if (shouldCount && role === "user") {
707
- const content = payload.content;
708
- if (Array.isArray(content)) {
709
- let countedUserMsg = false;
710
- for (const item of content) {
711
- if (item && typeof item === "object") {
712
- const text = item.text;
713
- if (typeof text === "string") {
714
- // One weekend-share tick per real user message line.
715
- if (!countedUserMsg && !isInjectedPrompt(text)) {
716
- recordUserMessageTemporal(target.raw, ts);
717
- countedUserMsg = true;
718
- }
719
- const sample = sanitizePrompt(text);
720
- if (sample) {
721
- if (!target.seenUserPromptThisSession) {
722
- pushPromptSample(target.raw, sample);
723
- target.seenUserPromptThisSession = true;
724
- }
725
- if (target.collectConversation && !isInjectedPrompt(text)) {
726
- const longSample = sanitizePrompt(text, MAX_EXCHANGE_PROMPT_LEN) ?? sample;
727
- recordConversationUserTurn(target, sessionId, sample, longSample);
728
- // Style counters are local-only (no LLM) — measure the
729
- // full prompt so a late "?" still counts.
730
- const fullSample = sanitizePrompt(text, Number.POSITIVE_INFINITY) ??
731
- sample;
732
- recordConvoStyle(target.raw, fullSample, ts, sessionId);
733
- }
734
- break;
735
- }
736
- }
737
- }
738
- }
739
- }
740
- }
741
- else if (shouldCount &&
742
- role === "assistant" &&
743
- target.collectConversation) {
744
- const content = payload.content;
745
- if (Array.isArray(content)) {
746
- const textParts = [];
747
- for (const item of content) {
748
- if (item && typeof item === "object") {
749
- const text = item.text;
750
- if (typeof text === "string")
751
- textParts.push(text);
752
- }
753
- }
754
- if (textParts.length > 0) {
755
- recordConversationAssistantTurn(target, sessionId, textParts.join(" "));
756
- }
757
- }
758
- }
759
- }
760
- }
761
- });
762
- }
763
- /**
764
- * Compute longest historical streak + current streak from a set of YYYY-MM-DD
765
- * day strings. Current streak counts consecutive days ending today or yesterday;
766
- * older trailing streaks count as 0 (broken).
767
- */
768
- export function computeStreaks(activeDays) {
769
- if (activeDays.size === 0)
770
- return { longest: 0, current: 0 };
771
- const sorted = [...activeDays].sort();
772
- const dayMs = 86_400_000;
773
- let longest = 1;
774
- let run = 1;
775
- for (let i = 1; i < sorted.length; i++) {
776
- const prev = Date.parse(sorted[i - 1]);
777
- const curr = Date.parse(sorted[i]);
778
- const diff = Math.round((curr - prev) / dayMs);
779
- if (diff === 1) {
780
- run += 1;
781
- if (run > longest)
782
- longest = run;
783
- }
784
- else if (diff > 1) {
785
- run = 1;
786
- }
787
- }
788
- // Current streak — walk backward from the latest day
789
- let current = 1;
790
- for (let i = sorted.length - 1; i > 0; i--) {
791
- const prev = Date.parse(sorted[i - 1]);
792
- const curr = Date.parse(sorted[i]);
793
- if (Math.round((curr - prev) / dayMs) === 1) {
794
- current += 1;
795
- }
796
- else {
797
- break;
798
- }
799
- }
800
- // Streak only counts as "current" if it ended today or yesterday
801
- const lastDay = Date.parse(sorted[sorted.length - 1]);
802
- const today = Date.parse(new Date().toISOString().slice(0, 10));
803
- const daysSinceLast = Math.round((today - lastDay) / dayMs);
804
- if (daysSinceLast > 1)
805
- current = 0;
806
- return { longest, current };
807
- }
808
- function estimateActiveDurationMs(minuteBuckets) {
809
- const sorted = [...new Set(minuteBuckets)]
810
- .filter((m) => Number.isFinite(m))
811
- .sort((a, b) => a - b)
812
- .map((m) => m * 60_000);
813
- if (sorted.length === 0)
814
- return 0;
815
- if (sorted.length === 1)
816
- return SINGLE_EVENT_SESSION_MINUTES * 60 * 1000;
817
- // Sum the time between consecutive events, but clamp each gap to 15 min so idle
818
- // stretches (lunch, overnight, a session left open) don't count as active time.
819
- // No per-session ceiling — a genuinely long focused session counts in full.
820
- const maxGapMs = MAX_ACTIVE_GAP_MINUTES * 60 * 1000;
821
- let total = 0;
822
- for (let i = 1; i < sorted.length; i++) {
823
- const gap = sorted[i] - sorted[i - 1];
824
- if (gap <= 0)
825
- continue;
826
- total += Math.min(gap, maxGapMs);
827
- }
828
- return total;
829
- }
830
- function compactCwd(cwd) {
831
- const home = homedir();
832
- const p = cwd.startsWith(home) ? cwd.replace(home, "~") : cwd;
833
- // Collapse git worktrees back to the repo they branch from so parallel
834
- // worktrees of one project don't each register as a separate project.
835
- // ~/work/standout/.worktrees/feat-x → ~/work/standout
836
- // ~/work/standout/.claude/worktrees/foo → ~/work/standout
837
- // Conductor paths (~/conductor/workspaces/...) are left intact so the
838
- // conductor-workspace split below still detects them.
839
- return p
840
- .replace(/\/\.claude\/worktrees\/[^/]+.*$/, "")
841
- .replace(/\/\.worktrees\/[^/]+.*$/, "");
842
- }
843
- // Split a session's events into active segments (consecutive events <= 15min
844
- // apart), so concurrency reflects real work, not a tab idle for days.
845
- function activeSegments(minuteBuckets) {
846
- const sorted = [...new Set(minuteBuckets)]
847
- .filter((m) => Number.isFinite(m))
848
- .sort((a, b) => a - b)
849
- .map((m) => m * 60_000);
850
- if (sorted.length === 0)
851
- return [];
852
- const maxGapMs = MAX_ACTIVE_GAP_MINUTES * 60 * 1000;
853
- const segs = [];
854
- let segStart = sorted[0];
855
- let prev = sorted[0];
856
- for (let i = 1; i < sorted.length; i++) {
857
- if (sorted[i] - prev > maxGapMs) {
858
- segs.push([segStart, prev]);
859
- segStart = sorted[i];
860
- }
861
- prev = sorted[i];
862
- }
863
- segs.push([segStart, prev]);
864
- return segs;
865
- }
866
- // Time-weighted concurrency: how many sessions overlap at once. Open-tab uses the
867
- // full first→last span (idle tabs included); active-juggle only the active segments.
868
- function computeConcurrency(sessions) {
869
- let longestMs = 0;
870
- const openSpans = [];
871
- const activeSpans = [];
872
- for (const s of sessions) {
873
- if (Number.isFinite(s.firstTs) && Number.isFinite(s.lastTs)) {
874
- const span = s.lastTs - s.firstTs;
875
- if (span > longestMs)
876
- longestMs = span;
877
- if (span > 0)
878
- openSpans.push([s.firstTs, s.lastTs]);
879
- }
880
- for (const seg of activeSegments(s.eventTimestamps))
881
- if (seg[1] > seg[0])
882
- activeSpans.push(seg);
883
- }
884
- // Time-weighted average / peak overlap over a set of intervals.
885
- const sweep = (spans) => {
886
- const events = [];
887
- for (const [a, b] of spans) {
888
- events.push([a, 1]);
889
- events.push([b, -1]);
890
- }
891
- events.sort((x, y) => x[0] - y[0] || x[1] - y[1]);
892
- let cur = 0;
893
- let peak = 0;
894
- let weighted = 0;
895
- let covered = 0;
896
- let multi = 0;
897
- let prev = null;
898
- for (const [t, delta] of events) {
899
- if (prev !== null && t > prev && cur > 0) {
900
- weighted += cur * (t - prev);
901
- covered += t - prev;
902
- if (cur >= 2)
903
- multi += t - prev;
904
- }
905
- cur += delta;
906
- if (cur > peak)
907
- peak = cur;
908
- prev = t;
909
- }
910
- const avg = covered > 0 ? weighted / covered : 0;
911
- return { avg, peak, multiFraction: covered > 0 ? multi / covered : 0 };
912
- };
913
- const open = sweep(openSpans);
914
- const active = sweep(activeSpans);
915
- return {
916
- open_tab_avg: +open.avg.toFixed(1),
917
- open_tab_peak: open.peak,
918
- longest_session_hours: +(longestMs / 1000 / 3600).toFixed(1),
919
- active_juggle_pct: +active.multiFraction.toFixed(2),
920
- };
921
- }
922
- // Evenly-spread sample of the conversation reservoir: cap per session, then stride
923
- // across the ts-sorted pool so the sample spans the whole window, not just the tail.
924
- function sampleConversation(reservoir) {
925
- const bySession = new Map();
926
- for (const r of reservoir) {
927
- const arr = bySession.get(r.session);
928
- if (arr)
929
- arr.push(r);
930
- else
931
- bySession.set(r.session, [{ text: r.text, ts: r.ts }]);
932
- }
933
- const picked = [];
934
- for (const arr of bySession.values()) {
935
- arr.sort((a, b) => a.ts - b.ts);
936
- if (arr.length <= MAX_CONVO_PER_SESSION) {
937
- picked.push(...arr);
938
- }
939
- else {
940
- const step = arr.length / MAX_CONVO_PER_SESSION;
941
- for (let i = 0; i < MAX_CONVO_PER_SESSION; i++)
942
- picked.push(arr[Math.floor(i * step)]);
943
- }
944
- }
945
- picked.sort((a, b) => a.ts - b.ts);
946
- if (picked.length <= CONVO_SAMPLE_TARGET)
947
- return picked.map((p) => p.text);
948
- const step = picked.length / CONVO_SAMPLE_TARGET;
949
- const out = [];
950
- for (let i = 0; i < CONVO_SAMPLE_TARGET; i++)
951
- out.push(picked[Math.floor(i * step)].text);
952
- return out;
953
- }
954
- export function finalize(raw) {
955
- if (raw.sessions.size === 0)
956
- return null;
957
- const sessionTimes = Array.from(raw.sessions.values());
958
- let totalDurationMs = 0;
959
- let firstTs = Infinity;
960
- let lastTs = -Infinity;
961
- const projectCounts = new Map();
962
- const parallelCwds = new Set();
963
- const modelsSet = new Set();
964
- const versionsSet = new Set();
965
- for (const s of sessionTimes) {
966
- totalDurationMs += estimateActiveDurationMs(s.eventTimestamps);
967
- if (s.firstTs < firstTs)
968
- firstTs = s.firstTs;
969
- if (s.lastTs > lastTs)
970
- lastTs = s.lastTs;
971
- if (s.cwd) {
972
- // Count distinct parallel workspaces from the RAW cwd before compactCwd
973
- // collapses worktrees — both Conductor and git worktrees count.
974
- if (PARALLEL_WORKSPACE_RE.test(s.cwd))
975
- parallelCwds.add(s.cwd);
976
- const key = compactCwd(s.cwd);
977
- projectCounts.set(key, (projectCounts.get(key) || 0) + 1);
978
- }
979
- s.models.forEach((m) => {
980
- if (m && !m.startsWith("<") && !m.includes("synthetic"))
981
- modelsSet.add(m);
982
- });
983
- if (s.version)
984
- versionsSet.add(s.version);
985
- }
986
- const languages = {};
987
- // Count how many distinct file paths touch each language/framework — a usage
988
- // proxy, so the stack orders by what the user actually works in. Generated /
989
- // vendored / build paths are skipped so they don't reflect hand-authored work.
990
- const frameworkCounts = {};
991
- for (const filePath of raw.filePaths) {
992
- if (GENERATED_PATH_RE.test(filePath))
993
- continue;
994
- const lowered = filePath.toLowerCase();
995
- const ext = lowered.split(".").pop();
996
- if (ext && EXT_TO_LANG[ext]) {
997
- const lang = EXT_TO_LANG[ext];
998
- languages[lang] = (languages[lang] || 0) + 1;
999
- }
1000
- for (const sig of FRAMEWORK_SIGNALS) {
1001
- if (sig.match.test(filePath))
1002
- frameworkCounts[sig.name] = (frameworkCounts[sig.name] ?? 0) + 1;
1003
- }
1004
- }
1005
- const frameworks = Object.keys(frameworkCounts).sort((a, b) => frameworkCounts[b] - frameworkCounts[a]);
1006
- // Peak hour = argmax of hourCounts
1007
- let peakHour = null;
1008
- let peakVal = 0;
1009
- raw.hourCounts.forEach((count, hour) => {
1010
- if (count > peakVal) {
1011
- peakVal = count;
1012
- peakHour = hour;
1013
- }
1014
- });
1015
- // Weekend share = fraction of messages the USER sent that landed on Sat/Sun
1016
- // (local tz). Falls back to all-event timing only if no user messages were seen.
1017
- const weekendRatio = raw.userMessageEvents > 0
1018
- ? raw.userMessageWeekendEvents / raw.userMessageEvents
1019
- : raw.totalEvents > 0
1020
- ? raw.weekendEvents / raw.totalEvents
1021
- : 0;
1022
- const toolCallCounts = {};
1023
- for (const [name, count] of raw.toolCounts) {
1024
- toolCallCounts[name] = count;
1025
- }
1026
- // Group Conductor parallel-workspace sessions into a single virtual project
1027
- // and surface their count separately. Conductor spawns Claude Code in
1028
- // ~/conductor/workspaces/<name> dirs, so each branch shows up as its own
1029
- // project in the raw data — useful for the wrapped "73 parallel branches" card.
1030
- let conductorWorkspaces = 0;
1031
- let conductorSessions = 0;
1032
- const nonConductorProjects = [];
1033
- for (const [cwd, count] of projectCounts.entries()) {
1034
- if (cwd.includes("/conductor/workspaces/")) {
1035
- conductorWorkspaces += 1;
1036
- conductorSessions += count;
1037
- }
1038
- else {
1039
- nonConductorProjects.push({ cwd, session_count: count });
1040
- }
1041
- }
1042
- const projects = nonConductorProjects
1043
- .sort((a, b) => b.session_count - a.session_count)
1044
- .slice(0, 20);
1045
- const streaks = computeStreaks(raw.activeDays);
1046
- // Per-session token + model + month aggregates
1047
- let totalInputTokens = 0;
1048
- let totalOutputTokens = 0;
1049
- let totalCacheReadTokens = 0;
1050
- let totalCacheWriteTokens = 0;
1051
- const modelSessionCounts = {};
1052
- const monthly = new Map();
1053
- for (const s of sessionTimes) {
1054
- totalInputTokens += s.inputTokens;
1055
- totalOutputTokens += s.outputTokens;
1056
- totalCacheReadTokens += s.cacheReadTokens;
1057
- totalCacheWriteTokens += s.cacheWriteTokens;
1058
- // Primary model for this session = the one with the most turns
1059
- let primary = null;
1060
- let primaryCount = 0;
1061
- for (const [m, n] of s.modelTurnCounts) {
1062
- if (n > primaryCount) {
1063
- primary = m;
1064
- primaryCount = n;
1065
- }
1066
- }
1067
- if (primary && !primary.startsWith("<") && !primary.includes("synthetic")) {
1068
- modelSessionCounts[primary] = (modelSessionCounts[primary] ?? 0) + 1;
1069
- }
1070
- // Bucket into month based on session start
1071
- if (isFinite(s.firstTs)) {
1072
- const monthKey = new Date(s.firstTs).toISOString().slice(0, 7); // YYYY-MM
1073
- let bucket = monthly.get(monthKey);
1074
- if (!bucket) {
1075
- bucket = {
1076
- sessions: 0,
1077
- durationMs: 0,
1078
- inputTokens: 0,
1079
- outputTokens: 0,
1080
- cacheReadTokens: 0,
1081
- cacheWriteTokens: 0,
1082
- modelTurnCounts: new Map(),
1083
- };
1084
- monthly.set(monthKey, bucket);
1085
- }
1086
- bucket.sessions += 1;
1087
- bucket.durationMs += estimateActiveDurationMs(s.eventTimestamps);
1088
- bucket.inputTokens += s.inputTokens;
1089
- bucket.outputTokens += s.outputTokens;
1090
- bucket.cacheReadTokens += s.cacheReadTokens;
1091
- bucket.cacheWriteTokens += s.cacheWriteTokens;
1092
- for (const [m, n] of s.modelTurnCounts) {
1093
- bucket.modelTurnCounts.set(m, (bucket.modelTurnCounts.get(m) ?? 0) + n);
1094
- }
1095
- }
1096
- }
1097
- const monthlyBuckets = [...monthly.entries()]
1098
- .sort(([a], [b]) => a.localeCompare(b))
1099
- .map(([month, b]) => {
1100
- let primary = null;
1101
- let primaryCount = 0;
1102
- for (const [m, n] of b.modelTurnCounts) {
1103
- if (n > primaryCount) {
1104
- primary = m;
1105
- primaryCount = n;
1106
- }
1107
- }
1108
- return {
1109
- month,
1110
- sessions: b.sessions,
1111
- duration_hours: +(b.durationMs / 1000 / 3600).toFixed(1),
1112
- input_tokens: b.inputTokens,
1113
- output_tokens: b.outputTokens,
1114
- cache_read_tokens: b.cacheReadTokens,
1115
- cache_write_tokens: b.cacheWriteTokens,
1116
- cache_tokens: b.cacheReadTokens + b.cacheWriteTokens,
1117
- primary_model: primary,
1118
- };
1119
- });
1120
- return {
1121
- total_sessions: raw.sessions.size,
1122
- active_days: raw.activeDays.size,
1123
- first_session: isFinite(firstTs) ? new Date(firstTs).toISOString() : null,
1124
- last_session: isFinite(lastTs) ? new Date(lastTs).toISOString() : null,
1125
- total_duration_hours: +(totalDurationMs / 1000 / 3600).toFixed(1),
1126
- tool_call_counts: toolCallCounts,
1127
- projects,
1128
- models: [...modelsSet],
1129
- versions: [...versionsSet].slice(0, 10),
1130
- peak_hour: peakHour,
1131
- hour_buckets: raw.hourCounts.slice(),
1132
- weekend_ratio: +weekendRatio.toFixed(2),
1133
- longest_streak_days: streaks.longest,
1134
- current_streak_days: streaks.current,
1135
- conductor_workspaces: conductorWorkspaces,
1136
- conductor_session_count: conductorSessions,
1137
- parallel_workspaces: parallelCwds.size,
1138
- languages,
1139
- frameworks: [...frameworks],
1140
- framework_counts: frameworkCounts,
1141
- prompt_samples: raw.promptSamples,
1142
- conversation_samples: sampleConversation(raw.convoSamples),
1143
- conversation: {
1144
- total_prompts: raw.convoTurns,
1145
- question_ratio: raw.convoTurns > 0
1146
- ? +(raw.questionTurns / raw.convoTurns).toFixed(2)
1147
- : 0,
1148
- avg_prompt_chars: raw.convoTurns > 0
1149
- ? Math.round(raw.promptCharsTotal / raw.convoTurns)
1150
- : 0,
1151
- },
1152
- concurrency: computeConcurrency(sessionTimes),
1153
- prompt_frequency: [...raw.promptCounts.values()]
1154
- .sort((a, b) => b.count - a.count)
1155
- .slice(0, MAX_PROMPT_FREQUENCY),
1156
- exchanges: raw.exchanges,
1157
- interaction: {
1158
- user_turns: raw.userTurns,
1159
- correction_turns: raw.correctionTurns,
1160
- tool_calls: [...raw.toolCounts.values()].reduce((a, b) => a + b, 0),
1161
- },
1162
- total_input_tokens: totalInputTokens,
1163
- total_output_tokens: totalOutputTokens,
1164
- total_cache_read_tokens: totalCacheReadTokens,
1165
- total_cache_write_tokens: totalCacheWriteTokens,
1166
- model_session_counts: modelSessionCounts,
1167
- monthly_buckets: monthlyBuckets,
1168
- };
1169
- }
1170
- function summarizeUsage(stats) {
1171
- return {
1172
- total_sessions: stats.total_sessions,
1173
- active_days: stats.active_days,
1174
- first_session: stats.first_session,
1175
- last_session: stats.last_session,
1176
- total_duration_hours: stats.total_duration_hours,
1177
- total_input_tokens: stats.total_input_tokens,
1178
- total_output_tokens: stats.total_output_tokens,
1179
- total_cache_read_tokens: stats.total_cache_read_tokens,
1180
- total_cache_write_tokens: stats.total_cache_write_tokens,
1181
- monthly_buckets: stats.monthly_buckets,
1182
- hour_buckets: stats.hour_buckets,
1183
- peak_hour: stats.peak_hour,
1184
- longest_streak_days: stats.longest_streak_days,
1185
- };
1186
- }
1187
- function emptyWindowStats(allTime) {
1188
- return {
1189
- total_sessions: 0,
1190
- active_days: 0,
1191
- first_session: null,
1192
- last_session: null,
1193
- total_duration_hours: 0,
1194
- tool_call_counts: {},
1195
- projects: [],
1196
- models: [],
1197
- versions: [],
1198
- peak_hour: null,
1199
- hour_buckets: new Array(24).fill(0),
1200
- weekend_ratio: 0,
1201
- longest_streak_days: 0,
1202
- current_streak_days: 0,
1203
- conductor_workspaces: 0,
1204
- conductor_session_count: 0,
1205
- parallel_workspaces: 0,
1206
- languages: {},
1207
- frameworks: [],
1208
- framework_counts: {},
1209
- prompt_samples: [],
1210
- conversation_samples: [],
1211
- conversation: { total_prompts: 0, question_ratio: 0, avg_prompt_chars: 0 },
1212
- concurrency: {
1213
- open_tab_avg: 0,
1214
- open_tab_peak: 0,
1215
- longest_session_hours: 0,
1216
- active_juggle_pct: 0,
1217
- },
1218
- prompt_frequency: [],
1219
- exchanges: [],
1220
- interaction: { user_turns: 0, correction_turns: 0, tool_calls: 0 },
1221
- total_input_tokens: 0,
1222
- total_output_tokens: 0,
1223
- total_cache_read_tokens: 0,
1224
- total_cache_write_tokens: 0,
1225
- model_session_counts: {},
1226
- monthly_buckets: [],
1227
- all_time: summarizeUsage(allTime),
1228
- };
1229
- }
1230
- function withAllTime(recent, allTime, previous = null) {
1231
- if (!allTime)
1232
- return recent;
1233
- const stats = recent ?? emptyWindowStats(allTime);
1234
- stats.all_time = summarizeUsage(allTime);
1235
- if (previous)
1236
- stats.previous_30d = summarizeUsage(previous);
1237
- return stats;
1238
- }
1239
- export async function gatherAiUsage() {
1240
- const claudeFiles = listClaudeCodeFiles();
1241
- const codexFiles = listCodexFiles();
1242
- const windowStartMs = Date.now() - USAGE_WINDOW_MS;
1243
- const previousWindowStartMs = windowStartMs - USAGE_WINDOW_MS;
1244
- const claudeRecentRaw = emptyRaw();
1245
- const claudePreviousRaw = emptyRaw();
1246
- const claudeAllRaw = emptyRaw();
1247
- const codexRecentRaw = emptyRaw();
1248
- const codexPreviousRaw = emptyRaw();
1249
- const codexAllRaw = emptyRaw();
1250
- const [cursor] = await Promise.all([
1251
- gatherCursor({ windowStartMs }).catch(() => null),
1252
- (async () => {
1253
- for (const file of claudeFiles) {
1254
- try {
1255
- await parseClaudeCodeFileTargets(file, [
1256
- {
1257
- raw: claudeRecentRaw,
1258
- opts: { windowStartMs },
1259
- collectConversation: true,
1260
- },
1261
- {
1262
- raw: claudePreviousRaw,
1263
- opts: {
1264
- windowStartMs: previousWindowStartMs,
1265
- windowEndMs: windowStartMs,
1266
- },
1267
- },
1268
- { raw: claudeAllRaw },
1269
- ]);
1270
- }
1271
- catch {
1272
- // skip
1273
- }
1274
- }
1275
- })(),
1276
- (async () => {
1277
- for (const file of codexFiles) {
1278
- try {
1279
- await parseCodexFileTargets(file, [
1280
- {
1281
- raw: codexRecentRaw,
1282
- opts: { windowStartMs },
1283
- collectConversation: true,
1284
- },
1285
- {
1286
- raw: codexPreviousRaw,
1287
- opts: {
1288
- windowStartMs: previousWindowStartMs,
1289
- windowEndMs: windowStartMs,
1290
- },
1291
- },
1292
- { raw: codexAllRaw },
1293
- ]);
1294
- }
1295
- catch {
1296
- // skip
1297
- }
1298
- }
1299
- })(),
1300
- ]);
1301
- return {
1302
- claude_code: withAllTime(finalize(claudeRecentRaw), finalize(claudeAllRaw), finalize(claudePreviousRaw)),
1303
- codex: withAllTime(finalize(codexRecentRaw), finalize(codexAllRaw), finalize(codexPreviousRaw)),
1304
- cursor,
1305
- };
1306
- }