@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.
- package/README.md +127 -0
- package/dist/cli.js +3889 -0
- package/dist/cli.js.map +1 -0
- package/dist/daySlice.js +98 -0
- package/dist/daySlice.js.map +1 -0
- package/dist/extractors/claudeCode.js +779 -0
- package/dist/extractors/claudeCode.js.map +1 -0
- package/dist/extractors/cursor.js +601 -0
- package/dist/extractors/cursor.js.map +1 -0
- package/dist/extractors/git.js +9 -0
- package/dist/extractors/git.js.map +1 -0
- package/dist/extractors/vscode.js +615 -0
- package/dist/extractors/vscode.js.map +1 -0
- package/dist/localHandoff.js +238 -0
- package/dist/localHandoff.js.map +1 -0
- package/dist/retention.js +68 -0
- package/dist/retention.js.map +1 -0
- package/dist/scan.js +220 -0
- package/dist/scan.js.map +1 -0
- package/dist/ship.js +208 -0
- package/dist/ship.js.map +1 -0
- package/dist/staging.js +121 -0
- package/dist/staging.js.map +1 -0
- package/dist/stores.js +25 -0
- package/dist/stores.js.map +1 -0
- package/dist/types.js +74 -0
- package/dist/types.js.map +1 -0
- package/package.json +32 -0
|
@@ -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
|