session-steward 0.4.0 → 0.5.1

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/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
6
6
  <link href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23171717'/%3E%3Cpath d='M16 4 26 8v7c0 6-4 11-10 13C10 26 6 21 6 15V8Z' fill='%23f5f5f5'/%3E%3Cpath d='m9.5 16 2.2-2.2 3 3 6.5-6.5 2.2 2.2-8.7 8.7Z' fill='%23171717'/%3E%3C/svg%3E" rel="icon"/>
7
7
  <title>Session Steward</title>
8
- <script type="module" crossorigin src="/assets/index-QhQbSn0H.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-BQ6SUqXr.css">
8
+ <script type="module" crossorigin src="/assets/index-JaDHeyls.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-B8U6eLCA.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/lib/cli.mjs CHANGED
@@ -1,9 +1,18 @@
1
1
  import { once } from "node:events";
2
2
  import path from "node:path";
3
3
  import readline from "node:readline/promises";
4
- import process, { stdin as input, stdout as output } from "node:process";
4
+ import process, {
5
+ stderr as errorOutput,
6
+ stdin as input,
7
+ stdout as output,
8
+ } from "node:process";
5
9
 
6
10
  import { getProvider } from "./providers/index.mjs";
11
+ import {
12
+ SESSION_EVENT_KIND,
13
+ SESSION_EVENT_REASON,
14
+ sessionEventCoveragePercent,
15
+ } from "./session-events.mjs";
7
16
 
