@ascenda-one/history-import 0.1.12

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.
@@ -0,0 +1,779 @@
1
+ /**
2
+ * Claude Code transcript extractor — first in the evaporation order.
3
+ *
4
+ * Store: `<snapshot>/projects/<project-slug>/<sessionId>.jsonl`, one JSON
5
+ * object per line, PLUS `<snapshot>/projects/<project-slug>/<sessionId>/**\/*.jsonl`
6
+ * — subagent (`Task` tool) transcripts, verified nested three levels deep as
7
+ * `<sessionId>/subagents/agent-<id>.jsonl` on a real store (115 of them
8
+ * alongside 324 top-level sessions, 2026-08-18) but walked recursively here
9
+ * rather than hardcoded to that one directory name, since nothing about the
10
+ * nesting is documented. A store's `.last-cleanup` purge can also remove a
11
+ * top-level transcript while leaving its subagent directory behind, so a
12
+ * session fold is seeded from whichever appears first and merged with
13
+ * whichever appears second — never dropped for missing its sibling.
14
+ *
15
+ * Every subagent line carries the PARENT session's `sessionId` (confirmed:
16
+ * `isSidechain: true`, `agentId` set, `sessionId` equal to the orchestrating
17
+ * conversation's) — it is not a session of its own. Its `user` lines are the
18
+ * orchestrator's task instructions and its own tool-result round-trips, never
19
+ * something a human typed, so none of them may become an `ai_prompt_submitted`
20
+ * event or count toward `promptCount` — that would double the load-bearing
21
+ * human-prompt classifier's error rate for exactly the sessions that lean on
22
+ * subagents most. Its token spend, tool failures and edits are still real
23
+ * work done in service of the parent session, so those fold into the same
24
+ * session totals with a `subagent*` breakdown alongside so the merge is
25
+ * visible rather than silent.
26
+ *
27
+ * Key order varies per line type and `type` is often not the first key, so
28
+ * nothing here greps — every line is JSON-parsed and dispatched on its
29
+ * top-level `type`.
30
+ *
31
+ * The single most important classification: most `user` lines are NOT human
32
+ * prompts. Tool results come back on user-role lines (a `toolUseResult` key,
33
+ * or a content array of `tool_result` items). On the verified store ~108k
34
+ * user lines reduce to a far smaller set of actual typed prompts — conflating
35
+ * them would inflate every prompt metric by ~10x and poison the baseline.
36
+ *
37
+ * Beyond the prompt/session/after-hours signals already shipped, this file
38
+ * also extracts (verified against a real 439-transcript store, 2026-08-18 —
39
+ * counts land within the range a fresh snapshot would produce):
40
+ * - **Compaction** (`system` lines, `subtype: "compact_boundary"`,
41
+ * `compactMetadata.trigger`): per-session counts, split manual/auto to
42
+ * match the live hooks' `context_compression_manual`/`_auto` vocabulary
43
+ * (`ascenda-claude-code-hooks/src/mapClaudeEvent.ts`).
44
+ * - **Failures**: `toolUseResult` tool-result content items with
45
+ * `is_error: true` (the authoritative marker — more reliable than
46
+ * string-sniffing `toolUseResult`'s text, which several tools skip) plus
47
+ * `system` lines with `subtype: "api_error"`. This is the direct fix for
48
+ * the historical-import honesty audit's F1: an imported baseline with zero
49
+ * failure/compaction evidence was reading as a fabricated "perfect" strain
50
+ * score rather than "no evidence collected".
51
+ * - **Context pressure**: per-assistant-turn `input_tokens +
52
+ * cache_read_input_tokens + cache_creation_input_tokens`, peak per
53
+ * session, expressed as a fraction of an assumed 200k-token window (see
54
+ * `ASSUMED_CONTEXT_WINDOW_TOKENS` for why that number is a documented
55
+ * approximation, not a fact the transcript records).
56
+ * - **Human-corrected edits**: `toolUseResult.userModified === true`.
57
+ * - **Lines changed**: `toolUseResult.structuredPatch[].lines`, counted
58
+ * (`+`/`-` prefixes) and discarded immediately — never retained as text.
59
+ * - **Correction cadence**: human prompts arriving <2 minutes after the
60
+ * previous one in the same session (a proxy for "that answer needed
61
+ * immediate correction", not idle thinking time).
62
+ * - **Abandoned prompts**: `queue-operation` `remove` entries whose content
63
+ * is not the synthetic `<task-notification>` wrapper — i.e. a human typed
64
+ * something into the queue and then deleted it before it sent. The prompt
65
+ * text itself is read only to check that one prefix and is never retained.
66
+ * - **Active minutes**: every known-line timestamp (main thread and
67
+ * subagents, merged) is gap-split at 5 minutes — on this store 95.8% of
68
+ * consecutive-line gaps are under 30 seconds and the distribution falls
69
+ * off sharply past 5 minutes, so a gap past that point is someone stepping
70
+ * away, not thinking. Wall-clock `sessionMinutes` is idle-inflated for any
71
+ * session spanning hours; `activeMinutes` is the gap-split alternative.
72
+ *
73
+ * Emission (aggregate before shipping — never one event per line):
74
+ * - `ai_prompt_submitted` per HUMAN prompt (canonical type, so the existing
75
+ * demand/baseline readers count it natively), provenance historical_direct.
76
+ * - `create_focus_session` per session: counts, token totals, model mix,
77
+ * duration, compaction/failure/edit/context signals — provenance
78
+ * historical_derived.
79
+ * - `after_hours_ai_session` per session with ≥1 human prompt in the
80
+ * after-hours window (canonical type), provenance historical_derived.
81
+ * - `context_compression_manual` / `context_compression_auto` per session
82
+ * with ≥1 of that trigger (canonical types, aggregate — same "one event
83
+ * per session, count in the metric" shape as `after_hours_ai_session`, not
84
+ * one event per compaction).
85
+ * - `tool_failure` per session with ≥1 failure (canonical type, aggregate,
86
+ * same shape).
87
+ * - one `extraction_epoch` for the store's observed window. Local only:
88
+ * the shipper filters it out, since it describes the read rather than
89
+ * anyone's work and has no canonical catalog type.
90
+ * Metrics carry counts, ids and timestamps only — never prompt/response text.
91
+ */
92
+ import * as fs from "node:fs/promises";
93
+ import * as path from "node:path";
94
+ import { bucketDurationMs, bucketLinesChanged, isAfterHours } from "@ascenda-one/tool-kit";
95
+ import { HISTORICAL_PROVENANCE } from "../types.js";
96
+ import { sliceSessionByLocalDay } from "../daySlice.js";
97
+ /** Line types the extractor reads fields from. */
98
+ export const KNOWN_CLAUDE_LINE_TYPES = [
99
+ "user",
100
+ "assistant",
101
+ "attachment",
102
+ "system",
103
+ "queue-operation",
104
+ "last-prompt",
105
+ "custom-title"
106
+ ];
107
+ /** Line types observed in real stores that carry nothing the import needs.
108
+ * Recognised so they don't count as schema drift. */
109
+ export const META_CLAUDE_LINE_TYPES = new Set([
110
+ "ai-title",
111
+ "mode",
112
+ "pr-link",
113
+ "worktree-state",
114
+ "relocated",
115
+ "create",
116
+ "file",
117
+ "directory",
118
+ "image",
119
+ "file-history-snapshot",
120
+ "summary"
121
+ ]);
122
+ export function sniffClaudeLine(line) {
123
+ const trimmed = line.trim();
124
+ if (trimmed === "")
125
+ return { kind: "unparsed", raw: line };
126
+ let parsed;
127
+ try {
128
+ parsed = JSON.parse(trimmed);
129
+ }
130
+ catch {
131
+ return { kind: "unparsed", raw: line };
132
+ }
133
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
134
+ return { kind: "unparsed", raw: line };
135
+ }
136
+ const record = parsed;
137
+ const type = record.type;
138
+ if (typeof type !== "string")
139
+ return { kind: "unparsed", raw: line };
140
+ if (!KNOWN_CLAUDE_LINE_TYPES.includes(type)) {
141
+ return { kind: "unknown", type };
142
+ }
143
+ const message = record.message;
144
+ return {
145
+ kind: type,
146
+ sourceVersion: typeof record.version === "string" ? record.version : null,
147
+ occurredAt: typeof record.timestamp === "string" ? record.timestamp : null,
148
+ sessionId: typeof record.sessionId === "string" ? record.sessionId : null,
149
+ model: message && typeof message.model === "string" ? message.model : null
150
+ };
151
+ }
152
+ /** A user-role line that is actually a tool result round-trip, not a person
153
+ * typing. Two independent markers observed in real stores; either decides. */
154
+ export function isToolResultUserLine(record) {
155
+ if ("toolUseResult" in record)
156
+ return true;
157
+ const message = record.message;
158
+ const content = message?.content;
159
+ if (Array.isArray(content)) {
160
+ return content.some((item) => typeof item === "object" &&
161
+ item !== null &&
162
+ item.type === "tool_result");
163
+ }
164
+ return false;
165
+ }
166
+ /**
167
+ * A tool-result round-trip that failed. `is_error: true` on the content
168
+ * item is the authoritative marker — verified against string-sniffing
169
+ * `toolUseResult`'s text instead (which several tools' error text doesn't
170
+ * even start with "Error"): on a real store `is_error` catches 2,044 failures
171
+ * against only 1,957 the string heuristic would, so the flag is read
172
+ * directly rather than guessed at from formatting.
173
+ */
174
+ export function isToolFailureLine(record) {
175
+ const message = record.message;
176
+ const content = message?.content;
177
+ if (!Array.isArray(content))
178
+ return false;
179
+ return content.some((item) => typeof item === "object" &&
180
+ item !== null &&
181
+ item.type === "tool_result" &&
182
+ item.is_error === true);
183
+ }
184
+ /**
185
+ * Net lines added/removed across an Edit tool's `structuredPatch` hunks.
186
+ * Reads each hunk's `lines` array only to count `+`/`-` prefixes — the diff
187
+ * text itself is never retained past this loop, matching the metrics-only
188
+ * rule the rest of the extractor follows.
189
+ */
190
+ function countChangedLines(structuredPatch) {
191
+ let count = 0;
192
+ for (const hunk of structuredPatch) {
193
+ if (typeof hunk !== "object" || hunk === null)
194
+ continue;
195
+ const lines = hunk.lines;
196
+ if (!Array.isArray(lines))
197
+ continue;
198
+ for (const l of lines) {
199
+ if (typeof l === "string" && (l.startsWith("+") || l.startsWith("-")))
200
+ count += 1;
201
+ }
202
+ }
203
+ return count;
204
+ }
205
+ /** A `queue-operation` `remove` whose content is a human-typed prompt that
206
+ * was deleted before it sent — as opposed to the synthetic
207
+ * `<task-notification>` wrapper a background task's completion removes from
208
+ * the queue. Only the prefix is inspected; the prompt text is never stored. */
209
+ function isAbandonedPromptRemoval(content) {
210
+ return typeof content === "string" && content.length > 0 && !content.startsWith("<task-notification>");
211
+ }
212
+ function newFold(sessionId, projectSlug) {
213
+ return {
214
+ sessionId,
215
+ projectSlug,
216
+ cwd: null,
217
+ gitBranch: null,
218
+ sourceVersion: null,
219
+ firstTs: null,
220
+ lastTs: null,
221
+ humanPrompts: 0,
222
+ afterHoursPrompts: 0,
223
+ assistantTurns: 0,
224
+ toolResults: 0,
225
+ queuedPrompts: 0,
226
+ unknownLines: 0,
227
+ unparsedLines: 0,
228
+ inputTokens: 0,
229
+ outputTokens: 0,
230
+ cacheReadTokens: 0,
231
+ models: new Map(),
232
+ lastMainModel: null,
233
+ modelSwitchCount: 0,
234
+ compactionCount: 0,
235
+ compactionManualCount: 0,
236
+ compactionAutoCount: 0,
237
+ toolResultErrorCount: 0,
238
+ apiErrorCount: 0,
239
+ contextWindowPeakTokens: 0,
240
+ userModifiedEditCount: 0,
241
+ linesChangedTotal: 0,
242
+ abandonedPromptCount: 0,
243
+ subagentTranscripts: 0,
244
+ subagentPrompts: 0,
245
+ subagentAssistantTurns: 0,
246
+ subagentTokensTotal: 0,
247
+ timelinePoints: [],
248
+ humanPromptTimestamps: []
249
+ };
250
+ }
251
+ function asNumber(value) {
252
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
253
+ }
254
+ /**
255
+ * Session wall-clock duration in ms, or null when the fold has no usable
256
+ * timeline. Kept separate from bucketing so the exact minute count is
257
+ * available for `sessionMinutes` alongside the bucket derived from it.
258
+ */
259
+ function sessionDurationMs(fold) {
260
+ if (!fold.firstTs || !fold.lastTs)
261
+ return null;
262
+ const ms = Date.parse(fold.lastTs) - Date.parse(fold.firstTs);
263
+ return Number.isFinite(ms) && ms >= 0 ? ms : null;
264
+ }
265
+ /**
266
+ * Buckets via tool-kit's `bucketDurationMs` rather than a local re-derivation
267
+ * of the same thresholds — this extractor used to run its own "0-5m" |
268
+ * "5-30m" | "30m-2h" | "2-8h" | "8-24h" | "24h+" vocabulary, a third dialect
269
+ * that matched neither the live collectors' tool-contract buckets nor the
270
+ * backend's reader. Reusing the shared function makes that drift structurally
271
+ * impossible instead of just documented.
272
+ */
273
+ function durationBucketOf(fold) {
274
+ const ms = sessionDurationMs(fold);
275
+ if (ms === null)
276
+ return "unknown";
277
+ return bucketDurationMs(ms) ?? "unknown";
278
+ }
279
+ /**
280
+ * Gap-split active time: sum only the gaps between consecutive known-line
281
+ * timestamps that are 5 minutes or less. On a real store 95.8% of
282
+ * consecutive-line gaps are under 30 seconds and the distribution thins out
283
+ * sharply past 5 minutes (0.4% land 5-10m, 0.6% land past 30m) — a gap past
284
+ * that point reads as "stepped away", not "reading the response". A session
285
+ * spanning 8-24h wall-clock is mostly idle by this measure, which is the
286
+ * point: `sessionMinutes` (wall clock) stays available for anyone who wants
287
+ * it, `activeMinutes` is the honest alternative.
288
+ */
289
+ const ACTIVE_GAP_MS = 5 * 60_000;
290
+ function activeMinutesOf(fold) {
291
+ if (fold.timelinePoints.length < 2)
292
+ return 0;
293
+ const sorted = [...fold.timelinePoints].sort((a, b) => a - b);
294
+ let activeMs = 0;
295
+ for (let i = 1; i < sorted.length; i++) {
296
+ const gap = sorted[i] - sorted[i - 1];
297
+ if (gap > 0 && gap <= ACTIVE_GAP_MS)
298
+ activeMs += gap;
299
+ }
300
+ return Math.round(activeMs / 60_000);
301
+ }
302
+ /** A reprompt inside 2 minutes of the previous human prompt in the same
303
+ * session — evidence the prior answer needed immediate correction rather
304
+ * than the user having gone off to think. */
305
+ const RAPID_REPROMPT_MS = 2 * 60_000;
306
+ function rapidRepromptCountOf(fold) {
307
+ const timestamps = fold.humanPromptTimestamps.filter((t) => t !== null);
308
+ let count = 0;
309
+ for (let i = 1; i < timestamps.length; i++) {
310
+ const gap = Date.parse(timestamps[i]) - Date.parse(timestamps[i - 1]);
311
+ if (Number.isFinite(gap) && gap >= 0 && gap < RAPID_REPROMPT_MS)
312
+ count += 1;
313
+ }
314
+ return count;
315
+ }
316
+ /**
317
+ * Standard Claude API context window. A documented approximation, not a fact
318
+ * the transcript records: a session running an extended/1M-token context
319
+ * would read as a smaller-than-real peak fraction. Honest under-reporting
320
+ * beats a silently wrong ceiling — the same "claim never outruns its
321
+ * evidence" rule the rest of this package follows.
322
+ */
323
+ const ASSUMED_CONTEXT_WINDOW_TOKENS = 200_000;
324
+ function contextWindowPeakPctOf(fold) {
325
+ if (fold.contextWindowPeakTokens <= 0)
326
+ return 0;
327
+ const pct = fold.contextWindowPeakTokens / ASSUMED_CONTEXT_WINDOW_TOKENS;
328
+ return Math.round(Math.min(1, pct) * 1000) / 1000;
329
+ }
330
+ /**
331
+ * Fold one transcript file's lines into an existing fold. Called once for a
332
+ * session's primary transcript (`isSidechain: false`) and once per subagent
333
+ * transcript found under its directory (`isSidechain: true`) — the same fold
334
+ * accumulates both, because a subagent's work happened in service of the one
335
+ * session a human was driving, even though its `user` lines are never a
336
+ * human prompt.
337
+ */
338
+ async function foldLinesInto(fold, filePath, opts) {
339
+ // Transcripts run to hundreds of MB; read line-wise, never whole-file.
340
+ const handle = await fs.open(filePath);
341
+ try {
342
+ for await (const line of handle.readLines({ encoding: "utf8" })) {
343
+ const sniffed = sniffClaudeLine(line);
344
+ if (sniffed.kind === "unparsed") {
345
+ if (line.trim() !== "")
346
+ fold.unparsedLines += 1;
347
+ continue;
348
+ }
349
+ if (sniffed.kind === "unknown") {
350
+ fold.unknownLines += 1;
351
+ continue;
352
+ }
353
+ // Trust the store's own id over the filename when they disagree — main
354
+ // thread only. A subagent's own sessionId already equals its parent's,
355
+ // so correcting off it here could only ever be a no-op or a mistake.
356
+ if (!opts.isSidechain && sniffed.sessionId && fold.sessionId !== sniffed.sessionId) {
357
+ fold.sessionId = sniffed.sessionId;
358
+ }
359
+ if (sniffed.sourceVersion)
360
+ fold.sourceVersion = sniffed.sourceVersion;
361
+ if (sniffed.occurredAt) {
362
+ if (!fold.firstTs || sniffed.occurredAt < fold.firstTs)
363
+ fold.firstTs = sniffed.occurredAt;
364
+ if (!fold.lastTs || sniffed.occurredAt > fold.lastTs)
365
+ fold.lastTs = sniffed.occurredAt;
366
+ const ms = Date.parse(sniffed.occurredAt);
367
+ if (Number.isFinite(ms))
368
+ fold.timelinePoints.push(ms);
369
+ }
370
+ switch (sniffed.kind) {
371
+ case "user": {
372
+ // Re-parse is cheap relative to disk; the sniffer deliberately does
373
+ // not carry the whole record.
374
+ const record = JSON.parse(line);
375
+ if (typeof record.cwd === "string" && !fold.cwd)
376
+ fold.cwd = record.cwd;
377
+ if (typeof record.gitBranch === "string" && !fold.gitBranch) {
378
+ fold.gitBranch = record.gitBranch;
379
+ }
380
+ if (isToolFailureLine(record))
381
+ fold.toolResultErrorCount += 1;
382
+ if (isToolResultUserLine(record)) {
383
+ fold.toolResults += 1;
384
+ const tur = record.toolUseResult;
385
+ if (tur && typeof tur === "object" && !Array.isArray(tur)) {
386
+ const turRecord = tur;
387
+ if (turRecord.userModified === true)
388
+ fold.userModifiedEditCount += 1;
389
+ if (Array.isArray(turRecord.structuredPatch)) {
390
+ fold.linesChangedTotal += countChangedLines(turRecord.structuredPatch);
391
+ }
392
+ }
393
+ }
394
+ else if (opts.isSidechain) {
395
+ // A subagent's task instruction — not a tool round-trip, and not
396
+ // something a human typed either. Counted so it's visible, never
397
+ // folded into promptCount.
398
+ fold.subagentPrompts += 1;
399
+ }
400
+ else {
401
+ fold.humanPrompts += 1;
402
+ if (sniffed.occurredAt && isAfterHours(new Date(sniffed.occurredAt))) {
403
+ fold.afterHoursPrompts += 1;
404
+ }
405
+ fold.humanPromptTimestamps.push(sniffed.occurredAt);
406
+ }
407
+ break;
408
+ }
409
+ case "assistant": {
410
+ const record = JSON.parse(line);
411
+ const usage = record.message?.usage;
412
+ if (opts.isSidechain) {
413
+ fold.subagentAssistantTurns += 1;
414
+ if (usage) {
415
+ fold.subagentTokensTotal +=
416
+ asNumber(usage.input_tokens) +
417
+ asNumber(usage.output_tokens) +
418
+ asNumber(usage.cache_read_input_tokens);
419
+ }
420
+ break;
421
+ }
422
+ fold.assistantTurns += 1;
423
+ if (sniffed.model) {
424
+ fold.models.set(sniffed.model, (fold.models.get(sniffed.model) ?? 0) + 1);
425
+ if (fold.lastMainModel && fold.lastMainModel !== sniffed.model) {
426
+ fold.modelSwitchCount += 1;
427
+ }
428
+ fold.lastMainModel = sniffed.model;
429
+ }
430
+ if (usage) {
431
+ fold.inputTokens += asNumber(usage.input_tokens);
432
+ fold.outputTokens += asNumber(usage.output_tokens);
433
+ fold.cacheReadTokens += asNumber(usage.cache_read_input_tokens);
434
+ const contextTokens = asNumber(usage.input_tokens) +
435
+ asNumber(usage.cache_read_input_tokens) +
436
+ asNumber(usage.cache_creation_input_tokens);
437
+ if (contextTokens > fold.contextWindowPeakTokens)
438
+ fold.contextWindowPeakTokens = contextTokens;
439
+ }
440
+ break;
441
+ }
442
+ case "system": {
443
+ // Not observed inside subagent transcripts on the verified store
444
+ // (compaction and api_error both live on the main thread only),
445
+ // but nothing here assumes that stays true — it just naturally
446
+ // folds into the same session counters if it ever does.
447
+ const record = JSON.parse(line);
448
+ if (record.subtype === "compact_boundary") {
449
+ fold.compactionCount += 1;
450
+ const trigger = record.compactMetadata?.trigger;
451
+ if (trigger === "manual")
452
+ fold.compactionManualCount += 1;
453
+ else if (trigger === "auto")
454
+ fold.compactionAutoCount += 1;
455
+ }
456
+ else if (record.subtype === "api_error") {
457
+ fold.apiErrorCount += 1;
458
+ }
459
+ break;
460
+ }
461
+ case "queue-operation": {
462
+ const record = JSON.parse(line);
463
+ if (record.operation === "enqueue") {
464
+ fold.queuedPrompts += 1;
465
+ }
466
+ else if (record.operation === "remove" && isAbandonedPromptRemoval(record.content)) {
467
+ fold.abandonedPromptCount += 1;
468
+ }
469
+ break;
470
+ }
471
+ default:
472
+ break; // attachment / last-prompt / custom-title: window only.
473
+ }
474
+ }
475
+ }
476
+ finally {
477
+ await handle.close();
478
+ }
479
+ }
480
+ async function foldTranscript(filePath, projectSlug) {
481
+ const fold = newFold(path.basename(filePath, ".jsonl"), projectSlug);
482
+ await foldLinesInto(fold, filePath, { isSidechain: false });
483
+ return fold;
484
+ }
485
+ /** Recursively collects every `.jsonl` file under `root`, depth-first,
486
+ * sorted for determinism. Not hardcoded to `subagents/` — nothing about that
487
+ * nesting is documented, so this walks whatever is actually there rather
488
+ * than assuming today's layout survives the next Claude Code release. */
489
+ async function walkJsonlFiles(root) {
490
+ const out = [];
491
+ let otherFiles = 0;
492
+ async function walk(dir) {
493
+ let entries;
494
+ try {
495
+ entries = await fs.readdir(dir, { withFileTypes: true });
496
+ }
497
+ catch {
498
+ return;
499
+ }
500
+ for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
501
+ const full = path.join(dir, entry.name);
502
+ if (entry.isDirectory()) {
503
+ await walk(full);
504
+ }
505
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
506
+ out.push(full);
507
+ }
508
+ else if (entry.isFile()) {
509
+ otherFiles += 1;
510
+ }
511
+ }
512
+ }
513
+ await walk(root);
514
+ return { files: out, otherFiles };
515
+ }
516
+ function topModel(models) {
517
+ let best = null;
518
+ let bestCount = 0;
519
+ for (const [model, count] of models) {
520
+ if (count > bestCount) {
521
+ best = model;
522
+ bestCount = count;
523
+ }
524
+ }
525
+ return best;
526
+ }
527
+ export async function* extractClaudeCode(snapshotDir, extractionId) {
528
+ const projectsDir = path.join(snapshotDir, "projects");
529
+ let projectSlugs = [];
530
+ try {
531
+ projectSlugs = (await fs.readdir(projectsDir, { withFileTypes: true }))
532
+ .filter((entry) => entry.isDirectory())
533
+ .map((entry) => entry.name);
534
+ }
535
+ catch {
536
+ return; // No projects dir in the snapshot — nothing to extract.
537
+ }
538
+ let windowOldest = null;
539
+ let windowNewest = null;
540
+ let sessionCount = 0;
541
+ // Projects holding files but no readable transcript at all.
542
+ //
543
+ // A first attempt counted every non-`.jsonl` file, which read 449 on a
544
+ // healthy store: `memory/*.md`, `agent-*.meta.json`, `toolu_*.txt`, fetched
545
+ // PDFs. A counter that is permanently non-zero is one nobody reads, so it
546
+ // detects nothing. This asks the question that actually matters instead —
547
+ // is there a project whose transcripts we can no longer read? — which is
548
+ // shape-based rather than a guess at what the next format will be called,
549
+ // so an entirely novel encoding trips it just as well as a known one.
550
+ //
551
+ // On the reference machine this is 1 of 74: a session directory whose
552
+ // `tool-results/` sidecars outlived the transcript the 30-day purge took.
553
+ // That is a true positive, and the reason the counter is worth having.
554
+ let projectsWithNoReadableTranscript = 0;
555
+ for (const slug of projectSlugs.sort()) {
556
+ const dir = path.join(projectsDir, slug);
557
+ let entries;
558
+ try {
559
+ entries = await fs.readdir(dir, { withFileTypes: true });
560
+ }
561
+ catch {
562
+ continue;
563
+ }
564
+ const topLevelFiles = entries
565
+ .filter((e) => e.isFile() && e.name.endsWith(".jsonl"))
566
+ .map((e) => e.name)
567
+ .sort();
568
+ let slugJsonlFiles = topLevelFiles.length;
569
+ let slugOtherFiles = entries.filter((e) => e.isFile() && !e.name.endsWith(".jsonl")).length;
570
+ const sessionDirNames = entries
571
+ .filter((e) => e.isDirectory())
572
+ .map((e) => e.name)
573
+ .sort();
574
+ // Keyed by the top-level filename's own basename — which is also the
575
+ // directory-naming convention subagent transcripts nest under — so a
576
+ // merge never depends on a session correcting its own id mid-file.
577
+ const foldsByKey = new Map();
578
+ for (const name of topLevelFiles) {
579
+ const fold = await foldTranscript(path.join(dir, name), slug);
580
+ foldsByKey.set(path.basename(name, ".jsonl"), fold);
581
+ }
582
+ for (const sessionDirName of sessionDirNames) {
583
+ const nested = await walkJsonlFiles(path.join(dir, sessionDirName));
584
+ const nestedFiles = nested.files;
585
+ slugJsonlFiles += nestedFiles.length;
586
+ slugOtherFiles += nested.otherFiles;
587
+ if (nestedFiles.length === 0)
588
+ continue;
589
+ let fold = foldsByKey.get(sessionDirName);
590
+ if (!fold) {
591
+ // The top-level transcript evaporated (purge) but its subagent
592
+ // directory survived — still worth a fold rather than a silent skip.
593
+ fold = newFold(sessionDirName, slug);
594
+ foldsByKey.set(sessionDirName, fold);
595
+ }
596
+ for (const nestedPath of nestedFiles) {
597
+ fold.subagentTranscripts += 1;
598
+ await foldLinesInto(fold, nestedPath, { isSidechain: true });
599
+ }
600
+ }
601
+ // Files here, but nothing this extractor can read as a transcript.
602
+ if (slugJsonlFiles === 0 && slugOtherFiles > 0)
603
+ projectsWithNoReadableTranscript += 1;
604
+ for (const fold of foldsByKey.values()) {
605
+ // A fold with no usable timeline is unusable regardless of its
606
+ // contents.
607
+ if (!fold.firstTs || !fold.lastTs)
608
+ continue;
609
+ sessionCount += 1;
610
+ if (!windowOldest || fold.firstTs < windowOldest)
611
+ windowOldest = fold.firstTs;
612
+ if (!windowNewest || fold.lastTs > windowNewest)
613
+ windowNewest = fold.lastTs;
614
+ for (const ts of fold.humanPromptTimestamps) {
615
+ if (!ts)
616
+ continue;
617
+ yield {
618
+ occurredAt: ts,
619
+ store: "claude_code",
620
+ sourceVersion: fold.sourceVersion,
621
+ sessionRef: fold.sessionId,
622
+ repoRef: fold.cwd ?? fold.projectSlug,
623
+ eventKind: "ai_prompt_submitted",
624
+ metrics: {},
625
+ provenance: HISTORICAL_PROVENANCE.direct,
626
+ extractionId
627
+ };
628
+ }
629
+ const durationMs = sessionDurationMs(fold);
630
+ const toolFailureCount = fold.toolResultErrorCount + fold.apiErrorCount;
631
+ const sessionMetrics = {
632
+ promptCount: fold.humanPrompts,
633
+ assistantTurns: fold.assistantTurns,
634
+ toolResultCount: fold.toolResults,
635
+ queuedPrompts: fold.queuedPrompts,
636
+ inputTokens: fold.inputTokens,
637
+ outputTokens: fold.outputTokens,
638
+ cacheReadTokens: fold.cacheReadTokens,
639
+ durationBucket: durationBucketOf(fold),
640
+ afterHoursPrompts: fold.afterHoursPrompts,
641
+ unknownLines: fold.unknownLines,
642
+ unparsedLines: fold.unparsedLines,
643
+ modelCount: fold.models.size,
644
+ modelSwitchCount: fold.modelSwitchCount,
645
+ compactionCount: fold.compactionCount,
646
+ compactionManualCount: fold.compactionManualCount,
647
+ compactionAutoCount: fold.compactionAutoCount,
648
+ toolResultErrorCount: fold.toolResultErrorCount,
649
+ apiErrorCount: fold.apiErrorCount,
650
+ toolFailureCount,
651
+ contextWindowPeakTokens: fold.contextWindowPeakTokens,
652
+ contextWindowPeakPct: contextWindowPeakPctOf(fold),
653
+ userModifiedEditCount: fold.userModifiedEditCount,
654
+ linesChanged: fold.linesChangedTotal,
655
+ linesChangedBucket: bucketLinesChanged(fold.linesChangedTotal),
656
+ rapidRepromptCount: rapidRepromptCountOf(fold),
657
+ abandonedPromptCount: fold.abandonedPromptCount,
658
+ activeMinutes: activeMinutesOf(fold),
659
+ subagentTranscripts: fold.subagentTranscripts,
660
+ subagentPrompts: fold.subagentPrompts,
661
+ subagentAssistantTurns: fold.subagentAssistantTurns,
662
+ subagentTokensTotal: fold.subagentTokensTotal
663
+ };
664
+ // Exact minutes, not just the bucket — the backend's SessionMinutesPerDay
665
+ // reader prefers an explicit `sessionMinutes` metric over deriving a
666
+ // bucket midpoint, so ship the real number when the timeline gives it.
667
+ if (durationMs !== null)
668
+ sessionMetrics.sessionMinutes = Math.round(durationMs / 60_000);
669
+ sessionMetrics.sessionStartedAt = fold.firstTs;
670
+ const primaryModel = topModel(fold.models);
671
+ if (primaryModel)
672
+ sessionMetrics.primaryModel = primaryModel;
673
+ if (fold.gitBranch)
674
+ sessionMetrics.gitBranch = fold.gitBranch;
675
+ yield {
676
+ occurredAt: fold.lastTs,
677
+ store: "claude_code",
678
+ sourceVersion: fold.sourceVersion,
679
+ sessionRef: fold.sessionId,
680
+ repoRef: fold.cwd ?? fold.projectSlug,
681
+ eventKind: "create_focus_session",
682
+ metrics: sessionMetrics,
683
+ // Same gap threshold the session-level activeMinutes uses, passed in
684
+ // rather than redeclared: two definitions of "active" would drift.
685
+ dayBreakdown: sliceSessionByLocalDay(fold.humanPromptTimestamps, {
686
+ activeGapMs: ACTIVE_GAP_MS
687
+ }),
688
+ provenance: HISTORICAL_PROVENANCE.derived,
689
+ extractionId
690
+ };
691
+ if (fold.afterHoursPrompts > 0) {
692
+ yield {
693
+ occurredAt: fold.lastTs,
694
+ store: "claude_code",
695
+ sourceVersion: fold.sourceVersion,
696
+ sessionRef: fold.sessionId,
697
+ repoRef: fold.cwd ?? fold.projectSlug,
698
+ eventKind: "after_hours_ai_session",
699
+ metrics: { afterHoursPrompts: fold.afterHoursPrompts },
700
+ provenance: HISTORICAL_PROVENANCE.derived,
701
+ extractionId
702
+ };
703
+ }
704
+ // Aggregate per session, same shape as after_hours_ai_session above —
705
+ // never one event per compaction/failure. Canonical event names so
706
+ // these ride the existing catalog rather than living only inside
707
+ // create_focus_session's metrics bag.
708
+ if (fold.compactionManualCount > 0) {
709
+ yield {
710
+ occurredAt: fold.lastTs,
711
+ store: "claude_code",
712
+ sourceVersion: fold.sourceVersion,
713
+ sessionRef: fold.sessionId,
714
+ repoRef: fold.cwd ?? fold.projectSlug,
715
+ eventKind: "context_compression_manual",
716
+ metrics: { compactionCount: fold.compactionManualCount },
717
+ provenance: HISTORICAL_PROVENANCE.derived,
718
+ extractionId
719
+ };
720
+ }
721
+ if (fold.compactionAutoCount > 0) {
722
+ yield {
723
+ occurredAt: fold.lastTs,
724
+ store: "claude_code",
725
+ sourceVersion: fold.sourceVersion,
726
+ sessionRef: fold.sessionId,
727
+ repoRef: fold.cwd ?? fold.projectSlug,
728
+ eventKind: "context_compression_auto",
729
+ metrics: { compactionCount: fold.compactionAutoCount },
730
+ provenance: HISTORICAL_PROVENANCE.derived,
731
+ extractionId
732
+ };
733
+ }
734
+ if (toolFailureCount > 0) {
735
+ yield {
736
+ occurredAt: fold.lastTs,
737
+ store: "claude_code",
738
+ sourceVersion: fold.sourceVersion,
739
+ sessionRef: fold.sessionId,
740
+ repoRef: fold.cwd ?? fold.projectSlug,
741
+ eventKind: "tool_failure",
742
+ metrics: {
743
+ toolFailureCount,
744
+ toolResultErrorCount: fold.toolResultErrorCount,
745
+ apiErrorCount: fold.apiErrorCount
746
+ },
747
+ provenance: HISTORICAL_PROVENANCE.derived,
748
+ extractionId
749
+ };
750
+ }
751
+ }
752
+ }
753
+ // A store that yielded nothing readable still has something to report. The
754
+ // window guard alone suppressed the epoch in exactly that case, so a store
755
+ // this extractor could no longer read produced no diagnostic at all —
756
+ // silence indistinguishable from "you did no work". Emit whenever there is
757
+ // either a window or a read failure to declare.
758
+ if ((windowOldest && windowNewest) || projectsWithNoReadableTranscript > 0) {
759
+ const window = windowOldest && windowNewest ? { windowOldest, windowNewest } : {};
760
+ yield {
761
+ // No window means nothing datable was read; the only honest timestamp
762
+ // left is when the read happened.
763
+ occurredAt: windowNewest ?? new Date().toISOString(),
764
+ store: "claude_code",
765
+ sourceVersion: null,
766
+ sessionRef: null,
767
+ repoRef: null,
768
+ eventKind: "extraction_epoch",
769
+ metrics: {
770
+ ...window,
771
+ sessionCount,
772
+ projectsWithNoReadableTranscript
773
+ },
774
+ provenance: HISTORICAL_PROVENANCE.derived,
775
+ extractionId
776
+ };
777
+ }
778
+ }
779
+ //# sourceMappingURL=claudeCode.js.map