session-steward 0.3.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.
- package/CHANGELOG.md +31 -0
- package/README.md +27 -14
- package/bin/session-steward-cli.mjs +14 -0
- package/dist/assets/index-B6C5qvGV.js +9 -0
- package/dist/assets/index-B8U6eLCA.css +2 -0
- package/dist/index.html +2 -2
- package/lib/cli.mjs +139 -2
- package/lib/providers/claude-code/events.mjs +498 -0
- package/lib/providers/claude-code/index.mjs +2 -0
- package/lib/providers/claude-code/store.mjs +60 -15
- package/lib/providers/codex/database-families.mjs +177 -0
- package/lib/providers/codex/events.mjs +731 -0
- package/lib/providers/codex/index.mjs +2 -0
- package/lib/providers/codex/store.mjs +466 -329
- package/lib/server.mjs +70 -4
- package/lib/session-event-reader.mjs +126 -0
- package/lib/session-events.mjs +307 -0
- package/lib/storage/jsonl.mjs +148 -0
- package/package.json +7 -2
- package/dist/assets/index-CN9iax_v.css +0 -2
- package/dist/assets/index-fUX3qen0.js +0 -9
|
@@ -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
|
+
}
|
|
@@ -10,6 +10,10 @@ import { measurePath } from "../../storage/files.mjs";
|
|
|
10
10
|
import { readJsonlEntries, rewriteJsonlFile } from "../../storage/jsonl.mjs";
|
|
11
11
|
|
|
12
12
|
const PROVIDER_ID = "claude-code";
|
|
13
|
+
const COMPATIBILITY_PROFILE = Object.freeze({
|
|
14
|
+
id: "claude-local-store-2026-08",
|
|
15
|
+
builtFor: { claudeCli: ["2.1.199", "2.1.220"], claudeDesktop: ["1.24012.9"] },
|
|
16
|
+
});
|
|
13
17
|
const SUPPORTED_ENTRYPOINTS = new Set(["cli", "claude-desktop"]);
|
|
14
18
|
const KNOWN_TOP_LEVEL = new Set([
|
|
15
19
|
".DS_Store", ".last-cleanup", ".last-update-result.json", "agents", "backups", "cache", "commands", "debug", "downloads", "file-history", "history.jsonl",
|
|
@@ -331,8 +335,9 @@ async function discover(claudeHome, desktopDataHome) {
|
|
|
331
335
|
const summariesById = new Map();
|
|
332
336
|
const unknown = [];
|
|
333
337
|
let projectDirectories = [];
|
|
338
|
+
let projectsAvailability = "available";
|
|
334
339
|
try { projectDirectories = await fs.readdir(paths.projectsDirectory, { withFileTypes: true }); } catch (error) {
|
|
335
|
-
|
|
340
|
+
projectsAvailability = error?.code === "ENOENT" ? "missing" : "unreadable";
|
|
336
341
|
}
|
|
337
342
|
for (const projectEntry of projectDirectories) {
|
|
338
343
|
if (!projectEntry.isDirectory()) continue;
|
|
@@ -387,7 +392,7 @@ async function discover(claudeHome, desktopDataHome) {
|
|
|
387
392
|
updatedAtMs: Math.max(...copies.map((item) => item.activityAtMs)),
|
|
388
393
|
});
|
|
389
394
|
}
|
|
390
|
-
return { desktop, paths, records, recordsById: new Map(records.map((record) => [record.id, record])), unknown };
|
|
395
|
+
return { desktop, paths, projectsAvailability, records, recordsById: new Map(records.map((record) => [record.id, record])), unknown };
|
|
391
396
|
}
|
|
392
397
|
|
|
393
398
|
async function discoverCached(claudeHome, desktopDataHome, { refresh = false } = {}) {
|
|
@@ -513,24 +518,31 @@ async function topLevelEntries(directory) {
|
|
|
513
518
|
|
|
514
519
|
export async function diagnoseStorageCompatibility({ claudeHome, desktopDataHome }) {
|
|
515
520
|
const store = await discoverCached(claudeHome, desktopDataHome);
|
|
516
|
-
const
|
|
521
|
+
const unrecognized = [];
|
|
517
522
|
for (const entry of await topLevelEntries(store.paths.claudeHome)) {
|
|
518
|
-
if (!KNOWN_TOP_LEVEL.has(entry.name))
|
|
523
|
+
if (!KNOWN_TOP_LEVEL.has(entry.name)) unrecognized.push(`Unrecognized Claude data: ${entry.name}`);
|
|
519
524
|
}
|
|
520
|
-
if (store.unknown.length)
|
|
521
|
-
if (store.desktop.unlinked.length)
|
|
525
|
+
if (store.unknown.length) unrecognized.push(`${store.unknown.length} session file${store.unknown.length === 1 ? "" : "s"} could not be classified safely.`);
|
|
526
|
+
if (store.desktop.unlinked.length) unrecognized.push(`${store.desktop.unlinked.length} Desktop session record${store.desktop.unlinked.length === 1 ? "" : "s"} could not be linked safely.`);
|
|
527
|
+
const missing = store.projectsAvailability === "available"
|
|
528
|
+
? []
|
|
529
|
+
: [store.projectsAvailability === "missing"
|
|
530
|
+
? "Claude project sessions folder was not found."
|
|
531
|
+
: "Claude project sessions folder could not be read."];
|
|
522
532
|
return {
|
|
523
|
-
builtFor:
|
|
533
|
+
builtFor: COMPATIBILITY_PROFILE.builtFor,
|
|
524
534
|
changed: [],
|
|
525
|
-
missing
|
|
526
|
-
|
|
527
|
-
status:
|
|
535
|
+
missing,
|
|
536
|
+
profileId: COMPATIBILITY_PROFILE.id,
|
|
537
|
+
status: missing.length ? "unsupported" : unrecognized.length ? "partial" : "ready",
|
|
538
|
+
unrecognized,
|
|
528
539
|
};
|
|
529
540
|
}
|
|
530
541
|
|
|
531
542
|
export async function assertDeepCleanupSupported(options) {
|
|
532
543
|
const diagnostic = await diagnoseStorageCompatibility(options);
|
|
533
|
-
if (diagnostic.status
|
|
544
|
+
if (diagnostic.status === "unsupported") throw new Error("Thorough cleanup is paused because the Claude project sessions folder could not be read.");
|
|
545
|
+
return diagnostic;
|
|
534
546
|
}
|
|
535
547
|
|
|
536
548
|
async function matchingHistoryStats(historyPath, ids) {
|
|
@@ -630,6 +642,8 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
630
642
|
transcriptBytes: files.reduce((sum, file) => sum + file.size, 0),
|
|
631
643
|
transcriptFileCount: files.length,
|
|
632
644
|
transcriptPaths: [...selectedPaths],
|
|
645
|
+
unrecognizedLocationCount: (await topLevelEntries(store.paths.claudeHome))
|
|
646
|
+
.filter((entry) => !KNOWN_TOP_LEVEL.has(entry.name)).length + store.unknown.length + store.desktop.unlinked.length,
|
|
633
647
|
};
|
|
634
648
|
}
|
|
635
649
|
|
|
@@ -784,7 +798,20 @@ async function createBackup(plan, store, scope) {
|
|
|
784
798
|
await backupHistoryRows(store.paths.historyPath, destination, new Set(plan.ids));
|
|
785
799
|
sharedJsonl.push({ backupRelative, relative: "history.jsonl", root: "claude" });
|
|
786
800
|
}
|
|
787
|
-
const
|
|
801
|
+
const compatibility = await diagnoseStorageCompatibility({
|
|
802
|
+
claudeHome: store.paths.claudeHome,
|
|
803
|
+
desktopDataHome: store.paths.desktopDataHome,
|
|
804
|
+
});
|
|
805
|
+
const manifest = {
|
|
806
|
+
compatibilityStatus: compatibility.status,
|
|
807
|
+
createdAt: new Date().toISOString(),
|
|
808
|
+
entries,
|
|
809
|
+
profileId: COMPATIBILITY_PROFILE.id,
|
|
810
|
+
providerId: PROVIDER_ID,
|
|
811
|
+
scope,
|
|
812
|
+
sharedJsonl,
|
|
813
|
+
version: 2,
|
|
814
|
+
};
|
|
788
815
|
await fs.writeFile(path.join(backupDirectory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
789
816
|
return backupDirectory;
|
|
790
817
|
} catch (error) {
|
|
@@ -807,7 +834,13 @@ export async function executeSessionDeletion({ onProgress = () => {}, plan, scop
|
|
|
807
834
|
if (scope === "deep") targets.push(...plan.deepPaths);
|
|
808
835
|
for (const target of [...new Set(targets)].sort((a, b) => b.length - a.length)) await fs.rm(target, { force: true, recursive: true });
|
|
809
836
|
onProgress({ canCancel: false, message: "Checking cleanup", phase: "verification", progress: 90 });
|
|
810
|
-
return {
|
|
837
|
+
return {
|
|
838
|
+
backupDirectory,
|
|
839
|
+
deletedIds: plan.ids,
|
|
840
|
+
deletedTranscriptPaths: plan.transcriptPaths,
|
|
841
|
+
skippedTranscriptPaths: [],
|
|
842
|
+
unrecognizedLocationCount: plan.unrecognizedLocationCount,
|
|
843
|
+
};
|
|
811
844
|
} catch (error) {
|
|
812
845
|
error.backupDirectory = backupDirectory;
|
|
813
846
|
throw error;
|
|
@@ -863,7 +896,7 @@ export async function listSessionDeletionBackups({ claudeHome }) {
|
|
|
863
896
|
measurePath(backupDirectory),
|
|
864
897
|
]);
|
|
865
898
|
const restorable = manifest?.providerId === PROVIDER_ID &&
|
|
866
|
-
manifest?.version
|
|
899
|
+
[1, 2].includes(manifest?.version) &&
|
|
867
900
|
Array.isArray(manifest.entries);
|
|
868
901
|
|
|
869
902
|
backups.push({
|
|
@@ -892,6 +925,8 @@ export async function restoreSessionDeletionBackup({ backupDirectory, claudeHome
|
|
|
892
925
|
}
|
|
893
926
|
const manifest = JSON.parse(await fs.readFile(path.join(backupDirectory, "manifest.json"), "utf8"));
|
|
894
927
|
if (manifest?.providerId !== PROVIDER_ID || !Array.isArray(manifest.entries)) throw new Error("This recovery backup is not valid for Claude Code.");
|
|
928
|
+
invalidateSessionCache({ claudeHome, desktopDataHome });
|
|
929
|
+
const currentCompatibilityBeforeRestore = await diagnoseStorageCompatibility({ claudeHome, desktopDataHome });
|
|
895
930
|
const safetyBackupDirectory = path.join(store.paths.backupRoot, `restore-safety-${Date.now()}-${randomBytes(4).toString("hex")}`);
|
|
896
931
|
await fs.mkdir(safetyBackupDirectory, { mode: 0o700, recursive: true });
|
|
897
932
|
try {
|
|
@@ -931,7 +966,17 @@ export async function restoreSessionDeletionBackup({ backupDirectory, claudeHome
|
|
|
931
966
|
await pipeline(createReadStream(source), createWriteStream(destination, { flags: "a", mode: 0o600 }));
|
|
932
967
|
}
|
|
933
968
|
onProgress({ message: "Checking restored sessions", progress: 92 });
|
|
934
|
-
|
|
969
|
+
const layoutChanged = manifest.version === 2 && (
|
|
970
|
+
manifest.profileId !== COMPATIBILITY_PROFILE.id
|
|
971
|
+
|| manifest.compatibilityStatus !== currentCompatibilityBeforeRestore.status
|
|
972
|
+
);
|
|
973
|
+
invalidateSessionCache({ claudeHome, desktopDataHome });
|
|
974
|
+
return {
|
|
975
|
+
note: layoutChanged ? "The Claude storage layout changed after this backup was created. The recorded files were restored to their original locations." : null,
|
|
976
|
+
recoveryBackupsDeleted: false,
|
|
977
|
+
restoredEntryCount: manifest.entries.length + (manifest.sharedJsonl?.length ?? 0),
|
|
978
|
+
safetyBackupDirectory,
|
|
979
|
+
};
|
|
935
980
|
} catch (error) {
|
|
936
981
|
error.safetyBackupDirectory = safetyBackupDirectory;
|
|
937
982
|
throw error;
|