8
17
  function providerOptions(providerId, home) {
9
18
  return providerId === "codex" ? { codexHome: home } : { claudeHome: home };
@@ -67,6 +76,8 @@ Options
67
76
  --archive-status <status> Show all, active, or archived sessions
68
77
  --sort <updated|created|name|cwd|size>
69
78
  Choose the session order
79
+ --events Include the distilled session timeline
80
+ --events-limit <number> Limit timeline events (default 100)
70
81
  --limit <number> Limit JSON results
71
82
  -h, --help Show this help
72
83
  `.trim();
@@ -331,6 +342,103 @@ function printInspect(record, deletionPlan) {
331
342
  output.write(`Delete log rows: ${deletionPlan.logRowCount}\n`);
332
343
  }
333
344
 
345
+ function eventTime(atMs) {
346
+ if (!Number.isFinite(atMs)) return "--:--";
347
+ return new Date(atMs).toLocaleTimeString([], {
348
+ hour: "2-digit",
349
+ hour12: false,
350
+ minute: "2-digit",
351
+ });
352
+ }
353
+
354
+ function eventText(value) {
355
+ return typeof value === "string" ? value.replace(/\s+/gu, " ").trim() : "";
356
+ }
357
+
358
+ function planSummary(steps) {
359
+ const counts = new Map();
360
+ for (const step of steps) {
361
+ const status = step.status.replaceAll("_", " ");
362
+ counts.set(status, (counts.get(status) ?? 0) + 1);
363
+ }
364
+ return [...counts]
365
+ .map(([status, count]) => `${count} ${status}`)
366
+ .join(" · ") || "No steps recorded";
367
+ }
368
+
369
+ function sessionEventDescription(event) {
370
+ if (event.kind === SESSION_EVENT_KIND.ASK) {
371
+ return `${event.injected ? "[injected context] " : ""}${eventText(event.text)}`;
372
+ }
373
+ if (event.kind === SESSION_EVENT_KIND.SAID || event.kind === SESSION_EVENT_KIND.SUMMARY) {
374
+ return eventText(event.text);
375
+ }
376
+ if (event.kind === SESSION_EVENT_KIND.EDIT) {
377
+ const files = event.files.length > 0 ? event.files.join(", ") : "File not recorded";
378
+ const changes = Number.isInteger(event.added) && Number.isInteger(event.removed)
379
+ ? ` +${event.added}/-${event.removed}`
380
+ : "";
381
+ const outcome = event.applied === true
382
+ ? " applied"
383
+ : event.applied === false
384
+ ? " NOT APPLIED"
385
+ : "";
386
+ return `${files}${changes}${outcome}`;
387
+ }
388
+ if (event.kind === SESSION_EVENT_KIND.RAN) {
389
+ const command = eventText(event.command) || "Command not recorded";
390
+ const outcome = event.failed === true ? " FAILED" : "";
391
+ const error = event.failed === true && event.error ? ` ${eventText(event.error)}` : "";
392
+ return `${command}${outcome}${error}`;
393
+ }
394
+ if (event.kind === SESSION_EVENT_KIND.DECIDED) {
395
+ const answer = event.answer ? ` → ${eventText(event.answer)}` : "";
396
+ return `${eventText(event.question)}${answer}`;
397
+ }
398
+ if (event.kind === SESSION_EVENT_KIND.PLAN) return planSummary(event.steps);
399
+ return "Event details unavailable";
400
+ }
401
+
402
+ function sessionEventReason(reason) {
403
+ return {
404
+ [SESSION_EVENT_REASON.NO_RECOGNIZED_EVENTS]: "No recognized session events were found.",
405
+ [SESSION_EVENT_REASON.NO_TRANSCRIPT_PATH]: "No transcript path was recorded for this session.",
406
+ [SESSION_EVENT_REASON.TRANSCRIPT_MISSING]: "The transcript file is missing.",
407
+ }[reason] ?? null;
408
+ }
409
+
410
+ function printSessionEventCoverage(coverage) {
411
+ const details = [
412
+ `recognized ${sessionEventCoveragePercent(coverage)}%`,
413
+ `${coverage.skipped} skipped`,
414
+ ];
415
+ if (coverage.unmapped > 0) details.push(`${coverage.unmapped} unmapped`);
416
+ if (coverage.unparseable > 0) details.push(`${coverage.unparseable} unparseable`);
417
+ if (coverage.oversized > 0) details.push(`${coverage.oversized} oversized`);
418
+ errorOutput.write(`${details.join(" · ")}\n`);
419
+ if (coverage.unmappedTypes.length > 0) {
420
+ errorOutput.write(`unmapped types: ${coverage.unmappedTypes.map(({ count, type }) => `${type} (${count})`).join(", ")}\n`);
421
+ }
422
+ }
423
+
424
+ function printSessionEvents(result) {
425
+ output.write("\nSession timeline\n");
426
+ output.write("----------------\n");
427
+ const reason = sessionEventReason(result.reason);
428
+
429
+ if (reason) {
430
+ output.write(`${reason}\n`);
431
+ } else {
432
+ const width = output.isTTY && Number.isInteger(output.columns) ? output.columns : 120;
433
+ for (const event of [...result.events].reverse()) {
434
+ const kind = event.kind.toUpperCase().padEnd(8, " ");
435
+ output.write(`${truncate(`${eventTime(event.atMs)} ${kind} ${sessionEventDescription(event)}`, width)}\n`);
436
+ }
437
+ }
438
+
439
+ printSessionEventCoverage(result.coverage);
440
+ }
441
+
334
442
  function printDeletionPreview(plan, preflight, scope) {
335
443
  const fileCount = preflight.transcriptFileCount ?? plan.transcriptFileCount;
336
444
  const sessionBytes = preflight.transcriptBytes ?? plan.transcriptBytes;
@@ -537,7 +645,21 @@ async function printJson(options) {
537
645
 
538
646
  for (const record of result.records) {
539
647
  if (written >= limit) break;
540
- await writeChunk(`${first ? "" : ",\n"}${JSON.stringify(options.provider.formatSessionForJson(record), null, 2)}`);
648
+ let serializedRecord = options.provider.formatSessionForJson(record);
649
+ if (options.events) {
650
+ const eventResult = await options.provider.readSessionEvents({
651
+ ...providerOptions(options.provider.id, options.providerHome),
652
+ id: record.id,
653
+ limit: options.eventsLimit,
654
+ });
655
+ serializedRecord = {
656
+ ...serializedRecord,
657
+ coverage: eventResult.coverage,
658
+ events: eventResult.events,
659
+ header: eventResult.header,
660
+ };
661
+ }
662
+ await writeChunk(`${first ? "" : ",\n"}${JSON.stringify(serializedRecord, null, 2)}`);
541
663
  first = false;
542
664
  written += 1;
543
665
  }
@@ -797,6 +919,14 @@ async function runInteractive(state) {
797
919
  });
798
920
 
799
921
  printInspect(record, deletionPlan);
922
+ if (state.showEvents) {
923
+ const eventResult = await state.provider.readSessionEvents({
924
+ ...providerOptions(state.provider.id, state.providerHome),
925
+ id: record.id,
926
+ limit: state.eventsLimit,
927
+ });
928
+ printSessionEvents(eventResult);
929
+ }
800
930
  } catch (error) {
801
931
  output.write(`\n${error instanceof Error ? error.message : String(error)}\n`);
802
932
  }
@@ -970,6 +1100,7 @@ export async function runCli(options) {
970
1100
  const state = {
971
1101
  archiveStatus: validateArchiveStatus(options.archiveStatus),
972
1102
  cleanupMode: validateCleanupMode(options.cleanup),
1103
+ eventsLimit: options.eventsLimit,
973
1104
  provider,
974
1105
  providerHome,
975
1106
  inactiveDays: validateInactiveDays(options.inactiveDays),
@@ -978,6 +1109,7 @@ export async function runCli(options) {
978
1109
  result: null,
979
1110
  search: options.search || "",
980
1111
  showInternals: Boolean(options.includeInternals),
1112
+ showEvents: Boolean(options.events),
981
1113
  showSupporting: Boolean(options.includeSupporting),
982
1114
  sort: validateSort(options.sort || "updated"),
983
1115
  workspace: options.workspace,
@@ -0,0 +1,498 @@
1
+ import {
2
+ createSessionEvent,
3
+ createSessionEventCoverage,
4
+ createSessionEventHeader,
5
+ createSessionEventsResult,
6
+ SESSION_EVENT_KIND,
7
+ SESSION_EVENT_REASON,
8
+ } from "../../session-events.mjs";
9
+ import {
10
+ createSessionEventReadState,
11
+ createUnmappedSessionEventTracker,
12
+ isInjectedSessionAsk,
13
+ } from "../../session-event-reader.mjs";
14
+ import { visitJsonlSnapshotEntries } from "../../storage/jsonl.mjs";
15
+ import { getSessionRecord } from "./store.mjs";
16
+
17
+ const MAX_PENDING_DECISIONS = 2_048;
18
+ const PROVIDER_ID = "claude-code";
19
+ const READ_ONLY_TOOLS = new Set(["Glob", "Grep", "Read", "WebFetch", "WebSearch"]);
20
+ const RECORD_CLASSIFICATION = Object.freeze({
21
+ RECOGNIZED: "recognized",
22
+ SKIPPED: "skipped",
23
+ UNMAPPED: "unmapped",
24
+ UNPARSEABLE: "unparseable",
25
+ });
26
+ const SKIPPED_CONTENT_TYPES = new Set(["attachment", "image", "thinking"]);
27
+ const SKIPPED_RECORD_TYPES = new Set([
28
+ "attachment",
29
+ "custom-title",
30
+ "file-history-snapshot",
31
+ "frame-link",
32
+ "last-prompt",
33
+ "mode",
34
+ "queue-operation",
35
+ "summary",
36
+ "system",
37
+ ]);
38
+
39
+ function asTimestamp(value) {
40
+ if (typeof value === "number" && Number.isFinite(value)) return value;
41
+ if (typeof value !== "string") return null;
42
+ const timestamp = Date.parse(value);
43
+ return Number.isFinite(timestamp) ? timestamp : null;
44
+ }
45
+
46
+ function contentText(value) {
47
+ if (typeof value === "string") return value;
48
+
49
+ if (Array.isArray(value)) {
50
+ return value
51
+ .map((part) => contentText(part))
52
+ .filter((part) => part !== "")
53
+ .join("\n");
54
+ }
55
+
56
+ if (!value || typeof value !== "object") return "";
57
+ if (typeof value.text === "string") return value.text;
58
+ if (typeof value.content === "string" || Array.isArray(value.content)) {
59
+ return contentText(value.content);
60
+ }
61
+ return "";
62
+ }
63
+
64
+ function emptyResult({ cwd = null, origin = null, reason }) {
65
+ return createSessionEventsResult({
66
+ coverage: createSessionEventCoverage(),
67
+ events: [],
68
+ header: createSessionEventHeader({ cwd, origin, provider: PROVIDER_ID }),
69
+ reason,
70
+ window: {
71
+ complete: true,
72
+ end: null,
73
+ outcomesMayBeUnresolved: false,
74
+ },
75
+ });
76
+ }
77
+
78
+ function messageParts(record) {
79
+ const content = record.message?.content;
80
+ if (typeof content === "string") return [{ text: content, type: "text" }];
81
+ return Array.isArray(content) ? content : [];
82
+ }
83
+
84
+ function planSteps(input) {
85
+ const todos = Array.isArray(input?.todos) ? input.todos : [];
86
+ return todos
87
+ .filter((todo) => typeof todo?.status === "string")
88
+ .map((todo) => ({
89
+ status: todo.status,
90
+ text: typeof todo.content === "string"
91
+ ? todo.content
92
+ : typeof todo.activeForm === "string"
93
+ ? todo.activeForm
94
+ : "Untitled task",
95
+ }));
96
+ }
97
+
98
+ function questionText(input) {
99
+ if (typeof input?.question === "string") return input.question;
100
+ if (!Array.isArray(input?.questions)) return "Question";
101
+ return input.questions
102
+ .map((question) => question?.question)
103
+ .filter((question) => typeof question === "string")
104
+ .join("\n") || "Question";
105
+ }
106
+
107
+ export async function readSessionEvents({
108
+ claudeHome,
109
+ desktopDataHome,
110
+ id,
111
+ limit,
112
+ maxLineBytes,
113
+ mode,
114
+ signal,
115
+ }) {
116
+ const record = await getSessionRecord({ claudeHome, desktopDataHome, id });
117
+ if (!record) return null;
118
+
119
+ const origin = record.surface ?? record.recordSource ?? null;
120
+ if (!record.rolloutPath) {
121
+ return emptyResult({
122
+ cwd: record.cwd || null,
123
+ origin,
124
+ reason: SESSION_EVENT_REASON.NO_TRANSCRIPT_PATH,
125
+ });
126
+ }
127
+
128
+ const coverage = createSessionEventCoverage();
129
+ const pendingDecisionIds = [];
130
+ const readState = createSessionEventReadState({ limit, mode });
131
+ const unmappedTypes = createUnmappedSessionEventTracker();
132
+ let acceptingInjectedAsks = true;
133
+ let header = createSessionEventHeader({
134
+ cwd: record.cwd || null,
135
+ origin,
136
+ provider: PROVIDER_ID,
137
+ });
138
+
139
+ function addEvent(event, pendingId = null) {
140
+ return readState.add(event, { pendingId });
141
+ }
142
+
143
+ function rememberDecision(pendingId) {
144
+ if (!pendingId) return;
145
+ pendingDecisionIds.push(pendingId);
146
+ if (pendingDecisionIds.length > MAX_PENDING_DECISIONS) pendingDecisionIds.shift();
147
+ }
148
+
149
+ function resolveDecision(answer) {
150
+ while (pendingDecisionIds.length > 0) {
151
+ const pendingId = pendingDecisionIds.shift();
152
+ if (readState.resolve(pendingId, (event) => {
153
+ if (event.kind === SESSION_EVENT_KIND.DECIDED) event.answer = answer;
154
+ })) return true;
155
+ }
156
+
157
+ return false;
158
+ }
159
+
160
+ function toolEvent(part, atMs, sequence) {
161
+ const input = part.input && typeof part.input === "object" ? part.input : {};
162
+ const name = typeof part.name === "string" ? part.name : "unknown tool";
163
+ const pendingId = typeof part.id === "string" ? part.id : null;
164
+
165
+ if (name === "Edit" || name === "Write") {
166
+ return {
167
+ event: createSessionEvent({
168
+ atMs,
169
+ files: typeof input.file_path === "string" ? [input.file_path] : [],
170
+ kind: SESSION_EVENT_KIND.EDIT,
171
+ sequence,
172
+ }),
173
+ pendingId,
174
+ };
175
+ }
176
+
177
+ if (name === "Bash") {
178
+ return {
179
+ event: createSessionEvent({
180
+ atMs,
181
+ command: typeof input.command === "string" ? input.command : null,
182
+ kind: SESSION_EVENT_KIND.RAN,
183
+ sequence,
184
+ workdir: typeof input.workdir === "string" ? input.workdir : record.cwd || null,
185
+ }),
186
+ pendingId,
187
+ };
188
+ }
189
+
190
+ if (name === "AskUserQuestion") {
191
+ return {
192
+ decision: true,
193
+ event: createSessionEvent({
194
+ atMs,
195
+ kind: SESSION_EVENT_KIND.DECIDED,
196
+ question: questionText(input),
197
+ sequence,
198
+ }),
199
+ pendingId,
200
+ };
201
+ }
202
+
203
+ if (name === "TodoWrite") {
204
+ return {
205
+ event: createSessionEvent({
206
+ atMs,
207
+ kind: SESSION_EVENT_KIND.PLAN,
208
+ sequence,
209
+ steps: planSteps(input),
210
+ }),
211
+ pendingId: null,
212
+ };
213
+ }
214
+
215
+ if (!READ_ONLY_TOOLS.has(name) && typeof input.file_path === "string") {
216
+ return {
217
+ event: createSessionEvent({
218
+ atMs,
219
+ files: [input.file_path],
220
+ kind: SESSION_EVENT_KIND.EDIT,
221
+ sequence,
222
+ }),
223
+ pendingId,
224
+ };
225
+ }
226
+
227
+ if (typeof input.command === "string") {
228
+ return {
229
+ event: createSessionEvent({
230
+ atMs,
231
+ command: input.command,
232
+ kind: SESSION_EVENT_KIND.RAN,
233
+ sequence,
234
+ workdir: typeof input.workdir === "string" ? input.workdir : record.cwd || null,
235
+ }),
236
+ pendingId,
237
+ };
238
+ }
239
+
240
+ return {
241
+ event: createSessionEvent({
242
+ atMs,
243
+ command: name,
244
+ kind: SESSION_EVENT_KIND.RAN,
245
+ sequence,
246
+ unclassified: true,
247
+ workdir: record.cwd || null,
248
+ }),
249
+ pendingId,
250
+ };
251
+ }
252
+
253
+ function resolveToolResult(part) {
254
+ const pendingId = part.tool_use_id;
255
+ const answer = contentText(part.content).trim();
256
+ const failed = part.is_error === true;
257
+ return readState.resolve(pendingId, (event) => {
258
+ if (event.kind === SESSION_EVENT_KIND.DECIDED) {
259
+ event.answer = answer || null;
260
+ } else if (event.kind === SESSION_EVENT_KIND.EDIT) {
261
+ event.applied = !failed;
262
+ } else if (event.kind === SESSION_EVENT_KIND.RAN) {
263
+ event.error = failed ? answer || "Tool failed." : null;
264
+ event.failed = failed;
265
+ }
266
+ });
267
+ }
268
+
269
+ function updateHeader(recordValue) {
270
+ const branch = recordValue.gitBranch ?? recordValue.git?.branch ?? header.git?.branch ?? null;
271
+ const commit = recordValue.git?.commit ?? recordValue.git?.commit_hash ?? header.git?.commit ?? null;
272
+ const repository = recordValue.git?.repository
273
+ ?? recordValue.git?.repository_url
274
+ ?? header.git?.repository
275
+ ?? null;
276
+ const hasGit = branch !== null || commit !== null || repository !== null;
277
+ header = createSessionEventHeader({
278
+ cwd: recordValue.cwd ?? header.cwd,
279
+ git: hasGit ? { branch, commit, repository } : null,
280
+ model: recordValue.message?.model ?? recordValue.model ?? header.model,
281
+ origin: recordValue.entrypoint ?? header.origin,
282
+ provider: PROVIDER_ID,
283
+ version: recordValue.version ?? header.version,
284
+ });
285
+ }
286
+
287
+ function handleRecord(recordValue, sequence) {
288
+ updateHeader(recordValue);
289
+ const atMs = asTimestamp(recordValue.timestamp ?? recordValue.createdAt);
290
+ const parts = messageParts(recordValue);
291
+
292
+ if (recordValue.type === "user") {
293
+ if (recordValue.isCompactSummary === true) {
294
+ acceptingInjectedAsks = false;
295
+ const text = contentText(recordValue.message?.content);
296
+ if (!text) {
297
+ return { classification: RECORD_CLASSIFICATION.UNPARSEABLE, stop: false };
298
+ }
299
+ return {
300
+ classification: RECORD_CLASSIFICATION.RECOGNIZED,
301
+ stop: addEvent(createSessionEvent({
302
+ atMs,
303
+ kind: SESSION_EVENT_KIND.SUMMARY,
304
+ sequence,
305
+ text,
306
+ })),
307
+ };
308
+ }
309
+
310
+ let recognized = false;
311
+ let resolvedDecision = false;
312
+ let unparseable = parts.length === 0;
313
+ let unmappedType = null;
314
+
315
+ for (const part of parts) {
316
+ if (typeof part?.type !== "string") {
317
+ unparseable = true;
318
+ continue;
319
+ }
320
+ if (part?.type === "text" && typeof part.text === "string") continue;
321
+ if (part?.type !== "tool_result") {
322
+ if (!SKIPPED_CONTENT_TYPES.has(part?.type)) {
323
+ unmappedType = `user:${part?.type ?? "missing content type"}`;
324
+ }
325
+ continue;
326
+ }
327
+ const resolved = resolveToolResult(part);
328
+ if (part.is_error === true) recognized = true;
329
+ if (part.is_error === true && !resolved) unparseable = true;
330
+ }
331
+
332
+ const text = parts
333
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
334
+ .map((part) => part.text)
335
+ .join("\n");
336
+
337
+ if (text) {
338
+ resolvedDecision = resolveDecision(text);
339
+ recognized = true;
340
+ if (resolvedDecision) acceptingInjectedAsks = false;
341
+ if (!resolvedDecision) {
342
+ const injected = acceptingInjectedAsks && isInjectedSessionAsk(text);
343
+ if (!injected) acceptingInjectedAsks = false;
344
+ return {
345
+ classification: unmappedType
346
+ ? RECORD_CLASSIFICATION.UNMAPPED
347
+ : unparseable
348
+ ? RECORD_CLASSIFICATION.UNPARSEABLE
349
+ : RECORD_CLASSIFICATION.RECOGNIZED,
350
+ stop: addEvent(createSessionEvent({
351
+ atMs,
352
+ injected,
353
+ kind: SESSION_EVENT_KIND.ASK,
354
+ sequence,
355
+ text,
356
+ })),
357
+ unmappedType,
358
+ };
359
+ }
360
+ }
361
+
362
+ return {
363
+ classification: unmappedType
364
+ ? RECORD_CLASSIFICATION.UNMAPPED
365
+ : unparseable
366
+ ? RECORD_CLASSIFICATION.UNPARSEABLE
367
+ : recognized
368
+ ? RECORD_CLASSIFICATION.RECOGNIZED
369
+ : RECORD_CLASSIFICATION.SKIPPED,
370
+ stop: false,
371
+ unmappedType,
372
+ };
373
+ }
374
+
375
+ if (recordValue.type === "assistant") {
376
+ acceptingInjectedAsks = false;
377
+ let recognized = false;
378
+ let stop = false;
379
+ const unparseable = parts.length === 0 || parts.some((part) => (
380
+ typeof part?.type !== "string"
381
+ || (part.type === "text" && typeof part.text !== "string")
382
+ ));
383
+ const unmappedPart = parts.find((part) => (
384
+ typeof part?.type === "string"
385
+ && part.type !== "text"
386
+ && part?.type !== "tool_use"
387
+ && !SKIPPED_CONTENT_TYPES.has(part?.type)
388
+ ));
389
+ const unmappedType = unmappedPart
390
+ ? `assistant:${unmappedPart?.type ?? "missing content type"}`
391
+ : null;
392
+ const text = parts
393
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
394
+ .map((part) => part.text)
395
+ .join("\n");
396
+
397
+ if (text) {
398
+ recognized = true;
399
+ stop = addEvent(createSessionEvent({
400
+ atMs,
401
+ kind: SESSION_EVENT_KIND.SAID,
402
+ sequence,
403
+ text,
404
+ }));
405
+ }
406
+
407
+ if (!stop) {
408
+ for (const part of parts) {
409
+ if (part?.type === "text" && typeof part.text === "string") continue;
410
+ if (part?.type !== "tool_use") {
411
+ continue;
412
+ }
413
+ const result = toolEvent(part, atMs, sequence);
414
+ recognized = true;
415
+ stop = addEvent(result.event, result.pendingId);
416
+ if (result.decision) rememberDecision(result.pendingId);
417
+ if (stop) break;
418
+ }
419
+ }
420
+
421
+ return {
422
+ classification: unmappedType
423
+ ? RECORD_CLASSIFICATION.UNMAPPED
424
+ : unparseable
425
+ ? RECORD_CLASSIFICATION.UNPARSEABLE
426
+ : recognized
427
+ ? RECORD_CLASSIFICATION.RECOGNIZED
428
+ : RECORD_CLASSIFICATION.SKIPPED,
429
+ stop,
430
+ unmappedType,
431
+ };
432
+ }
433
+
434
+ if (typeof recordValue.type !== "string") {
435
+ return { classification: RECORD_CLASSIFICATION.UNPARSEABLE, stop: false };
436
+ }
437
+
438
+ return {
439
+ classification: SKIPPED_RECORD_TYPES.has(recordValue.type)
440
+ ? RECORD_CLASSIFICATION.SKIPPED
441
+ : RECORD_CLASSIFICATION.UNMAPPED,
442
+ stop: false,
443
+ unmappedType: `claude:${recordValue.type ?? "missing record type"}`,
444
+ };
445
+ }
446
+
447
+ let read;
448
+
449
+ try {
450
+ read = await visitJsonlSnapshotEntries(
451
+ record.rolloutPath,
452
+ (entry) => {
453
+ if (signal?.aborted) return false;
454
+ coverage.total += 1;
455
+
456
+ if (entry.oversized) {
457
+ coverage.oversized += 1;
458
+ return true;
459
+ }
460
+
461
+ if (!entry.parsed || typeof entry.parsed !== "object") {
462
+ coverage.unparseable += 1;
463
+ return true;
464
+ }
465
+
466
+ const result = handleRecord(entry.parsed, entry.index);
467
+ coverage[result.classification] += 1;
468
+ if (result.classification === RECORD_CLASSIFICATION.UNMAPPED) {
469
+ unmappedTypes.add(result.unmappedType);
470
+ }
471
+ return !result.stop;
472
+ },
473
+ { maxLineBytes },
474
+ );
475
+ } catch (error) {
476
+ if (error?.code === "ENOENT") {
477
+ return emptyResult({
478
+ cwd: record.cwd || null,
479
+ origin,
480
+ reason: SESSION_EVENT_REASON.TRANSCRIPT_MISSING,
481
+ });
482
+ }
483
+
484
+ throw error;
485
+ }
486
+
487
+ const events = readState.values();
488
+ coverage.unmappedTypes = unmappedTypes.values();
489
+ return createSessionEventsResult({
490
+ coverage,
491
+ events,
492
+ header,
493
+ reason: events.length === 0 && read.complete
494
+ ? SESSION_EVENT_REASON.NO_RECOGNIZED_EVENTS
495
+ : null,
496
+ window: readState.window(read),
497
+ });
498
+ }
@@ -1,7 +1,9 @@
1
+ import { readSessionEvents } from "./events.mjs";
1
2
  import * as store from "./store.mjs";
2
3
 
3
4
  export const claudeCodeProvider = Object.freeze({
4
5
  id: "claude-code",
5
6
  displayName: "Claude Code",
6
7
  ...store,
8
+ readSessionEvents,
7
9
  });