session-steward 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  });