packbat 0.1.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/dist/main.js ADDED
@@ -0,0 +1,2541 @@
1
+ import {
2
+ HARNESS_IDS,
3
+ PackbatError,
4
+ UUID_SOURCE,
5
+ adapters,
6
+ ageMs,
7
+ checkFresh,
8
+ checkInstalled,
9
+ checkLive,
10
+ checkOffbox,
11
+ createArchiveRemote,
12
+ createDoctorContext,
13
+ createInitScheduleOptions,
14
+ decompressBytes,
15
+ decryptWithIdentity,
16
+ detectInitStores,
17
+ errorMessage,
18
+ formatAge,
19
+ getAdapter,
20
+ identityToRecipient,
21
+ installInitSchedule,
22
+ isDatabaseSnapshotIndexRecord,
23
+ isEnoent,
24
+ isHarnessId,
25
+ loadConfig,
26
+ parseIdentityFile,
27
+ readDerivedIndex,
28
+ readDirectoryOrEmpty,
29
+ readHarnessTallies,
30
+ readIndex,
31
+ resolveHome,
32
+ runDoctor,
33
+ runSync,
34
+ skippedOffboxConfig,
35
+ uninstallSchedule,
36
+ userHome,
37
+ withRetrievalLock,
38
+ writeInitConfig
39
+ } from "./chunk-O43RENSG.js";
40
+
41
+ // src/main.ts
42
+ import { readFileSync } from "fs";
43
+
44
+ // src/commands/init.ts
45
+ import { existsSync } from "fs";
46
+ import { isAbsolute } from "path";
47
+ var USAGE = `Usage: packbat init --yes [--archive-root <abs>] [--offbox skip|remote]
48
+ [--offbox-remote <rclone-dest>] [--age-recipient <age1\u2026>]
49
+ [--rclone-config default|managed] [--no-activate]
50
+ packbat init --uninstall
51
+ `;
52
+ function usageError(message) {
53
+ process.stderr.write(`packbat init: ${message}
54
+
55
+ ${USAGE}`);
56
+ return null;
57
+ }
58
+ function parseOptions(argv) {
59
+ const options = { yes: false, uninstall: false, noActivate: false };
60
+ for (let index = 0; index < argv.length; index += 1) {
61
+ const argument = argv[index];
62
+ switch (argument) {
63
+ case "--yes":
64
+ if (options.yes) {
65
+ return usageError("--yes may only be passed once");
66
+ }
67
+ options.yes = true;
68
+ break;
69
+ case "--archive-root": {
70
+ if (options.archiveRoot !== void 0) {
71
+ return usageError("--archive-root may only be passed once");
72
+ }
73
+ const value = argv[index + 1];
74
+ if (value === void 0 || value.startsWith("--")) {
75
+ return usageError("--archive-root requires an absolute path");
76
+ }
77
+ if (!isAbsolute(value)) {
78
+ return usageError("--archive-root requires an absolute path");
79
+ }
80
+ options.archiveRoot = value;
81
+ index += 1;
82
+ break;
83
+ }
84
+ case "--offbox": {
85
+ if (options.offbox !== void 0) {
86
+ return usageError("--offbox may only be passed once");
87
+ }
88
+ const value = argv[index + 1];
89
+ if (value !== "skip" && value !== "remote") {
90
+ return usageError("--offbox only accepts skip or remote");
91
+ }
92
+ options.offbox = value;
93
+ index += 1;
94
+ break;
95
+ }
96
+ case "--offbox-remote": {
97
+ if (options.offboxRemote !== void 0) {
98
+ return usageError("--offbox-remote may only be passed once");
99
+ }
100
+ const value = argv[index + 1];
101
+ if (value === void 0 || value.startsWith("--")) {
102
+ return usageError("--offbox-remote requires an rclone destination");
103
+ }
104
+ options.offboxRemote = value;
105
+ index += 1;
106
+ break;
107
+ }
108
+ case "--age-recipient": {
109
+ if (options.ageRecipient !== void 0) {
110
+ return usageError("--age-recipient may only be passed once");
111
+ }
112
+ const value = argv[index + 1];
113
+ if (value === void 0 || value.startsWith("--")) {
114
+ return usageError("--age-recipient requires an age1\u2026 recipient");
115
+ }
116
+ if (!/^age1[0-9a-z]+$/u.test(value)) {
117
+ return usageError("--age-recipient requires an age1\u2026 recipient");
118
+ }
119
+ options.ageRecipient = value;
120
+ index += 1;
121
+ break;
122
+ }
123
+ case "--rclone-config": {
124
+ if (options.rcloneConfig !== void 0) {
125
+ return usageError("--rclone-config may only be passed once");
126
+ }
127
+ const value = argv[index + 1];
128
+ if (value !== "default" && value !== "managed") {
129
+ return usageError("--rclone-config only accepts default or managed");
130
+ }
131
+ options.rcloneConfig = value;
132
+ index += 1;
133
+ break;
134
+ }
135
+ case "--no-activate":
136
+ if (options.noActivate) {
137
+ return usageError("--no-activate may only be passed once");
138
+ }
139
+ options.noActivate = true;
140
+ break;
141
+ case "--uninstall":
142
+ if (options.uninstall) {
143
+ return usageError("--uninstall may only be passed once");
144
+ }
145
+ options.uninstall = true;
146
+ break;
147
+ default:
148
+ return usageError(`unknown option ${argument ?? ""}`);
149
+ }
150
+ }
151
+ if (options.uninstall && (options.yes || options.archiveRoot !== void 0 || options.offbox !== void 0 || options.offboxRemote !== void 0 || options.ageRecipient !== void 0 || options.rcloneConfig !== void 0 || options.noActivate)) {
152
+ return usageError("--uninstall cannot be combined with setup options");
153
+ }
154
+ if (options.offbox === "remote") {
155
+ if (options.offboxRemote === void 0) {
156
+ return usageError("--offbox remote requires --offbox-remote");
157
+ }
158
+ if (options.ageRecipient === void 0) {
159
+ return usageError("--offbox remote requires --age-recipient");
160
+ }
161
+ } else if (options.offboxRemote !== void 0 || options.ageRecipient !== void 0 || options.rcloneConfig !== void 0) {
162
+ return usageError("off-box remote options require --offbox remote");
163
+ }
164
+ return options;
165
+ }
166
+ function requestedOffbox(options) {
167
+ if (options.offbox === "skip") {
168
+ return skippedOffboxConfig();
169
+ }
170
+ if (options.offbox === "remote") {
171
+ return {
172
+ mode: "configured",
173
+ recipient: options.ageRecipient,
174
+ remotes: [
175
+ {
176
+ type: "rclone",
177
+ destination: options.offboxRemote,
178
+ rcloneConfig: options.rcloneConfig ?? "default"
179
+ }
180
+ ]
181
+ };
182
+ }
183
+ return void 0;
184
+ }
185
+ async function runInit(argv) {
186
+ const options = parseOptions(argv);
187
+ if (options === null) {
188
+ return 1;
189
+ }
190
+ const homePath = userHome();
191
+ if (options.uninstall) {
192
+ const result = await uninstallSchedule({
193
+ userHome: homePath,
194
+ statePath: resolveHome().statePath,
195
+ env: process.env
196
+ });
197
+ if (result.removedPaths.length === 0) {
198
+ process.stdout.write("schedule: nothing installed\n");
199
+ } else {
200
+ for (const path of result.removedPaths) {
201
+ process.stdout.write(`removed: ${path}
202
+ `);
203
+ }
204
+ }
205
+ return 0;
206
+ }
207
+ if (!options.yes) {
208
+ if (process.stdin.isTTY === true) {
209
+ const { runInitWizard } = await import("./init-wizard-PEBPT3M3.js");
210
+ return await runInitWizard();
211
+ }
212
+ process.stderr.write("packbat init: stdin is not a TTY; run `packbat init --yes`\n");
213
+ return 1;
214
+ }
215
+ const home = resolveHome();
216
+ const detection = detectInitStores(homePath);
217
+ const config = await writeInitConfig(
218
+ home,
219
+ options.archiveRoot ?? (existsSync(home.configPath) ? loadConfig(home).archiveRoot : home.defaultArchiveRoot),
220
+ requestedOffbox(options)
221
+ );
222
+ const installed = await installInitSchedule(await createInitScheduleOptions(home, homePath), !options.noActivate);
223
+ process.stdout.write(`detected: ${detection.detected.map(({ displayName }) => displayName).join(", ") || "none"}
224
+ `);
225
+ if (detection.unsupported.length === 0) {
226
+ process.stdout.write("found, not yet supported: none\n");
227
+ } else {
228
+ for (const { displayName, path } of detection.unsupported) {
229
+ process.stdout.write(`found, not yet supported: ${displayName} (${path})
230
+ `);
231
+ }
232
+ }
233
+ process.stdout.write(`archive: ${config.archiveRoot}
234
+ `);
235
+ for (const path of installed.schedule.artifactPaths) {
236
+ process.stdout.write(`wrote: ${path}
237
+ `);
238
+ }
239
+ for (const note of [...installed.schedule.notes, ...installed.activationNotes]) {
240
+ process.stdout.write(`${note}
241
+ `);
242
+ }
243
+ const syncCode = await runSync([]);
244
+ const doctorCode = await runDoctor([]);
245
+ return syncCode === 1 || doctorCode === 1 ? 1 : 0;
246
+ }
247
+
248
+ // src/core/restore.ts
249
+ import { createHash, randomUUID } from "crypto";
250
+ import { mkdir, readFile, rename, rm, stat, utimes, writeFile } from "fs/promises";
251
+ import { homedir } from "os";
252
+ import { basename, dirname, isAbsolute as isAbsolute2, join, relative, resolve, sep } from "path";
253
+ function recordRelPath(record) {
254
+ const prefix = `${record.harness}${sep}`;
255
+ if (!record.path.startsWith(prefix) || !record.path.endsWith(".zst")) {
256
+ throw new PackbatError(`invalid archive path in index: ${record.path}`);
257
+ }
258
+ const relPath = record.path.slice(prefix.length, -".zst".length);
259
+ if (relPath === "" || isAbsolute2(relPath)) {
260
+ throw new PackbatError(`invalid archive path in index: ${record.path}`);
261
+ }
262
+ return relPath;
263
+ }
264
+ function assertContained(root, path, label) {
265
+ const fromRoot = relative(resolve(root), resolve(path));
266
+ if (fromRoot === "" || fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute2(fromRoot)) {
267
+ throw new PackbatError(`${label} escapes its root: ${path}`);
268
+ }
269
+ }
270
+ function compareRecordRecency(left, right) {
271
+ if (left.sourceMtimeMs !== right.sourceMtimeMs) {
272
+ return left.sourceMtimeMs - right.sourceMtimeMs;
273
+ }
274
+ const archivedAt = left.archivedAt.localeCompare(right.archivedAt);
275
+ return archivedAt !== 0 ? archivedAt : left.path.localeCompare(right.path);
276
+ }
277
+ function codexState(record) {
278
+ const relPath = recordRelPath(record);
279
+ if (relPath.startsWith(`sessions${sep}`)) {
280
+ return "active";
281
+ }
282
+ if (relPath.startsWith(`archived_sessions${sep}`)) {
283
+ return "archived";
284
+ }
285
+ return null;
286
+ }
287
+ function selectCodexState(records) {
288
+ const hasActive = records.some((record) => codexState(record) === "active");
289
+ const hasArchived = records.some((record) => codexState(record) === "archived");
290
+ if (!hasActive || !hasArchived) {
291
+ return { records, supersededLocations: [] };
292
+ }
293
+ const newest = records.reduce((current, record) => compareRecordRecency(record, current) > 0 ? record : current);
294
+ const selectedState = codexState(newest);
295
+ const selected = records.filter((record) => codexState(record) === selectedState);
296
+ const supersededLocations = records.filter((record) => codexState(record) !== selectedState).map(recordRelPath).sort((left, right) => left.localeCompare(right));
297
+ return { records: selected, supersededLocations };
298
+ }
299
+ function toArchivedUnit(config, machine, harness, id, records) {
300
+ const selected = harness === "codex" ? selectCodexState(records) : { records, supersededLocations: [] };
301
+ const machineRoot = join(config.archiveRoot, machine);
302
+ const files = selected.records.map((record) => {
303
+ const archivePath = join(machineRoot, record.path);
304
+ assertContained(machineRoot, archivePath, "archive path");
305
+ return { record, relPath: recordRelPath(record), archivePath };
306
+ }).sort((left, right) => {
307
+ if (left.record.role !== right.record.role) {
308
+ return left.record.role === "main" ? -1 : 1;
309
+ }
310
+ return left.relPath.localeCompare(right.relPath);
311
+ });
312
+ const newestSourceMtimeMs = Math.max(...files.map((file) => file.record.sourceMtimeMs));
313
+ return {
314
+ kind: "session",
315
+ id,
316
+ harness,
317
+ machine,
318
+ files,
319
+ newestSourceMtimeMs,
320
+ archived: harness === "codex" && files.some((file) => codexState(file.record) === "archived"),
321
+ supersededLocations: selected.supersededLocations
322
+ };
323
+ }
324
+ function toArchivedSnapshotUnit(config, machine, id, record) {
325
+ const machineRoot = join(config.archiveRoot, machine);
326
+ const archivePath = join(machineRoot, record.path);
327
+ assertContained(machineRoot, archivePath, "archive path");
328
+ return {
329
+ kind: "db-snapshot",
330
+ id,
331
+ harness: record.harness,
332
+ machine,
333
+ files: [{ record, relPath: recordRelPath(record), archivePath }],
334
+ newestSourceMtimeMs: record.sourceMtimeMs,
335
+ archived: false,
336
+ supersededLocations: []
337
+ };
338
+ }
339
+ function attachGeminiProjectMarkers(groups) {
340
+ const markers = /* @__PURE__ */ new Map();
341
+ for (const group of groups.values()) {
342
+ if (group.harness !== "gemini") {
343
+ continue;
344
+ }
345
+ for (const record of group.records) {
346
+ const parts = recordRelPath(record).split(sep);
347
+ if (parts.length === 2 && parts[1] === ".project_root") {
348
+ markers.set(parts[0], record);
349
+ }
350
+ }
351
+ }
352
+ for (const group of groups.values()) {
353
+ if (group.harness !== "gemini") {
354
+ continue;
355
+ }
356
+ const main2 = group.records.find((record) => record.role === "main");
357
+ if (main2 === void 0) {
358
+ continue;
359
+ }
360
+ const parts = recordRelPath(main2).split(sep);
361
+ const marker = parts[1] === "chats" ? markers.get(parts[0]) : void 0;
362
+ if (marker !== void 0 && !group.records.some((record) => record.path === marker.path)) {
363
+ group.records.push(marker);
364
+ }
365
+ }
366
+ }
367
+ async function readArchivedUnits(config, machine) {
368
+ let records;
369
+ try {
370
+ records = [...(await readDerivedIndex(join(config.archiveRoot, machine), machine)).records.values()].filter(
371
+ (record) => record.machine === machine
372
+ );
373
+ } catch (error) {
374
+ throw new PackbatError(`could not read archive index for ${machine}: ${errorMessage(error)}`);
375
+ }
376
+ const groups = /* @__PURE__ */ new Map();
377
+ for (const record of records.filter((record2) => !isDatabaseSnapshotIndexRecord(record2))) {
378
+ const key = `${record.harness}\0${record.unit}`;
379
+ const group = groups.get(key);
380
+ if (group === void 0) {
381
+ groups.set(key, { harness: record.harness, id: record.unit, records: [record] });
382
+ } else {
383
+ group.records.push(record);
384
+ }
385
+ }
386
+ attachGeminiProjectMarkers(groups);
387
+ const newestSnapshots = /* @__PURE__ */ new Map();
388
+ for (const record of records.filter(isDatabaseSnapshotIndexRecord)) {
389
+ for (const session of record.sessions) {
390
+ const key = `${record.harness}\0${session.id}`;
391
+ const current = newestSnapshots.get(key);
392
+ if (current === void 0 || compareRecordRecency(record, current.record) > 0) {
393
+ newestSnapshots.set(key, { id: session.id, record });
394
+ }
395
+ }
396
+ }
397
+ return [
398
+ ...[...groups.values()].map((group) => toArchivedUnit(config, machine, group.harness, group.id, group.records)),
399
+ ...[...newestSnapshots.values()].map(({ id, record }) => toArchivedSnapshotUnit(config, machine, id, record))
400
+ ].sort(
401
+ (left, right) => right.newestSourceMtimeMs - left.newestSourceMtimeMs || left.id.localeCompare(right.id) || left.harness.localeCompare(right.harness)
402
+ );
403
+ }
404
+ function resolveArchivedUnit(units, prefix) {
405
+ const matchingIds = [...new Set(units.filter((unit) => unit.id.startsWith(prefix)).map((unit) => unit.id))].sort(
406
+ (left, right) => left.localeCompare(right)
407
+ );
408
+ if (matchingIds.length === 0) {
409
+ throw new PackbatError(`no archived unit matches "${prefix}"`);
410
+ }
411
+ if (matchingIds.length > 1) {
412
+ throw new PackbatError(
413
+ `archive prefix "${prefix}" is ambiguous:
414
+ ${matchingIds.map((id2) => ` ${id2}`).join("\n")}`
415
+ );
416
+ }
417
+ const id = matchingIds[0];
418
+ const matches = units.filter((unit) => unit.id === id);
419
+ if (matches.length !== 1) {
420
+ throw new PackbatError(
421
+ `archive id "${id}" exists in multiple harnesses:
422
+ ${matches.map((unit) => ` ${unit.id} (${unit.harness})`).join("\n")}`
423
+ );
424
+ }
425
+ return matches[0];
426
+ }
427
+ async function liveMtime(path) {
428
+ try {
429
+ return (await stat(path)).mtimeMs;
430
+ } catch (error) {
431
+ if (error.code === "ENOENT") {
432
+ return null;
433
+ }
434
+ throw new PackbatError(`could not inspect live target ${path}: ${errorMessage(error)}`);
435
+ }
436
+ }
437
+ async function writeRestoredFile(plan) {
438
+ let archivedBytes;
439
+ try {
440
+ archivedBytes = await readFile(plan.file.archivePath);
441
+ } catch (error) {
442
+ throw new PackbatError(`could not read archived file ${plan.file.archivePath}: ${errorMessage(error)}`);
443
+ }
444
+ const actualSha256 = createHash("sha256").update(archivedBytes).digest("hex");
445
+ if (actualSha256 !== plan.file.record.sha256) {
446
+ throw new PackbatError(`archived file is corrupt: ${plan.file.archivePath} (sha256 mismatch)`);
447
+ }
448
+ let bytes;
449
+ try {
450
+ bytes = decompressBytes(archivedBytes);
451
+ } catch (error) {
452
+ throw new PackbatError(`could not read archived file ${plan.file.archivePath}: ${errorMessage(error)}`);
453
+ }
454
+ await mkdir(dirname(plan.target), { recursive: true });
455
+ const temporary = join(dirname(plan.target), `.${basename(plan.target)}.tmp-${process.pid}-${randomUUID()}`);
456
+ try {
457
+ await writeFile(temporary, bytes);
458
+ await rename(temporary, plan.target);
459
+ const mtimeSeconds = plan.file.record.sourceMtimeMs / 1e3;
460
+ await utimes(plan.target, mtimeSeconds, mtimeSeconds);
461
+ } catch (error) {
462
+ await rm(temporary, { force: true });
463
+ throw new PackbatError(`could not restore ${plan.target}: ${errorMessage(error)}`);
464
+ }
465
+ }
466
+ async function archivedPayload(file) {
467
+ let archivedBytes;
468
+ try {
469
+ archivedBytes = await readFile(file.archivePath);
470
+ } catch (error) {
471
+ throw new PackbatError(`could not read archived file ${file.archivePath}: ${errorMessage(error)}`);
472
+ }
473
+ const actualSha256 = createHash("sha256").update(archivedBytes).digest("hex");
474
+ if (actualSha256 !== file.record.sha256) {
475
+ throw new PackbatError(`archived file is corrupt: ${file.archivePath} (sha256 mismatch)`);
476
+ }
477
+ try {
478
+ return decompressBytes(archivedBytes);
479
+ } catch (error) {
480
+ throw new PackbatError(`could not read archived file ${file.archivePath}: ${errorMessage(error)}`);
481
+ }
482
+ }
483
+ function sideBySidePath(target, sessionId) {
484
+ return join(dirname(target), `opencode-restored-${sessionId}.db`);
485
+ }
486
+ async function restoreDatabaseSnapshot(unit) {
487
+ const adapter = getAdapter(unit.harness);
488
+ if (adapter === void 0 || adapter.mutationModel !== "db-snapshot") {
489
+ throw new PackbatError(`archive uses unsupported database snapshot harness ${unit.harness}`);
490
+ }
491
+ const target = adapter.storeRoot(process.env, homedir());
492
+ if (await liveMtime(target) !== null) {
493
+ const recoveryPath = sideBySidePath(target, unit.id);
494
+ throw new PackbatError(
495
+ `restore requires an absent OpenCode database: ${target}
496
+ side-by-side recovery: OPENCODE_DB=${recoveryPath} opencode -s ${unit.id}`
497
+ );
498
+ }
499
+ const file = unit.files[0];
500
+ if (file === void 0 || unit.files.length !== 1 || !isDatabaseSnapshotIndexRecord(file.record)) {
501
+ throw new PackbatError(`invalid database snapshot archive for ${unit.id}`);
502
+ }
503
+ const bytes = await archivedPayload(file);
504
+ const contentSha256 = createHash("sha256").update(bytes).digest("hex");
505
+ if (contentSha256 !== file.record.contentSha256) {
506
+ throw new PackbatError(`archived database is corrupt: ${file.archivePath} (content sha256 mismatch)`);
507
+ }
508
+ await mkdir(dirname(target), { recursive: true });
509
+ const temporary = join(dirname(target), `.${basename(target)}.tmp-${process.pid}-${randomUUID()}`);
510
+ try {
511
+ await writeFile(temporary, bytes);
512
+ await adapter.validateSnapshot(temporary, unit.id);
513
+ if (await liveMtime(target) !== null) {
514
+ const recoveryPath = sideBySidePath(target, unit.id);
515
+ throw new PackbatError(
516
+ `restore requires an absent OpenCode database: ${target}
517
+ side-by-side recovery: OPENCODE_DB=${recoveryPath} opencode -s ${unit.id}`
518
+ );
519
+ }
520
+ await Promise.all([rm(`${target}-wal`, { force: true }), rm(`${target}-shm`, { force: true })]);
521
+ await rename(temporary, target);
522
+ } catch (error) {
523
+ await rm(temporary, { force: true });
524
+ if (error instanceof PackbatError) {
525
+ throw error;
526
+ }
527
+ throw new PackbatError(`could not restore ${target}: ${errorMessage(error)}`);
528
+ }
529
+ return {
530
+ fileCount: 1,
531
+ targetRoot: target,
532
+ resumeHints: adapter.resumeHint({ id: unit.id, targetPath: target })
533
+ };
534
+ }
535
+ async function restoreArchivedUnit(unit, force) {
536
+ if (unit.kind === "db-snapshot") {
537
+ return await restoreDatabaseSnapshot(unit);
538
+ }
539
+ const adapter = getAdapter(unit.harness);
540
+ if (adapter === void 0 || adapter.mutationModel === "db-snapshot") {
541
+ throw new PackbatError(`archive uses unsupported harness ${unit.harness}`);
542
+ }
543
+ const targetRoot = adapter.storeRoot(process.env, homedir());
544
+ const plans = unit.files.map((file) => {
545
+ const target = adapter.restoreTarget(targetRoot, file.relPath);
546
+ assertContained(targetRoot, target, "restore target");
547
+ return { file, target };
548
+ });
549
+ const targets = /* @__PURE__ */ new Set();
550
+ for (const plan of plans) {
551
+ if (targets.has(plan.target)) {
552
+ throw new PackbatError(`archive maps more than one file to ${plan.target}`);
553
+ }
554
+ targets.add(plan.target);
555
+ }
556
+ const offenders = [];
557
+ for (const plan of plans) {
558
+ const mtimeMs = await liveMtime(plan.target);
559
+ if (mtimeMs !== null && mtimeMs > plan.file.record.sourceMtimeMs) {
560
+ offenders.push(plan.target);
561
+ }
562
+ }
563
+ if (!force && offenders.length > 0) {
564
+ throw new PackbatError(
565
+ `restore would overwrite newer live files:
566
+ ${offenders.map((path) => ` ${path}`).join("\n")}`
567
+ );
568
+ }
569
+ for (const plan of plans) {
570
+ await writeRestoredFile(plan);
571
+ }
572
+ return {
573
+ fileCount: plans.length,
574
+ targetRoot,
575
+ resumeHints: adapter.resumeHint({ id: unit.id, relPaths: unit.files.map((file) => file.relPath) })
576
+ };
577
+ }
578
+
579
+ // src/offbox/remote-restore.ts
580
+ import { mkdir as mkdir2, mkdtemp, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
581
+ import { tmpdir } from "os";
582
+ import { dirname as dirname2, join as join2 } from "path";
583
+ async function readIdentity(path) {
584
+ try {
585
+ return parseIdentityFile(await readFile2(path, "utf8"));
586
+ } catch (error) {
587
+ if (error instanceof PackbatError) {
588
+ throw error;
589
+ }
590
+ throw new PackbatError(`could not read identity file ${path}: ${errorMessage(error)}`);
591
+ }
592
+ }
593
+ async function pullAndDecryptFile(options) {
594
+ await mkdir2(dirname2(options.encryptedPath), { recursive: true });
595
+ await options.pull(options.encryptedPath);
596
+ try {
597
+ await writeFile2(
598
+ options.decryptedPath,
599
+ await decryptWithIdentity(options.identity, await readFile2(options.encryptedPath))
600
+ );
601
+ } catch (error) {
602
+ throw new PackbatError(`could not decrypt ${options.label}: ${errorMessage(error)}`);
603
+ }
604
+ }
605
+ function selectRemote(config, destination) {
606
+ if (config.offbox.mode !== "configured") {
607
+ throw new PackbatError("off-box is not configured; run `packbat init` first");
608
+ }
609
+ if (destination === void 0) {
610
+ return config.offbox.remotes[0];
611
+ }
612
+ const remote = config.offbox.remotes.find((candidate) => candidate.destination === destination);
613
+ if (remote === void 0) {
614
+ throw new PackbatError(`no configured remote has destination ${destination}`);
615
+ }
616
+ return remote;
617
+ }
618
+ async function restoreFromRemote(options) {
619
+ if (options.config.offbox.mode !== "configured") {
620
+ throw new PackbatError("off-box is not configured; run `packbat init` first");
621
+ }
622
+ const offbox = options.config.offbox;
623
+ const remoteConfig = selectRemote(options.config, options.remoteDestination);
624
+ const remote = createArchiveRemote(remoteConfig);
625
+ const identity = await readIdentity(options.identityPath);
626
+ let recipient;
627
+ try {
628
+ recipient = await identityToRecipient(identity);
629
+ } catch (error) {
630
+ throw new PackbatError(`could not parse age identity: ${errorMessage(error)}`);
631
+ }
632
+ if (recipient !== offbox.recipient) {
633
+ throw new PackbatError("identity does not match the configured age recipient");
634
+ }
635
+ const stagePath = await mkdtemp(join2(tmpdir(), "packbat-remote-restore-"));
636
+ try {
637
+ const machinePath = join2(stagePath, options.machine);
638
+ const encryptedIndexPath = join2(stagePath, "index.jsonl.age");
639
+ await mkdir2(machinePath, { recursive: true });
640
+ await pullAndDecryptFile({
641
+ pull: async (destinationPath) => await remote.getIndex(options.machine, destinationPath),
642
+ encryptedPath: encryptedIndexPath,
643
+ decryptedPath: join2(machinePath, "index.jsonl"),
644
+ identity,
645
+ label: "remote index"
646
+ });
647
+ const stageConfig = { ...options.config, archiveRoot: stagePath };
648
+ const units = await readArchivedUnits(stageConfig, options.machine);
649
+ if (options.prefix === void 0) {
650
+ return { kind: "listed", units };
651
+ }
652
+ const unit = resolveArchivedUnit(units, options.prefix);
653
+ for (const file of unit.files) {
654
+ const encryptedPath = `${file.archivePath}.age`;
655
+ await pullAndDecryptFile({
656
+ pull: async (destinationPath) => await remote.getArchiveObject(options.machine, file.record.path, destinationPath),
657
+ encryptedPath,
658
+ decryptedPath: file.archivePath,
659
+ identity,
660
+ label: `remote file ${file.record.path}`
661
+ });
662
+ }
663
+ return { kind: "restored", unit, restore: await restoreArchivedUnit(unit, options.force) };
664
+ } finally {
665
+ await rm2(stagePath, { recursive: true, force: true });
666
+ }
667
+ }
668
+
669
+ // src/commands/restore.ts
670
+ var USAGE2 = "Usage: packbat restore [--machine <name>] [--force] [--from-remote --identity <file> [--remote <destination>]] [<id-or-prefix>]\n";
671
+ var MACHINE_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
672
+ function usageError2(message) {
673
+ process.stderr.write(`packbat restore: ${message}
674
+
675
+ ${USAGE2}`);
676
+ return null;
677
+ }
678
+ function parseOptions2(argv) {
679
+ const options = { force: false, fromRemote: false };
680
+ for (let index = 0; index < argv.length; index += 1) {
681
+ const argument = argv[index];
682
+ switch (argument) {
683
+ case "--machine": {
684
+ if (options.machine !== void 0) {
685
+ return usageError2("--machine may only be passed once");
686
+ }
687
+ const value = argv[index + 1];
688
+ if (value === void 0 || value.startsWith("--")) {
689
+ return usageError2("--machine requires a name");
690
+ }
691
+ if (!MACHINE_PATTERN.test(value)) {
692
+ return usageError2("--machine must be lowercase and hostname-safe (a-z, 0-9, -)");
693
+ }
694
+ options.machine = value;
695
+ index += 1;
696
+ break;
697
+ }
698
+ case "--force":
699
+ if (options.force) {
700
+ return usageError2("--force may only be passed once");
701
+ }
702
+ options.force = true;
703
+ break;
704
+ case "--from-remote":
705
+ if (options.fromRemote) {
706
+ return usageError2("--from-remote may only be passed once");
707
+ }
708
+ options.fromRemote = true;
709
+ break;
710
+ case "--identity": {
711
+ if (options.identityPath !== void 0) {
712
+ return usageError2("--identity may only be passed once");
713
+ }
714
+ const value = argv[index + 1];
715
+ if (value === void 0 || value.startsWith("--")) {
716
+ return usageError2("--identity requires a file");
717
+ }
718
+ options.identityPath = value;
719
+ index += 1;
720
+ break;
721
+ }
722
+ case "--remote": {
723
+ if (options.remoteDestination !== void 0) {
724
+ return usageError2("--remote may only be passed once");
725
+ }
726
+ const value = argv[index + 1];
727
+ if (value === void 0 || value.startsWith("--")) {
728
+ return usageError2("--remote requires a destination");
729
+ }
730
+ options.remoteDestination = value;
731
+ index += 1;
732
+ break;
733
+ }
734
+ default:
735
+ if (argument === void 0) {
736
+ return usageError2("missing argument");
737
+ }
738
+ if (argument.startsWith("-")) {
739
+ return usageError2(`unknown option ${argument}`);
740
+ }
741
+ if (options.prefix !== void 0) {
742
+ return usageError2("only one id or prefix may be passed");
743
+ }
744
+ options.prefix = argument;
745
+ }
746
+ }
747
+ if (options.force && options.prefix === void 0) {
748
+ return usageError2("--force requires an id or prefix");
749
+ }
750
+ if (options.fromRemote && options.identityPath === void 0) {
751
+ return usageError2("--from-remote requires --identity <file>");
752
+ }
753
+ if (!options.fromRemote && options.identityPath !== void 0) {
754
+ return usageError2("--identity requires --from-remote");
755
+ }
756
+ if (!options.fromRemote && options.remoteDestination !== void 0) {
757
+ return usageError2("--remote requires --from-remote");
758
+ }
759
+ return options;
760
+ }
761
+ function printUnits(machine, units) {
762
+ if (units.length === 0) {
763
+ process.stdout.write(`no archived sessions for ${machine}
764
+ `);
765
+ return;
766
+ }
767
+ for (const unit of units) {
768
+ const fileCount = `${unit.files.length} file${unit.files.length === 1 ? "" : "s"}`;
769
+ const archived = unit.archived ? " \xB7 archived" : "";
770
+ process.stdout.write(
771
+ `${unit.id} \xB7 ${unit.harness} \xB7 ${unit.machine} \xB7 ${fileCount} \xB7 ${new Date(unit.newestSourceMtimeMs).toISOString()}${archived}
772
+ `
773
+ );
774
+ }
775
+ }
776
+ function printRestoreResult(unit, result) {
777
+ for (const location of unit.supersededLocations) {
778
+ process.stdout.write(`superseded codex location: ${location}
779
+ `);
780
+ }
781
+ process.stdout.write(
782
+ `restored ${result.fileCount} file${result.fileCount === 1 ? "" : "s"} to ${result.targetRoot}
783
+ `
784
+ );
785
+ for (const hint of result.resumeHints) {
786
+ process.stdout.write(`${hint}
787
+ `);
788
+ }
789
+ }
790
+ async function runRestore(argv) {
791
+ const options = parseOptions2(argv);
792
+ if (options === null) {
793
+ return 1;
794
+ }
795
+ const home = resolveHome();
796
+ const config = loadConfig(home);
797
+ const machine = options.machine ?? config.machine;
798
+ if (options.fromRemote) {
799
+ const remote = await restoreFromRemote({
800
+ config,
801
+ machine,
802
+ identityPath: options.identityPath,
803
+ remoteDestination: options.remoteDestination,
804
+ prefix: options.prefix,
805
+ force: options.force
806
+ });
807
+ if (remote.kind === "listed") {
808
+ printUnits(machine, remote.units);
809
+ } else {
810
+ printRestoreResult(remote.unit, remote.restore);
811
+ }
812
+ return 0;
813
+ }
814
+ const units = await readArchivedUnits(config, machine);
815
+ if (options.prefix === void 0) {
816
+ printUnits(machine, units);
817
+ return 0;
818
+ }
819
+ const unit = resolveArchivedUnit(units, options.prefix);
820
+ const result = await restoreArchivedUnit(unit, options.force);
821
+ printRestoreResult(unit, result);
822
+ return 0;
823
+ }
824
+
825
+ // src/commands/search.ts
826
+ import { resolve as resolve2 } from "path";
827
+
828
+ // src/retrieval/database.ts
829
+ import { randomUUID as randomUUID2 } from "crypto";
830
+ import { chmod, mkdir as mkdir3, rename as rename2, rm as rm3, stat as stat3 } from "fs/promises";
831
+ import { createRequire } from "module";
832
+ import { basename as basename2, dirname as dirname3, join as join4 } from "path";
833
+
834
+ // src/readers/common.ts
835
+ import { createReadStream } from "fs";
836
+ import { createInterface } from "readline";
837
+ import { createZstdDecompress } from "zlib";
838
+ var MAX_ISSUES = 100;
839
+ function asObject(value) {
840
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
841
+ }
842
+ function stringField(object, key) {
843
+ const value = object?.[key];
844
+ return typeof value === "string" ? value : null;
845
+ }
846
+ function timestamp(value) {
847
+ if (typeof value !== "string") {
848
+ return null;
849
+ }
850
+ const milliseconds = Date.parse(value);
851
+ return Number.isNaN(milliseconds) ? null : new Date(milliseconds).toISOString();
852
+ }
853
+ function stringArray(value) {
854
+ if (!Array.isArray(value)) {
855
+ return [];
856
+ }
857
+ return [...new Set(value.filter((item) => typeof item === "string"))];
858
+ }
859
+ function textContent(value) {
860
+ if (typeof value === "string") {
861
+ return value === "" ? [] : [value];
862
+ }
863
+ if (!Array.isArray(value)) {
864
+ return [];
865
+ }
866
+ const texts = [];
867
+ for (const item of value) {
868
+ const block = asObject(item);
869
+ if (block?.type === "text" && typeof block.text === "string" && block.text !== "") {
870
+ texts.push(block.text);
871
+ } else if (block?.type === "input_text" && typeof block.text === "string" && block.text !== "") {
872
+ texts.push(block.text);
873
+ } else if (block?.type === "output_text" && typeof block.text === "string" && block.text !== "") {
874
+ texts.push(block.text);
875
+ }
876
+ }
877
+ return [...new Set(texts)];
878
+ }
879
+ function addIssue(issues, seen, issue, dedupeKey = `${issue.code}\0${issue.detail}`) {
880
+ if (issues.length >= MAX_ISSUES || seen.has(dedupeKey)) {
881
+ return;
882
+ }
883
+ seen.add(dedupeKey);
884
+ issues.push(issue);
885
+ }
886
+ async function parseJsonl(file) {
887
+ const records = [];
888
+ const issues = [];
889
+ let invalidLines = 0;
890
+ let line = 0;
891
+ try {
892
+ const decompressor = createZstdDecompress();
893
+ const input = createReadStream(file.archivePath);
894
+ const lines = createInterface({ input: input.pipe(decompressor), crlfDelay: Number.POSITIVE_INFINITY });
895
+ for await (const contents of lines) {
896
+ line += 1;
897
+ if (contents.trim() === "") {
898
+ continue;
899
+ }
900
+ try {
901
+ const value = JSON.parse(contents);
902
+ const object = asObject(value);
903
+ if (object === null) {
904
+ invalidLines += 1;
905
+ issues.push({
906
+ sourcePath: file.path,
907
+ sourceLine: line,
908
+ code: "invalid-record",
909
+ detail: "skipped JSON value that is not an object"
910
+ });
911
+ } else {
912
+ records.push({ value: object, line });
913
+ }
914
+ } catch {
915
+ invalidLines += 1;
916
+ if (issues.length < MAX_ISSUES) {
917
+ issues.push({
918
+ sourcePath: file.path,
919
+ sourceLine: line,
920
+ code: "malformed-json",
921
+ detail: "skipped malformed JSONL record"
922
+ });
923
+ }
924
+ }
925
+ }
926
+ return { records, issues, invalidLines, validLines: records.length, corrupt: false };
927
+ } catch (error) {
928
+ return {
929
+ records,
930
+ issues: [
931
+ ...issues,
932
+ {
933
+ sourcePath: file.path,
934
+ sourceLine: null,
935
+ code: "zstd-corrupt",
936
+ detail: `could not decompress archive: ${error instanceof Error ? error.message : String(error)}`
937
+ }
938
+ ],
939
+ invalidLines,
940
+ validLines: records.length,
941
+ corrupt: true
942
+ };
943
+ }
944
+ }
945
+ function finishTurns(turns) {
946
+ turns.sort((left, right) => {
947
+ if (left.timestamp === null && right.timestamp !== null) {
948
+ return 1;
949
+ }
950
+ if (left.timestamp !== null && right.timestamp === null) {
951
+ return -1;
952
+ }
953
+ return (left.timestamp?.localeCompare(right.timestamp ?? "") ?? 0) || left.sourcePath.localeCompare(right.sourcePath) || left.sourceLine - right.sourceLine;
954
+ });
955
+ return turns.map((turn, index) => ({ ...turn, turn: index }));
956
+ }
957
+ function pushUniqueTurn(turns, seen, turn) {
958
+ if (turn.text === "") {
959
+ return;
960
+ }
961
+ const key = `${turn.sourcePath}\0${turn.sourceLine}\0${turn.role}\0${turn.text}\0${JSON.stringify(turn.filesTouched)}\0${JSON.stringify(turn.commands)}`;
962
+ if (!seen.has(key)) {
963
+ seen.add(key);
964
+ turns.push(turn);
965
+ }
966
+ }
967
+ function explicitPaths(input) {
968
+ if (input === null) {
969
+ return [];
970
+ }
971
+ const paths = [];
972
+ for (const key of ["file_path", "path", "filepath", "absolute_path"]) {
973
+ const value = input[key];
974
+ if (typeof value === "string") {
975
+ paths.push(value);
976
+ }
977
+ }
978
+ for (const key of ["files", "paths", "file_paths"]) {
979
+ paths.push(...stringArray(input[key]));
980
+ }
981
+ return [...new Set(paths)];
982
+ }
983
+ function shellCommands(name, input) {
984
+ if (name === null || input === null || !/^(bash|shell|exec|exec_command)$/i.test(name)) {
985
+ return [];
986
+ }
987
+ for (const key of ["command", "cmd", "script"]) {
988
+ const value = input[key];
989
+ if (typeof value === "string") {
990
+ return [value];
991
+ }
992
+ }
993
+ return [];
994
+ }
995
+
996
+ // src/readers/claude-code.ts
997
+ var KNOWN_METADATA = /* @__PURE__ */ new Set([
998
+ "agent-name",
999
+ "ai-title",
1000
+ "attachment",
1001
+ "bridge-session",
1002
+ "custom-title",
1003
+ "file-history-snapshot",
1004
+ "last-prompt",
1005
+ "mode",
1006
+ "permission-mode",
1007
+ "pr-link",
1008
+ "queue-operation",
1009
+ "relocated",
1010
+ "system",
1011
+ "worktree-state"
1012
+ ]);
1013
+ var claudeCodeReader = {
1014
+ harness: "claude-code",
1015
+ version: 1,
1016
+ async read(unit) {
1017
+ const turns = [];
1018
+ const turnKeys = /* @__PURE__ */ new Set();
1019
+ const issues = [];
1020
+ const issueKeys = /* @__PURE__ */ new Set();
1021
+ const files = [];
1022
+ for (const file of unit.files) {
1023
+ if (!file.path.endsWith(".jsonl.zst")) {
1024
+ files.push({ path: file.path, status: "ok" });
1025
+ continue;
1026
+ }
1027
+ const parsed = await parseJsonl(file);
1028
+ for (const issue of parsed.issues) {
1029
+ addIssue(issues, issueKeys, issue, `${issue.sourcePath}\0${issue.sourceLine}\0${issue.code}`);
1030
+ }
1031
+ if (parsed.corrupt || parsed.validLines === 0 && parsed.invalidLines > 0) {
1032
+ files.push({ path: file.path, status: "corrupt" });
1033
+ continue;
1034
+ }
1035
+ let project = null;
1036
+ let recognized = false;
1037
+ let filePartial = parsed.invalidLines > 0;
1038
+ for (const record of parsed.records) {
1039
+ const type = stringField(record.value, "type");
1040
+ const ownProject = stringField(record.value, "cwd");
1041
+ if (ownProject !== null) {
1042
+ project = ownProject;
1043
+ }
1044
+ if (type !== "user" && type !== "assistant") {
1045
+ if (type !== null && KNOWN_METADATA.has(type)) {
1046
+ continue;
1047
+ }
1048
+ filePartial = true;
1049
+ addIssue(issues, issueKeys, {
1050
+ sourcePath: file.path,
1051
+ sourceLine: record.line,
1052
+ code: "unknown-record",
1053
+ detail: `skipped record type ${type ?? "<missing>"}`
1054
+ });
1055
+ continue;
1056
+ }
1057
+ recognized = true;
1058
+ const embeddedId = stringField(record.value, "sessionId");
1059
+ if (embeddedId !== null && embeddedId !== unit.id) {
1060
+ filePartial = true;
1061
+ addIssue(issues, issueKeys, {
1062
+ sourcePath: file.path,
1063
+ sourceLine: record.line,
1064
+ code: "identity-mismatch",
1065
+ detail: `session id ${embeddedId} does not match archive unit ${unit.id}`
1066
+ });
1067
+ }
1068
+ const recordTimestamp = timestamp(record.value.timestamp);
1069
+ const message = asObject(record.value.message);
1070
+ const role = type === "user" ? "user" : "assistant";
1071
+ const isSummary = record.value.isCompactSummary === true;
1072
+ for (const text of textContent(message?.content)) {
1073
+ pushUniqueTurn(turns, turnKeys, {
1074
+ timestamp: recordTimestamp,
1075
+ project,
1076
+ role: isSummary ? "summary" : role,
1077
+ text,
1078
+ filesTouched: [],
1079
+ commands: [],
1080
+ sourcePath: file.path,
1081
+ sourceLine: record.line
1082
+ });
1083
+ }
1084
+ if (!Array.isArray(message?.content)) {
1085
+ continue;
1086
+ }
1087
+ for (const rawBlock of message.content) {
1088
+ const block = asObject(rawBlock);
1089
+ const blockType = stringField(block, "type");
1090
+ if (blockType === "tool_use") {
1091
+ const name = stringField(block, "name");
1092
+ const input = asObject(block?.input);
1093
+ pushUniqueTurn(turns, turnKeys, {
1094
+ timestamp: recordTimestamp,
1095
+ project,
1096
+ role: "tool",
1097
+ text: name ?? "tool call",
1098
+ filesTouched: explicitPaths(input),
1099
+ commands: shellCommands(name, input),
1100
+ sourcePath: file.path,
1101
+ sourceLine: record.line
1102
+ });
1103
+ } else if (blockType === "tool_result") {
1104
+ for (const text of textContent(block?.content)) {
1105
+ pushUniqueTurn(turns, turnKeys, {
1106
+ timestamp: recordTimestamp,
1107
+ project,
1108
+ role: "tool",
1109
+ text,
1110
+ filesTouched: [],
1111
+ commands: [],
1112
+ sourcePath: file.path,
1113
+ sourceLine: record.line
1114
+ });
1115
+ }
1116
+ } else if (!["text", "thinking", "image", "document", "fallback"].includes(blockType ?? "")) {
1117
+ filePartial = true;
1118
+ addIssue(issues, issueKeys, {
1119
+ sourcePath: file.path,
1120
+ sourceLine: record.line,
1121
+ code: "unknown-content",
1122
+ detail: `skipped content type ${blockType ?? "<missing>"}`
1123
+ });
1124
+ }
1125
+ }
1126
+ }
1127
+ files.push({
1128
+ path: file.path,
1129
+ status: recognized ? filePartial ? "partial" : "ok" : "unsupported"
1130
+ });
1131
+ if (!recognized) {
1132
+ addIssue(issues, issueKeys, {
1133
+ sourcePath: file.path,
1134
+ sourceLine: null,
1135
+ code: "unsupported-structure",
1136
+ detail: "no recognized Claude Code session records"
1137
+ });
1138
+ }
1139
+ }
1140
+ return { turns: finishTurns(turns), issues, files };
1141
+ }
1142
+ };
1143
+
1144
+ // src/readers/codex.ts
1145
+ function structuredInput(value) {
1146
+ if (typeof value === "string") {
1147
+ try {
1148
+ return asObject(JSON.parse(value));
1149
+ } catch {
1150
+ return null;
1151
+ }
1152
+ }
1153
+ return asObject(value);
1154
+ }
1155
+ function eventText(payload) {
1156
+ const type = stringField(payload, "type");
1157
+ if (type === "user_message") {
1158
+ return { role: "user", text: stringField(payload, "message") ?? "" };
1159
+ }
1160
+ if (type === "agent_message") {
1161
+ return { role: "assistant", text: stringField(payload, "message") ?? "" };
1162
+ }
1163
+ return null;
1164
+ }
1165
+ var codexReader = {
1166
+ harness: "codex",
1167
+ version: 1,
1168
+ async read(unit) {
1169
+ const turns = [];
1170
+ const turnKeys = /* @__PURE__ */ new Set();
1171
+ const issues = [];
1172
+ const issueKeys = /* @__PURE__ */ new Set();
1173
+ const files = [];
1174
+ for (const file of unit.files) {
1175
+ const parsed = await parseJsonl(file);
1176
+ for (const issue of parsed.issues) {
1177
+ addIssue(issues, issueKeys, issue, `${issue.sourcePath}\0${issue.sourceLine}\0${issue.code}`);
1178
+ }
1179
+ if (parsed.corrupt || parsed.validLines === 0 && parsed.invalidLines > 0) {
1180
+ files.push({ path: file.path, status: "corrupt" });
1181
+ continue;
1182
+ }
1183
+ let project = null;
1184
+ let recognized = false;
1185
+ let filePartial = parsed.invalidLines > 0;
1186
+ const canonicalMessages = /* @__PURE__ */ new Set();
1187
+ const deferredEvents = [];
1188
+ for (const record of parsed.records) {
1189
+ const type = stringField(record.value, "type");
1190
+ const payload = asObject(record.value.payload);
1191
+ const recordTimestamp = timestamp(record.value.timestamp);
1192
+ project = stringField(record.value, "cwd") ?? project;
1193
+ if (type === "session_meta") {
1194
+ recognized = true;
1195
+ project = stringField(payload, "cwd") ?? project;
1196
+ const embeddedId = stringField(payload, "id");
1197
+ if (embeddedId !== null && embeddedId !== unit.id) {
1198
+ filePartial = true;
1199
+ addIssue(issues, issueKeys, {
1200
+ sourcePath: file.path,
1201
+ sourceLine: record.line,
1202
+ code: "identity-mismatch",
1203
+ detail: `session id ${embeddedId} does not match archive unit ${unit.id}`
1204
+ });
1205
+ }
1206
+ continue;
1207
+ }
1208
+ if (type === "turn_context") {
1209
+ recognized = true;
1210
+ project = stringField(payload, "cwd") ?? project;
1211
+ continue;
1212
+ }
1213
+ if (type === "response_item") {
1214
+ recognized = true;
1215
+ const itemType = stringField(payload, "type");
1216
+ if (itemType === "message") {
1217
+ const rawRole = stringField(payload, "role");
1218
+ const role = rawRole === "user" ? "user" : rawRole === "assistant" ? "assistant" : null;
1219
+ if (role !== null) {
1220
+ for (const text of textContent(payload?.content)) {
1221
+ canonicalMessages.add(`${role}\0${text}\0${recordTimestamp ?? ""}`);
1222
+ pushUniqueTurn(turns, turnKeys, {
1223
+ timestamp: recordTimestamp,
1224
+ project,
1225
+ role,
1226
+ text,
1227
+ filesTouched: [],
1228
+ commands: [],
1229
+ sourcePath: file.path,
1230
+ sourceLine: record.line
1231
+ });
1232
+ }
1233
+ }
1234
+ } else if (itemType === "function_call" || itemType === "custom_tool_call") {
1235
+ const name = stringField(payload, "name");
1236
+ const input = structuredInput(payload?.arguments ?? payload?.input);
1237
+ pushUniqueTurn(turns, turnKeys, {
1238
+ timestamp: recordTimestamp,
1239
+ project,
1240
+ role: "tool",
1241
+ text: name ?? "tool call",
1242
+ filesTouched: explicitPaths(input),
1243
+ commands: shellCommands(name, input),
1244
+ sourcePath: file.path,
1245
+ sourceLine: record.line
1246
+ });
1247
+ } else if (itemType === "function_call_output" || itemType === "custom_tool_call_output") {
1248
+ for (const text of textContent(payload?.output)) {
1249
+ pushUniqueTurn(turns, turnKeys, {
1250
+ timestamp: recordTimestamp,
1251
+ project,
1252
+ role: "tool",
1253
+ text,
1254
+ filesTouched: [],
1255
+ commands: [],
1256
+ sourcePath: file.path,
1257
+ sourceLine: record.line
1258
+ });
1259
+ }
1260
+ } else if (!["reasoning", "tool_search_call", "tool_search_output", "agent_message"].includes(itemType ?? "")) {
1261
+ filePartial = true;
1262
+ addIssue(issues, issueKeys, {
1263
+ sourcePath: file.path,
1264
+ sourceLine: record.line,
1265
+ code: "unknown-content",
1266
+ detail: `skipped response item type ${itemType ?? "<missing>"}`
1267
+ });
1268
+ }
1269
+ continue;
1270
+ }
1271
+ if (type === "event_msg") {
1272
+ recognized = true;
1273
+ const event = payload === null ? null : eventText(payload);
1274
+ if (event !== null && event.text !== "") {
1275
+ deferredEvents.push({
1276
+ timestamp: recordTimestamp,
1277
+ project,
1278
+ role: event.role,
1279
+ text: event.text,
1280
+ filesTouched: [],
1281
+ commands: [],
1282
+ sourcePath: file.path,
1283
+ sourceLine: record.line
1284
+ });
1285
+ }
1286
+ continue;
1287
+ }
1288
+ if (type === "compacted") {
1289
+ recognized = true;
1290
+ const text = stringField(payload, "message");
1291
+ if (text !== null) {
1292
+ pushUniqueTurn(turns, turnKeys, {
1293
+ timestamp: recordTimestamp,
1294
+ project,
1295
+ role: "summary",
1296
+ text,
1297
+ filesTouched: [],
1298
+ commands: [],
1299
+ sourcePath: file.path,
1300
+ sourceLine: record.line
1301
+ });
1302
+ }
1303
+ continue;
1304
+ }
1305
+ if (type === "world_state" || type === "inter_agent_communication_metadata") {
1306
+ recognized = true;
1307
+ continue;
1308
+ }
1309
+ filePartial = true;
1310
+ addIssue(issues, issueKeys, {
1311
+ sourcePath: file.path,
1312
+ sourceLine: record.line,
1313
+ code: "unknown-record",
1314
+ detail: `skipped record type ${type ?? "<missing>"}`
1315
+ });
1316
+ }
1317
+ for (const event of deferredEvents) {
1318
+ if (!canonicalMessages.has(`${event.role}\0${event.text}\0${event.timestamp ?? ""}`)) {
1319
+ pushUniqueTurn(turns, turnKeys, event);
1320
+ }
1321
+ }
1322
+ files.push({ path: file.path, status: recognized ? filePartial ? "partial" : "ok" : "unsupported" });
1323
+ if (!recognized) {
1324
+ addIssue(issues, issueKeys, {
1325
+ sourcePath: file.path,
1326
+ sourceLine: null,
1327
+ code: "unsupported-structure",
1328
+ detail: "no recognized Codex session records"
1329
+ });
1330
+ }
1331
+ }
1332
+ return { turns: finishTurns(turns), issues, files };
1333
+ }
1334
+ };
1335
+
1336
+ // src/readers/pi.ts
1337
+ var KNOWN_METADATA2 = /* @__PURE__ */ new Set(["model_change", "thinking_level_change", "session_info", "label"]);
1338
+ var piReader = {
1339
+ harness: "pi",
1340
+ version: 1,
1341
+ async read(unit) {
1342
+ const turns = [];
1343
+ const turnKeys = /* @__PURE__ */ new Set();
1344
+ const issues = [];
1345
+ const issueKeys = /* @__PURE__ */ new Set();
1346
+ const files = [];
1347
+ for (const file of unit.files) {
1348
+ const parsed = await parseJsonl(file);
1349
+ for (const issue of parsed.issues) {
1350
+ addIssue(issues, issueKeys, issue, `${issue.sourcePath}\0${issue.sourceLine}\0${issue.code}`);
1351
+ }
1352
+ if (parsed.corrupt || parsed.validLines === 0 && parsed.invalidLines > 0) {
1353
+ files.push({ path: file.path, status: "corrupt" });
1354
+ continue;
1355
+ }
1356
+ let project = null;
1357
+ let recognizedHeader = false;
1358
+ let supportedVersion = false;
1359
+ let filePartial = parsed.invalidLines > 0;
1360
+ for (const record of parsed.records) {
1361
+ const type = stringField(record.value, "type");
1362
+ const recordTimestamp = timestamp(record.value.timestamp);
1363
+ project = stringField(record.value, "cwd") ?? project;
1364
+ if (type === "session") {
1365
+ recognizedHeader = true;
1366
+ const version2 = record.value.version;
1367
+ supportedVersion = version2 === 1 || version2 === 2 || version2 === 3;
1368
+ project = stringField(record.value, "cwd") ?? project;
1369
+ if (!supportedVersion) {
1370
+ filePartial = true;
1371
+ addIssue(issues, issueKeys, {
1372
+ sourcePath: file.path,
1373
+ sourceLine: record.line,
1374
+ code: "unsupported-version",
1375
+ detail: `unsupported pi session version ${String(version2)}`
1376
+ });
1377
+ }
1378
+ continue;
1379
+ }
1380
+ if (!supportedVersion) {
1381
+ continue;
1382
+ }
1383
+ if (type === "message") {
1384
+ const message = asObject(record.value.message);
1385
+ const role = stringField(message, "role");
1386
+ const details = asObject(record.value.details) ?? asObject(message?.details);
1387
+ const filesTouched = [...stringArray(details?.readFiles), ...stringArray(details?.modifiedFiles)];
1388
+ if (role === "user" || role === "assistant" || role === "toolResult") {
1389
+ for (const text of textContent(message?.content)) {
1390
+ pushUniqueTurn(turns, turnKeys, {
1391
+ timestamp: recordTimestamp,
1392
+ project,
1393
+ role: role === "toolResult" ? "tool" : role,
1394
+ text,
1395
+ filesTouched: [...new Set(filesTouched)],
1396
+ commands: [],
1397
+ sourcePath: file.path,
1398
+ sourceLine: record.line
1399
+ });
1400
+ }
1401
+ if (role === "assistant" && Array.isArray(message?.content)) {
1402
+ for (const rawBlock of message.content) {
1403
+ const block = asObject(rawBlock);
1404
+ if (block?.type !== "toolCall") {
1405
+ continue;
1406
+ }
1407
+ const name = stringField(block, "name");
1408
+ const input = asObject(block.arguments) ?? asObject(block.input);
1409
+ pushUniqueTurn(turns, turnKeys, {
1410
+ timestamp: recordTimestamp,
1411
+ project,
1412
+ role: "tool",
1413
+ text: name ?? "tool call",
1414
+ filesTouched: [.../* @__PURE__ */ new Set([...filesTouched, ...explicitPaths(input)])],
1415
+ commands: shellCommands(name, input),
1416
+ sourcePath: file.path,
1417
+ sourceLine: record.line
1418
+ });
1419
+ }
1420
+ }
1421
+ } else if (role === "bashExecution") {
1422
+ const command = stringField(message, "command");
1423
+ const output = stringField(message, "output");
1424
+ pushUniqueTurn(turns, turnKeys, {
1425
+ timestamp: recordTimestamp,
1426
+ project,
1427
+ role: "tool",
1428
+ text: output === null || output === "" ? command ?? "bash execution" : output,
1429
+ filesTouched: [...new Set(filesTouched)],
1430
+ commands: command === null ? [] : [command],
1431
+ sourcePath: file.path,
1432
+ sourceLine: record.line
1433
+ });
1434
+ } else {
1435
+ filePartial = true;
1436
+ addIssue(issues, issueKeys, {
1437
+ sourcePath: file.path,
1438
+ sourceLine: record.line,
1439
+ code: "unknown-content",
1440
+ detail: `skipped pi message role ${role ?? "<missing>"}`
1441
+ });
1442
+ }
1443
+ continue;
1444
+ }
1445
+ if (type === "compaction" || type === "branch_summary") {
1446
+ const text = stringField(record.value, "summary") ?? stringField(record.value, "content");
1447
+ const details = asObject(record.value.details);
1448
+ const filesTouched = [...stringArray(details?.readFiles), ...stringArray(details?.modifiedFiles)];
1449
+ if (text !== null) {
1450
+ pushUniqueTurn(turns, turnKeys, {
1451
+ timestamp: recordTimestamp,
1452
+ project,
1453
+ role: "summary",
1454
+ text,
1455
+ filesTouched: [...new Set(filesTouched)],
1456
+ commands: [],
1457
+ sourcePath: file.path,
1458
+ sourceLine: record.line
1459
+ });
1460
+ }
1461
+ continue;
1462
+ }
1463
+ if (type !== null && KNOWN_METADATA2.has(type)) {
1464
+ continue;
1465
+ }
1466
+ filePartial = true;
1467
+ addIssue(issues, issueKeys, {
1468
+ sourcePath: file.path,
1469
+ sourceLine: record.line,
1470
+ code: "unknown-record",
1471
+ detail: `skipped record type ${type ?? "<missing>"}`
1472
+ });
1473
+ }
1474
+ const status = !recognizedHeader || !supportedVersion ? "unsupported" : filePartial ? "partial" : "ok";
1475
+ files.push({ path: file.path, status });
1476
+ if (!recognizedHeader) {
1477
+ addIssue(issues, issueKeys, {
1478
+ sourcePath: file.path,
1479
+ sourceLine: null,
1480
+ code: "unsupported-structure",
1481
+ detail: "no recognizable pi session header"
1482
+ });
1483
+ }
1484
+ }
1485
+ return { turns: finishTurns(turns), issues, files };
1486
+ }
1487
+ };
1488
+
1489
+ // src/readers/registry.ts
1490
+ var readers = [claudeCodeReader, codexReader, piReader];
1491
+ var byHarness = new Map(readers.map((reader) => [reader.harness, reader]));
1492
+ function getReader(harness) {
1493
+ return byHarness.get(harness);
1494
+ }
1495
+
1496
+ // src/retrieval/catalog.ts
1497
+ import { createHash as createHash2 } from "crypto";
1498
+ import { createReadStream as createReadStream2 } from "fs";
1499
+ import { stat as stat2 } from "fs/promises";
1500
+ import { join as join3, relative as relative2, sep as sep2 } from "path";
1501
+ var snapshotHarnesses = new Set(
1502
+ adapters.filter((adapter) => adapter.mutationModel === "db-snapshot").map((adapter) => adapter.id)
1503
+ );
1504
+ var UUID_PATTERN = new RegExp(`^${UUID_SOURCE}$`, "i");
1505
+ var UUID_IN_FILENAME_PATTERN = new RegExp(`(${UUID_SOURCE})(?:\\.jsonl)?\\.zst$`, "i");
1506
+ function portable(path) {
1507
+ return sep2 === "/" ? path : path.split(sep2).join("/");
1508
+ }
1509
+ async function walkZstd(root, directory = root) {
1510
+ const paths = [];
1511
+ for (const entry of (await readDirectoryOrEmpty(directory)).sort((a, b) => a.name.localeCompare(b.name))) {
1512
+ const path = join3(directory, entry.name);
1513
+ if (entry.isDirectory()) {
1514
+ paths.push(...await walkZstd(root, path));
1515
+ } else if (entry.isFile() && entry.name.endsWith(".zst")) {
1516
+ paths.push(portable(relative2(root, path)));
1517
+ }
1518
+ }
1519
+ return paths;
1520
+ }
1521
+ function inferUnit(harness, harnessPath) {
1522
+ const parts = harnessPath.split("/");
1523
+ if (harness === "claude-code") {
1524
+ const directoryId = parts.find((part) => UUID_PATTERN.test(part));
1525
+ if (directoryId !== void 0) {
1526
+ return { id: directoryId, role: "sidecar" };
1527
+ }
1528
+ const match2 = UUID_IN_FILENAME_PATTERN.exec(parts.at(-1) ?? "");
1529
+ return match2?.[1] === void 0 ? null : { id: match2[1], role: "main" };
1530
+ }
1531
+ const match = UUID_IN_FILENAME_PATTERN.exec(parts.at(-1) ?? "");
1532
+ return match?.[1] === void 0 ? null : { id: match[1], role: "main" };
1533
+ }
1534
+ function indexedFile(archiveRoot, machine, record, storedSize, storedMtimeMs) {
1535
+ const machinePath = portable(record.path);
1536
+ return {
1537
+ path: `${machine}/${machinePath}`,
1538
+ archivePath: join3(archiveRoot, machine, ...machinePath.split("/")),
1539
+ machine,
1540
+ harness: record.harness,
1541
+ unit: record.unit,
1542
+ role: record.role,
1543
+ storedSize,
1544
+ storedMtimeMs,
1545
+ archiveSha256: record.sha256
1546
+ };
1547
+ }
1548
+ function rawFile(archiveRoot, machine, machinePath, storedSize, storedMtimeMs) {
1549
+ const [harnessValue, ...rest] = machinePath.split("/");
1550
+ if (!isHarnessId(harnessValue) || snapshotHarnesses.has(harnessValue) || rest.length === 0) {
1551
+ return null;
1552
+ }
1553
+ const inferred = inferUnit(harnessValue, rest.join("/"));
1554
+ if (inferred === null) {
1555
+ return null;
1556
+ }
1557
+ return {
1558
+ path: `${machine}/${machinePath}`,
1559
+ archivePath: join3(archiveRoot, machine, ...machinePath.split("/")),
1560
+ machine,
1561
+ harness: harnessValue,
1562
+ unit: inferred.id,
1563
+ role: inferred.role,
1564
+ storedSize,
1565
+ storedMtimeMs,
1566
+ archiveSha256: null
1567
+ };
1568
+ }
1569
+ async function sha256(path) {
1570
+ const hash = createHash2("sha256");
1571
+ for await (const chunk of createReadStream2(path)) {
1572
+ hash.update(chunk);
1573
+ }
1574
+ return hash.digest("hex");
1575
+ }
1576
+ async function readArchiveCatalog(config, options = {}) {
1577
+ const files = /* @__PURE__ */ new Map();
1578
+ for (const machineEntry of (await readDirectoryOrEmpty(config.archiveRoot)).sort(
1579
+ (a, b) => a.name.localeCompare(b.name)
1580
+ )) {
1581
+ if (!machineEntry.isDirectory()) {
1582
+ continue;
1583
+ }
1584
+ const machine = machineEntry.name;
1585
+ const machineRoot = join3(config.archiveRoot, machine);
1586
+ const index = await readIndex(join3(machineRoot, "index.jsonl"));
1587
+ const rawPaths = await walkZstd(machineRoot);
1588
+ const rawStats = /* @__PURE__ */ new Map();
1589
+ for (const machinePath of rawPaths) {
1590
+ const stored = await stat2(join3(machineRoot, ...machinePath.split("/")));
1591
+ rawStats.set(machinePath, { size: stored.size, mtimeMs: stored.mtimeMs });
1592
+ }
1593
+ for (const record of index.records.values()) {
1594
+ if (record.machine !== machine || record.role === "database" || snapshotHarnesses.has(record.harness)) {
1595
+ continue;
1596
+ }
1597
+ const machinePath = portable(record.path);
1598
+ const stored = rawStats.get(machinePath);
1599
+ if (stored !== void 0) {
1600
+ const file = indexedFile(config.archiveRoot, machine, record, stored.size, stored.mtimeMs);
1601
+ files.set(file.path, file);
1602
+ }
1603
+ }
1604
+ for (const machinePath of rawPaths) {
1605
+ const path = `${machine}/${machinePath}`;
1606
+ if (files.has(path)) {
1607
+ continue;
1608
+ }
1609
+ const stored = rawStats.get(machinePath);
1610
+ const file = rawFile(config.archiveRoot, machine, machinePath, stored.size, stored.mtimeMs);
1611
+ if (file !== null) {
1612
+ files.set(file.path, file);
1613
+ }
1614
+ }
1615
+ }
1616
+ const units = /* @__PURE__ */ new Map();
1617
+ for (const file of files.values()) {
1618
+ const key = `${file.machine}/${file.harness}/${file.unit}`;
1619
+ const current = units.get(key);
1620
+ if (current === void 0) {
1621
+ units.set(key, { key, machine: file.machine, harness: file.harness, id: file.unit, files: [file] });
1622
+ } else {
1623
+ current.files.push(file);
1624
+ }
1625
+ }
1626
+ for (const unit of units.values()) {
1627
+ unit.files.sort((a, b) => a.role === b.role ? a.path.localeCompare(b.path) : a.role === "main" ? -1 : 1);
1628
+ if (options.hashFiles) {
1629
+ for (const file of unit.files) {
1630
+ file.archiveSha256 = await sha256(file.archivePath);
1631
+ }
1632
+ }
1633
+ }
1634
+ return [...units.values()].sort((a, b) => a.key.localeCompare(b.key));
1635
+ }
1636
+
1637
+ // src/retrieval/database.ts
1638
+ var SCHEMA_VERSION = 1;
1639
+ function newDatabase(path) {
1640
+ const sqlite = createRequire(import.meta.url)("node:sqlite");
1641
+ return new sqlite.DatabaseSync(path);
1642
+ }
1643
+ var SCHEMA = `
1644
+ PRAGMA user_version = 1;
1645
+ PRAGMA foreign_keys = ON;
1646
+
1647
+ CREATE TABLE archive_files (
1648
+ path TEXT PRIMARY KEY,
1649
+ machine TEXT NOT NULL,
1650
+ harness TEXT NOT NULL,
1651
+ unit TEXT NOT NULL,
1652
+ role TEXT NOT NULL CHECK (role IN ('main', 'sidecar')),
1653
+ stored_size INTEGER NOT NULL,
1654
+ stored_mtime_ms REAL NOT NULL,
1655
+ archive_sha256 TEXT,
1656
+ reader_version INTEGER NOT NULL,
1657
+ parse_status TEXT NOT NULL CHECK (parse_status IN ('ok', 'partial', 'unsupported', 'corrupt')),
1658
+ indexed_at TEXT NOT NULL
1659
+ ) STRICT;
1660
+
1661
+ CREATE TABLE units (
1662
+ key TEXT PRIMARY KEY,
1663
+ machine TEXT NOT NULL,
1664
+ harness TEXT NOT NULL,
1665
+ id TEXT NOT NULL,
1666
+ started_at TEXT,
1667
+ updated_at TEXT,
1668
+ UNIQUE (machine, harness, id)
1669
+ ) STRICT;
1670
+
1671
+ CREATE TABLE turns (
1672
+ id INTEGER PRIMARY KEY,
1673
+ unit TEXT NOT NULL REFERENCES units(key) ON DELETE CASCADE,
1674
+ turn INTEGER NOT NULL,
1675
+ source_path TEXT NOT NULL REFERENCES archive_files(path) ON DELETE CASCADE,
1676
+ source_line INTEGER NOT NULL,
1677
+ timestamp TEXT,
1678
+ project TEXT,
1679
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'tool', 'summary')),
1680
+ text TEXT NOT NULL,
1681
+ files_touched TEXT NOT NULL,
1682
+ commands TEXT NOT NULL,
1683
+ UNIQUE (unit, turn)
1684
+ ) STRICT;
1685
+
1686
+ CREATE TABLE parse_issues (
1687
+ id INTEGER PRIMARY KEY,
1688
+ source_path TEXT NOT NULL REFERENCES archive_files(path) ON DELETE CASCADE,
1689
+ source_line INTEGER,
1690
+ code TEXT NOT NULL,
1691
+ detail TEXT NOT NULL
1692
+ ) STRICT;
1693
+
1694
+ CREATE INDEX turns_filter
1695
+ ON turns (project, timestamp, unit, turn);
1696
+
1697
+ CREATE INDEX units_filter
1698
+ ON units (harness, machine, id);
1699
+
1700
+ CREATE VIRTUAL TABLE turns_fts USING fts5(
1701
+ unit UNINDEXED,
1702
+ turn UNINDEXED,
1703
+ role,
1704
+ text,
1705
+ files_touched,
1706
+ commands,
1707
+ content = 'turns',
1708
+ content_rowid = 'id',
1709
+ tokenize = 'unicode61 remove_diacritics 2'
1710
+ );
1711
+
1712
+ CREATE TRIGGER turns_ai AFTER INSERT ON turns BEGIN
1713
+ INSERT INTO turns_fts(rowid, unit, turn, role, text, files_touched, commands)
1714
+ VALUES (new.id, new.unit, new.turn, new.role, new.text, new.files_touched, new.commands);
1715
+ END;
1716
+
1717
+ CREATE TRIGGER turns_ad AFTER DELETE ON turns BEGIN
1718
+ INSERT INTO turns_fts(turns_fts, rowid, unit, turn, role, text, files_touched, commands)
1719
+ VALUES ('delete', old.id, old.unit, old.turn, old.role, old.text, old.files_touched, old.commands);
1720
+ END;
1721
+
1722
+ CREATE TRIGGER turns_au AFTER UPDATE ON turns BEGIN
1723
+ INSERT INTO turns_fts(turns_fts, rowid, unit, turn, role, text, files_touched, commands)
1724
+ VALUES ('delete', old.id, old.unit, old.turn, old.role, old.text, old.files_touched, old.commands);
1725
+ INSERT INTO turns_fts(rowid, unit, turn, role, text, files_touched, commands)
1726
+ VALUES (new.id, new.unit, new.turn, new.role, new.text, new.files_touched, new.commands);
1727
+ END;
1728
+ `;
1729
+ function retrievalDatabasePath(home) {
1730
+ return join4(home.cachePath, "retrieval.sqlite");
1731
+ }
1732
+ function assertFts5() {
1733
+ const database = newDatabase(":memory:");
1734
+ try {
1735
+ const options = database.prepare("PRAGMA compile_options").all();
1736
+ if (!options.some((row) => Object.values(row).includes("ENABLE_FTS5"))) {
1737
+ throw new PackbatError(
1738
+ "retrieval requires SQLite FTS5; use the official Node >=22.16 build or a Node build compiled with ENABLE_FTS5"
1739
+ );
1740
+ }
1741
+ } finally {
1742
+ database.close();
1743
+ }
1744
+ }
1745
+ async function prepareDirectory(path) {
1746
+ await mkdir3(dirname3(path), { recursive: true, mode: 448 });
1747
+ await chmod(dirname3(path), 448);
1748
+ }
1749
+ function initialize(path) {
1750
+ const database = newDatabase(path);
1751
+ database.exec(SCHEMA);
1752
+ return database;
1753
+ }
1754
+ async function createFreshAt(path) {
1755
+ await prepareDirectory(path);
1756
+ const database = initialize(path);
1757
+ await chmod(path, 384);
1758
+ return database;
1759
+ }
1760
+ async function openCurrent(path) {
1761
+ try {
1762
+ await stat3(path);
1763
+ } catch (error) {
1764
+ if (isEnoent(error)) {
1765
+ return null;
1766
+ }
1767
+ throw error;
1768
+ }
1769
+ const database = newDatabase(path);
1770
+ try {
1771
+ const version2 = database.prepare("PRAGMA user_version").get().user_version;
1772
+ if (version2 !== SCHEMA_VERSION) {
1773
+ database.close();
1774
+ return null;
1775
+ }
1776
+ database.exec("PRAGMA foreign_keys = ON");
1777
+ return database;
1778
+ } catch {
1779
+ database.close();
1780
+ return null;
1781
+ }
1782
+ }
1783
+ function timestampBounds(turns) {
1784
+ const values = turns.flatMap((turn) => turn.timestamp === null ? [] : [turn.timestamp]).sort();
1785
+ return { startedAt: values[0] ?? null, updatedAt: values.at(-1) ?? null };
1786
+ }
1787
+ async function replaceUnitAsync(database, unit) {
1788
+ const reader = getReader(unit.harness);
1789
+ const result = await reader.read(unit);
1790
+ const statuses = new Map(result.files.map((file) => [file.path, file.status]));
1791
+ const bounds = timestampBounds(result.turns);
1792
+ const indexedAt = (/* @__PURE__ */ new Date()).toISOString();
1793
+ database.exec("BEGIN IMMEDIATE");
1794
+ try {
1795
+ database.prepare("DELETE FROM units WHERE key = ?").run(unit.key);
1796
+ database.prepare("DELETE FROM archive_files WHERE machine = ? AND harness = ? AND unit = ?").run(unit.machine, unit.harness, unit.id);
1797
+ const insertFile = database.prepare(`
1798
+ INSERT INTO archive_files (
1799
+ path, machine, harness, unit, role, stored_size, stored_mtime_ms, archive_sha256,
1800
+ reader_version, parse_status, indexed_at
1801
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1802
+ `);
1803
+ for (const file of unit.files) {
1804
+ insertFile.run(
1805
+ file.path,
1806
+ file.machine,
1807
+ file.harness,
1808
+ file.unit,
1809
+ file.role,
1810
+ file.storedSize,
1811
+ file.storedMtimeMs,
1812
+ file.archiveSha256,
1813
+ reader.version,
1814
+ statuses.get(file.path) ?? "corrupt",
1815
+ indexedAt
1816
+ );
1817
+ }
1818
+ database.prepare("INSERT INTO units (key, machine, harness, id, started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run(unit.key, unit.machine, unit.harness, unit.id, bounds.startedAt, bounds.updatedAt);
1819
+ const insertTurn = database.prepare(`
1820
+ INSERT INTO turns (
1821
+ unit, turn, source_path, source_line, timestamp, project, role, text, files_touched, commands
1822
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1823
+ `);
1824
+ for (const turn of result.turns) {
1825
+ insertTurn.run(
1826
+ unit.key,
1827
+ turn.turn,
1828
+ turn.sourcePath,
1829
+ turn.sourceLine,
1830
+ turn.timestamp,
1831
+ turn.project,
1832
+ turn.role,
1833
+ turn.text,
1834
+ JSON.stringify(turn.filesTouched),
1835
+ JSON.stringify(turn.commands)
1836
+ );
1837
+ }
1838
+ const insertIssue = database.prepare(
1839
+ "INSERT INTO parse_issues (source_path, source_line, code, detail) VALUES (?, ?, ?, ?)"
1840
+ );
1841
+ for (const issue of result.issues) {
1842
+ if (statuses.has(issue.sourcePath)) {
1843
+ insertIssue.run(issue.sourcePath, issue.sourceLine, issue.code, issue.detail);
1844
+ }
1845
+ }
1846
+ database.exec("COMMIT");
1847
+ } catch (error) {
1848
+ database.exec("ROLLBACK");
1849
+ throw error;
1850
+ }
1851
+ }
1852
+ function cachedFiles(database) {
1853
+ return database.prepare(
1854
+ "SELECT path, machine, harness, unit, role, stored_size, stored_mtime_ms, archive_sha256, reader_version FROM archive_files"
1855
+ ).all();
1856
+ }
1857
+ function isChanged(file, cached) {
1858
+ return cached === void 0 || cached.machine !== file.machine || cached.harness !== file.harness || cached.unit !== file.unit || cached.role !== file.role || cached.stored_size !== file.storedSize || cached.stored_mtime_ms !== file.storedMtimeMs || cached.archive_sha256 !== file.archiveSha256 || cached.reader_version !== getReader(file.harness).version;
1859
+ }
1860
+ async function refresh(database, config, hashFiles = false) {
1861
+ const units = await readArchiveCatalog(config, { hashFiles });
1862
+ const byKey = new Map(units.map((unit) => [unit.key, unit]));
1863
+ const currentFiles = new Map(units.flatMap((unit) => unit.files.map((file) => [file.path, file])));
1864
+ const cached = cachedFiles(database);
1865
+ const cachedByPath = new Map(cached.map((file) => [file.path, file]));
1866
+ const changedKeys = /* @__PURE__ */ new Set();
1867
+ for (const unit of units) {
1868
+ if (unit.files.some((file) => isChanged(file, cachedByPath.get(file.path)))) {
1869
+ changedKeys.add(unit.key);
1870
+ }
1871
+ }
1872
+ for (const old of cached) {
1873
+ if (!currentFiles.has(old.path)) {
1874
+ changedKeys.add(`${old.machine}/${old.harness}/${old.unit}`);
1875
+ }
1876
+ }
1877
+ for (const key of [...changedKeys].sort()) {
1878
+ const unit = byKey.get(key);
1879
+ if (unit !== void 0) {
1880
+ await replaceUnitAsync(database, unit);
1881
+ } else {
1882
+ database.exec("BEGIN IMMEDIATE");
1883
+ try {
1884
+ database.prepare("DELETE FROM units WHERE key = ?").run(key);
1885
+ const [machine, harness, ...idParts] = key.split("/");
1886
+ database.prepare("DELETE FROM archive_files WHERE machine = ? AND harness = ? AND unit = ?").run(machine, harness, idParts.join("/"));
1887
+ database.exec("COMMIT");
1888
+ } catch (error) {
1889
+ database.exec("ROLLBACK");
1890
+ throw error;
1891
+ }
1892
+ }
1893
+ }
1894
+ }
1895
+ function warnings(database) {
1896
+ const rows = database.prepare(`
1897
+ SELECT p.code, u.key AS unit, p.source_path AS source, p.source_line AS line, p.detail,
1898
+ f.harness, f.reader_version AS readerVersion
1899
+ FROM parse_issues p
1900
+ JOIN archive_files f ON f.path = p.source_path
1901
+ JOIN units u ON u.machine = f.machine AND u.harness = f.harness AND u.id = f.unit
1902
+ ORDER BY u.key, p.source_path, p.source_line, p.id
1903
+ `).all();
1904
+ const unknowns = /* @__PURE__ */ new Set();
1905
+ return rows.flatMap((row) => {
1906
+ if (row.code === "unknown-record") {
1907
+ const key = `${row.harness}\0${row.readerVersion}\0${row.detail}`;
1908
+ if (unknowns.has(key)) return [];
1909
+ unknowns.add(key);
1910
+ }
1911
+ return [{ code: row.code, unit: row.unit, source: row.source, line: row.line, detail: row.detail }];
1912
+ });
1913
+ }
1914
+ async function buildFresh(path, config) {
1915
+ const database = await createFreshAt(path);
1916
+ try {
1917
+ await refresh(database, config, true);
1918
+ return database;
1919
+ } catch (error) {
1920
+ database.close();
1921
+ throw error;
1922
+ }
1923
+ }
1924
+ async function openAndRefresh(home, config) {
1925
+ const path = retrievalDatabasePath(home);
1926
+ await prepareDirectory(path);
1927
+ let database = await openCurrent(path);
1928
+ if (database === null) {
1929
+ const report = await rebuildRetrieval(home, config);
1930
+ void report;
1931
+ database = newDatabase(path);
1932
+ database.exec("PRAGMA foreign_keys = ON");
1933
+ return database;
1934
+ }
1935
+ await refresh(database, config);
1936
+ return database;
1937
+ }
1938
+ async function rebuildRetrieval(home, config) {
1939
+ const started = performance.now();
1940
+ const target = retrievalDatabasePath(home);
1941
+ await prepareDirectory(target);
1942
+ const temporary = join4(dirname3(target), `.${basename2(target)}.tmp-${process.pid}-${randomUUID2()}`);
1943
+ let database = null;
1944
+ try {
1945
+ database = await buildFresh(temporary, config);
1946
+ const counts = database.prepare(
1947
+ "SELECT (SELECT count(*) FROM archive_files) AS files, (SELECT count(*) FROM units) AS units, (SELECT count(*) FROM turns) AS turns"
1948
+ ).get();
1949
+ const foundWarnings = warnings(database);
1950
+ database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1951
+ database.close();
1952
+ database = null;
1953
+ await chmod(temporary, 384);
1954
+ await rename2(temporary, target);
1955
+ const bytes = (await stat3(target)).size;
1956
+ return {
1957
+ rebuilt: true,
1958
+ files: counts.files,
1959
+ units: counts.units,
1960
+ turns: counts.turns,
1961
+ bytes,
1962
+ elapsedMs: Math.round(performance.now() - started),
1963
+ warnings: foundWarnings
1964
+ };
1965
+ } catch (error) {
1966
+ database?.close();
1967
+ await rm3(temporary, { force: true });
1968
+ throw error;
1969
+ }
1970
+ }
1971
+ function queryTerms(query) {
1972
+ return [...query.matchAll(/[\p{L}\p{N}_-]+/gu)].map((match) => match[0]).filter((term) => !["AND", "OR", "NOT", "NEAR", "role", "text", "files_touched", "commands"].includes(term));
1973
+ }
1974
+ function snippet(text, query) {
1975
+ const points = Array.from(text);
1976
+ if (points.length <= 320) {
1977
+ return text;
1978
+ }
1979
+ const lower = text.toLocaleLowerCase();
1980
+ const match = queryTerms(query).map((term) => lower.indexOf(term.toLocaleLowerCase())).find((index) => index >= 0);
1981
+ if (match === void 0) {
1982
+ return `${points.slice(0, 319).join("")}\u2026`;
1983
+ }
1984
+ const pointIndex = Array.from(text.slice(0, match)).length;
1985
+ let start = Math.max(0, pointIndex - 159);
1986
+ let end = Math.min(points.length, start + 320);
1987
+ start = Math.max(0, end - 320);
1988
+ const leading = start > 0;
1989
+ const trailing = end < points.length;
1990
+ const budget = 320 - (leading ? 1 : 0) - (trailing ? 1 : 0);
1991
+ end = Math.min(points.length, start + budget);
1992
+ return `${leading ? "\u2026" : ""}${points.slice(start, end).join("")}${end < points.length ? "\u2026" : ""}`;
1993
+ }
1994
+ function searchDatabase(database, query, filters) {
1995
+ const conditions = ["turns_fts MATCH ?"];
1996
+ const parameters = [query];
1997
+ if (filters.harness !== null) {
1998
+ conditions.push("u.harness = ?");
1999
+ parameters.push(filters.harness);
2000
+ }
2001
+ if (filters.machine !== null) {
2002
+ conditions.push("u.machine = ?");
2003
+ parameters.push(filters.machine);
2004
+ }
2005
+ if (filters.project !== null) {
2006
+ conditions.push("t.project = ?");
2007
+ parameters.push(filters.project);
2008
+ }
2009
+ if (filters.since !== null) {
2010
+ conditions.push("t.timestamp IS NOT NULL AND t.timestamp >= ?");
2011
+ parameters.push(filters.since);
2012
+ }
2013
+ let rows;
2014
+ try {
2015
+ rows = database.prepare(`
2016
+ SELECT u.key, u.id AS unit, u.harness, u.machine, t.project, t.turn, t.timestamp,
2017
+ t.role, t.text, t.files_touched, t.commands
2018
+ FROM turns_fts
2019
+ JOIN turns t ON t.id = turns_fts.rowid
2020
+ JOIN units u ON u.key = t.unit
2021
+ WHERE ${conditions.join(" AND ")}
2022
+ ORDER BY bm25(turns_fts), t.timestamp DESC, u.key ASC, t.turn ASC
2023
+ LIMIT 51
2024
+ `).all(...parameters);
2025
+ } catch (error) {
2026
+ throw new PackbatError(`invalid search query: ${error instanceof Error ? error.message : String(error)}`);
2027
+ }
2028
+ const truncated = rows.length > 50;
2029
+ return {
2030
+ results: rows.slice(0, 50).map((row) => ({
2031
+ key: row.key,
2032
+ unit: row.unit,
2033
+ harness: row.harness,
2034
+ machine: row.machine,
2035
+ project: row.project,
2036
+ turn: row.turn,
2037
+ timestamp: row.timestamp,
2038
+ role: row.role,
2039
+ snippet: snippet(row.text, query),
2040
+ filesTouched: JSON.parse(row.files_touched),
2041
+ commands: JSON.parse(row.commands)
2042
+ })),
2043
+ truncated,
2044
+ warnings: warnings(database)
2045
+ };
2046
+ }
2047
+ function closeDatabase(database) {
2048
+ database.close();
2049
+ }
2050
+
2051
+ // src/commands/search.ts
2052
+ var USAGE3 = `Usage: packbat search <query> [--harness <id>] [--machine <name>] [--project <path>] [--since <RFC3339>] [--json]
2053
+ packbat search --rebuild [--json]
2054
+ `;
2055
+ function usageError3(message) {
2056
+ process.stderr.write(`packbat search: ${message}
2057
+
2058
+ ${USAGE3}`);
2059
+ return null;
2060
+ }
2061
+ function optionValue(argv, index, option) {
2062
+ const value = argv[index + 1];
2063
+ if (value === void 0 || value.startsWith("--")) {
2064
+ usageError3(`${option} requires a value`);
2065
+ return null;
2066
+ }
2067
+ return value;
2068
+ }
2069
+ function parseSince(value) {
2070
+ const date = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
2071
+ if (date === null) return null;
2072
+ const year = Number(date[1]);
2073
+ const month = Number(date[2]);
2074
+ const day = Number(date[3]);
2075
+ if (month < 1 || month > 12 || day < 1 || day > new Date(Date.UTC(year, month, 0)).getUTCDate()) return null;
2076
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
2077
+ const parsed2 = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
2078
+ return Number.isNaN(parsed2.getTime()) ? null : parsed2.toISOString();
2079
+ }
2080
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) {
2081
+ return null;
2082
+ }
2083
+ const parsed = new Date(value);
2084
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
2085
+ }
2086
+ function parseOptions3(argv) {
2087
+ const options = {
2088
+ query: null,
2089
+ harness: null,
2090
+ machine: null,
2091
+ project: null,
2092
+ since: null,
2093
+ json: false,
2094
+ rebuild: false
2095
+ };
2096
+ for (let index = 0; index < argv.length; index += 1) {
2097
+ const argument = argv[index];
2098
+ if (argument === "--json") {
2099
+ if (options.json) return usageError3("--json may only be passed once");
2100
+ options.json = true;
2101
+ } else if (argument === "--rebuild") {
2102
+ if (options.rebuild) return usageError3("--rebuild may only be passed once");
2103
+ options.rebuild = true;
2104
+ } else if (argument === "--harness") {
2105
+ if (options.harness !== null) return usageError3("--harness may only be passed once");
2106
+ const value = optionValue(argv, index, argument);
2107
+ if (value === null) return null;
2108
+ if (!isHarnessId(value)) return usageError3(`--harness must be one of ${HARNESS_IDS.join(", ")}`);
2109
+ options.harness = value;
2110
+ index += 1;
2111
+ } else if (argument === "--machine") {
2112
+ if (options.machine !== null) return usageError3("--machine may only be passed once");
2113
+ const value = optionValue(argv, index, argument);
2114
+ if (value === null) return null;
2115
+ options.machine = value;
2116
+ index += 1;
2117
+ } else if (argument === "--project") {
2118
+ if (options.project !== null) return usageError3("--project may only be passed once");
2119
+ const value = optionValue(argv, index, argument);
2120
+ if (value === null) return null;
2121
+ options.project = resolve2(value);
2122
+ index += 1;
2123
+ } else if (argument === "--since") {
2124
+ if (options.since !== null) return usageError3("--since may only be passed once");
2125
+ const value = optionValue(argv, index, argument);
2126
+ if (value === null) return null;
2127
+ const since = parseSince(value);
2128
+ if (since === null) return usageError3("--since must be RFC3339 or YYYY-MM-DD");
2129
+ options.since = since;
2130
+ index += 1;
2131
+ } else if (argument.startsWith("-")) {
2132
+ return usageError3(`unknown option ${argument}`);
2133
+ } else if (options.query !== null) {
2134
+ return usageError3("only one query may be passed");
2135
+ } else {
2136
+ options.query = argument;
2137
+ }
2138
+ }
2139
+ if (options.rebuild) {
2140
+ if (options.query !== null || options.harness !== null || options.machine !== null || options.project !== null || options.since !== null) {
2141
+ return usageError3("--rebuild only accepts --json");
2142
+ }
2143
+ } else if (options.query === null) {
2144
+ return usageError3("a query is required");
2145
+ }
2146
+ return options;
2147
+ }
2148
+ function printHit(hit) {
2149
+ process.stdout.write(`${hit.key} \xB7 turn ${hit.turn} \xB7 ${hit.role}`);
2150
+ if (hit.timestamp !== null) process.stdout.write(` \xB7 ${hit.timestamp}`);
2151
+ process.stdout.write("\n");
2152
+ if (hit.project !== null) process.stdout.write(`${hit.project}
2153
+ `);
2154
+ process.stdout.write(`${hit.snippet}
2155
+ `);
2156
+ if (hit.filesTouched.length > 0) process.stdout.write(`files: ${hit.filesTouched.join(", ")}
2157
+ `);
2158
+ if (hit.commands.length > 0) process.stdout.write(`commands: ${hit.commands.join(" | ")}
2159
+ `);
2160
+ process.stdout.write("\n");
2161
+ }
2162
+ async function runSearch(argv) {
2163
+ const options = parseOptions3(argv);
2164
+ if (options === null) return 1;
2165
+ assertFts5();
2166
+ const home = resolveHome();
2167
+ const config = loadConfig(home);
2168
+ const locked = await withRetrievalLock(home.statePath, async () => {
2169
+ if (options.rebuild) {
2170
+ const report = await rebuildRetrieval(home, config);
2171
+ if (options.json) {
2172
+ process.stdout.write(`${JSON.stringify({ v: 1, ...report })}
2173
+ `);
2174
+ } else {
2175
+ process.stdout.write(
2176
+ `rebuilt ${report.files} files, ${report.units} units, ${report.turns} turns \xB7 ${report.bytes} bytes \xB7 ${report.elapsedMs} ms
2177
+ `
2178
+ );
2179
+ }
2180
+ return 0;
2181
+ }
2182
+ const database = await openAndRefresh(home, config);
2183
+ try {
2184
+ const filters = {
2185
+ harness: options.harness,
2186
+ machine: options.machine,
2187
+ project: options.project,
2188
+ since: options.since
2189
+ };
2190
+ const result = searchDatabase(database, options.query, filters);
2191
+ if (options.json) {
2192
+ process.stdout.write(`${JSON.stringify({ v: 1, query: options.query, filters, ...result })}
2193
+ `);
2194
+ } else {
2195
+ for (const hit of result.results) printHit(hit);
2196
+ }
2197
+ return 0;
2198
+ } finally {
2199
+ closeDatabase(database);
2200
+ }
2201
+ });
2202
+ if (!locked.acquired) {
2203
+ process.stderr.write("packbat search: retrieval is already running\n");
2204
+ return 1;
2205
+ }
2206
+ return locked.value;
2207
+ }
2208
+
2209
+ // src/retrieval/show.ts
2210
+ function resolveShowUnit(units, value) {
2211
+ const exact = units.find((unit) => unit.key === value);
2212
+ if (exact !== void 0) {
2213
+ return exact;
2214
+ }
2215
+ const matches = units.filter((unit) => unit.id.startsWith(value));
2216
+ if (matches.length === 0) {
2217
+ throw new PackbatError(`no archived unit matches "${value}"`);
2218
+ }
2219
+ if (matches.length > 1) {
2220
+ throw new PackbatError(
2221
+ `archive unit "${value}" is ambiguous:
2222
+ ${matches.map((unit) => ` ${unit.key}`).join("\n")}`
2223
+ );
2224
+ }
2225
+ return matches[0];
2226
+ }
2227
+ function assertServeable(unit, result) {
2228
+ const searchablePaths = new Set(
2229
+ unit.files.filter((file) => file.path.endsWith(".jsonl.zst")).map((file) => file.path)
2230
+ );
2231
+ const failed = result.files.filter(
2232
+ (file) => searchablePaths.has(file.path) && (file.status === "unsupported" || file.status === "corrupt")
2233
+ );
2234
+ if (failed.length !== searchablePaths.size) {
2235
+ return;
2236
+ }
2237
+ const lines = failed.map((file) => {
2238
+ const codes = result.issues.filter((issue) => issue.sourcePath === file.path).map((issue) => issue.code).filter((code, index, all) => all.indexOf(code) === index);
2239
+ return ` ${file.path}: ${codes.join(", ") || file.status}`;
2240
+ });
2241
+ throw new PackbatError(`archived unit cannot be read:
2242
+ ${lines.join("\n")}`);
2243
+ }
2244
+ async function readShowUnit(unit) {
2245
+ const result = await getReader(unit.harness).read(unit);
2246
+ assertServeable(unit, result);
2247
+ const projects = [...new Set(result.turns.flatMap((turn) => turn.project === null ? [] : [turn.project]))].sort();
2248
+ const timestamps = result.turns.flatMap((turn) => turn.timestamp === null ? [] : [turn.timestamp]).sort((left, right) => left.localeCompare(right));
2249
+ return {
2250
+ v: 1,
2251
+ unit: {
2252
+ key: unit.key,
2253
+ id: unit.id,
2254
+ harness: unit.harness,
2255
+ machine: unit.machine,
2256
+ projects,
2257
+ startedAt: timestamps[0] ?? null,
2258
+ updatedAt: timestamps.at(-1) ?? null
2259
+ },
2260
+ turns: result.turns.map((turn) => ({
2261
+ turn: turn.turn,
2262
+ timestamp: turn.timestamp,
2263
+ project: turn.project,
2264
+ role: turn.role,
2265
+ text: turn.text,
2266
+ filesTouched: turn.filesTouched,
2267
+ commands: turn.commands
2268
+ })),
2269
+ warnings: result.issues.map((issue) => ({
2270
+ code: issue.code,
2271
+ unit: unit.key,
2272
+ source: issue.sourcePath,
2273
+ line: issue.sourceLine,
2274
+ detail: issue.detail
2275
+ }))
2276
+ };
2277
+ }
2278
+
2279
+ // src/commands/show.ts
2280
+ var USAGE4 = "Usage: packbat show <unit-or-key> [--json]\n";
2281
+ function parseOptions4(argv) {
2282
+ let value = null;
2283
+ let json = false;
2284
+ for (const argument of argv) {
2285
+ if (argument === "--json") {
2286
+ if (json) {
2287
+ process.stderr.write(`packbat show: --json may only be passed once
2288
+
2289
+ ${USAGE4}`);
2290
+ return null;
2291
+ }
2292
+ json = true;
2293
+ } else if (argument.startsWith("-")) {
2294
+ process.stderr.write(`packbat show: unknown option ${argument}
2295
+
2296
+ ${USAGE4}`);
2297
+ return null;
2298
+ } else if (value !== null) {
2299
+ process.stderr.write(`packbat show: only one unit or key may be passed
2300
+
2301
+ ${USAGE4}`);
2302
+ return null;
2303
+ } else {
2304
+ value = argument;
2305
+ }
2306
+ }
2307
+ if (value === null) {
2308
+ process.stderr.write(`packbat show: a unit or key is required
2309
+
2310
+ ${USAGE4}`);
2311
+ return null;
2312
+ }
2313
+ return { value, json };
2314
+ }
2315
+ function printShow(result) {
2316
+ process.stdout.write(`${result.unit.key}
2317
+ `);
2318
+ process.stdout.write(`${result.unit.harness} \xB7 ${result.unit.machine}
2319
+ `);
2320
+ if (result.unit.projects.length > 0) process.stdout.write(`projects: ${result.unit.projects.join(", ")}
2321
+ `);
2322
+ for (const turn of result.turns) {
2323
+ process.stdout.write(`
2324
+ ${turn.turn} \xB7 ${turn.role}`);
2325
+ if (turn.timestamp !== null) process.stdout.write(` \xB7 ${turn.timestamp}`);
2326
+ if (turn.project !== null) process.stdout.write(` \xB7 ${turn.project}`);
2327
+ process.stdout.write(`
2328
+ ${turn.text}
2329
+ `);
2330
+ if (turn.filesTouched.length > 0) process.stdout.write(`files: ${turn.filesTouched.join(", ")}
2331
+ `);
2332
+ if (turn.commands.length > 0) process.stdout.write(`commands: ${turn.commands.join(" | ")}
2333
+ `);
2334
+ }
2335
+ }
2336
+ async function runShow(argv) {
2337
+ const options = parseOptions4(argv);
2338
+ if (options === null) return 1;
2339
+ assertFts5();
2340
+ const home = resolveHome();
2341
+ const config = loadConfig(home);
2342
+ const unit = resolveShowUnit(await readArchiveCatalog(config), options.value);
2343
+ const result = await readShowUnit(unit);
2344
+ if (options.json) process.stdout.write(`${JSON.stringify(result)}
2345
+ `);
2346
+ else printShow(result);
2347
+ return 0;
2348
+ }
2349
+
2350
+ // src/commands/status.ts
2351
+ var USAGE5 = "Usage: packbat status [--json]\n";
2352
+ function parseOptions5(argv) {
2353
+ if (argv.length === 0) {
2354
+ return { json: false };
2355
+ }
2356
+ if (argv.length === 1 && argv[0] === "--json") {
2357
+ return { json: true };
2358
+ }
2359
+ process.stderr.write(`packbat status: only --json is accepted
2360
+
2361
+ ${USAGE5}`);
2362
+ return null;
2363
+ }
2364
+ function stampJson(stamp, now) {
2365
+ if (stamp.kind !== "value") {
2366
+ return null;
2367
+ }
2368
+ return {
2369
+ finishedAt: stamp.value.finishedAt,
2370
+ ageMs: ageMs(stamp.finishedAtMs, now),
2371
+ ok: stamp.value.ok,
2372
+ archived: stamp.value.archived,
2373
+ unchanged: stamp.value.unchanged,
2374
+ failed: stamp.value.failed
2375
+ };
2376
+ }
2377
+ function lastRunLine(stamp, now) {
2378
+ if (stamp.kind === "missing") {
2379
+ return "never";
2380
+ }
2381
+ if (stamp.kind === "invalid") {
2382
+ return stamp.detail;
2383
+ }
2384
+ return `${formatAge(ageMs(stamp.finishedAtMs, now))} \xB7 ${stamp.value.ok ? "ok" : "failed"} \xB7 archived ${stamp.value.archived}, unchanged ${stamp.value.unchanged}, failed ${stamp.value.failed}`;
2385
+ }
2386
+ function lastSuccessLine(stamp, now) {
2387
+ if (stamp.kind === "missing") {
2388
+ return "never";
2389
+ }
2390
+ if (stamp.kind === "invalid") {
2391
+ return stamp.detail;
2392
+ }
2393
+ return formatAge(ageMs(stamp.finishedAtMs, now));
2394
+ }
2395
+ function formatBytes(bytes) {
2396
+ if (bytes < 1024) {
2397
+ return `${bytes} B`;
2398
+ }
2399
+ if (bytes < 1024 * 1024) {
2400
+ return `${(bytes / 1024).toFixed(1)} KiB`;
2401
+ }
2402
+ return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
2403
+ }
2404
+ async function runStatus(argv) {
2405
+ const options = parseOptions5(argv);
2406
+ if (options === null) {
2407
+ return 1;
2408
+ }
2409
+ const home = resolveHome();
2410
+ const config = loadConfig(home);
2411
+ const context = createDoctorContext(config, home);
2412
+ const installed = await checkInstalled(context);
2413
+ const [live, fresh, harnesses, offbox] = await Promise.all([
2414
+ checkLive(context, installed, { probe: false }),
2415
+ checkFresh(context),
2416
+ readHarnessTallies(context),
2417
+ checkOffbox(context)
2418
+ ]);
2419
+ const report = {
2420
+ v: 2,
2421
+ machine: config.machine,
2422
+ archiveRoot: config.archiveRoot,
2423
+ schedule: {
2424
+ installed: installed.fact.status === "ok",
2425
+ installedDetail: installed.fact.detail,
2426
+ live: "not-checked",
2427
+ liveDetail: live.detail
2428
+ },
2429
+ lastRun: stampJson(fresh.lastRun, context.now),
2430
+ lastSuccess: stampJson(fresh.lastSuccess, context.now),
2431
+ fresh: { status: fresh.fact.status, detail: fresh.fact.detail },
2432
+ harnesses,
2433
+ offbox: offbox.map((item) => ({ status: item.status, detail: item.detail }))
2434
+ };
2435
+ if (options.json) {
2436
+ process.stdout.write(`${JSON.stringify(report)}
2437
+ `);
2438
+ return 0;
2439
+ }
2440
+ process.stdout.write(`machine: ${config.machine}
2441
+ `);
2442
+ process.stdout.write(`archive: ${config.archiveRoot}
2443
+ `);
2444
+ process.stdout.write(`schedule: ${installed.fact.detail} \xB7 ${live.detail}
2445
+ `);
2446
+ process.stdout.write(`last run: ${lastRunLine(fresh.lastRun, context.now)}
2447
+ `);
2448
+ process.stdout.write(`last success: ${lastSuccessLine(fresh.lastSuccess, context.now)}
2449
+ `);
2450
+ for (const tally of harnesses) {
2451
+ process.stdout.write(
2452
+ `${tally.harness}: ${tally.units} unit${tally.units === 1 ? "" : "s"} \xB7 ${tally.files} file${tally.files === 1 ? "" : "s"} \xB7 ${formatBytes(tally.storedBytes)}
2453
+ `
2454
+ );
2455
+ }
2456
+ for (const item of offbox) {
2457
+ process.stdout.write(`offbox: ${item.detail}
2458
+ `);
2459
+ }
2460
+ return 0;
2461
+ }
2462
+
2463
+ // src/main.ts
2464
+ var HELP = `Packbat \u2014 every agent session, kept.
2465
+
2466
+ Usage: packbat <command> [options]
2467
+
2468
+ Commands:
2469
+ init set up archiving: detect harnesses, schedule the sweep, off-box or skip
2470
+ sync run one sweep now (the scheduled job runs this)
2471
+ doctor prove the schedule is alive and nothing is being missed
2472
+ restore put an archived session back where its harness resumes it
2473
+ status one-screen health summary
2474
+ search find text across archived sessions
2475
+ show read one archived session
2476
+
2477
+ Options:
2478
+ -h, --help show this help
2479
+ -v, --version show version
2480
+
2481
+ Run \`packbat <command> --help\` for command options.
2482
+ `;
2483
+ var commands = {
2484
+ init: runInit,
2485
+ sync: runSync,
2486
+ doctor: runDoctor,
2487
+ restore: runRestore,
2488
+ status: runStatus,
2489
+ search: runSearch,
2490
+ show: runShow
2491
+ };
2492
+ function version() {
2493
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
2494
+ return pkg.version;
2495
+ }
2496
+ for (const stream of [process.stdout, process.stderr]) {
2497
+ stream.on("error", (error) => {
2498
+ if (error.code === "EPIPE") {
2499
+ process.exit(0);
2500
+ }
2501
+ throw error;
2502
+ });
2503
+ }
2504
+ async function main(argv) {
2505
+ const [first, ...rest] = argv;
2506
+ if (first === void 0 || first === "--help" || first === "-h") {
2507
+ process.stdout.write(HELP);
2508
+ return first === void 0 ? 1 : 0;
2509
+ }
2510
+ if (first === "--version" || first === "-v") {
2511
+ process.stdout.write(`${version()}
2512
+ `);
2513
+ return 0;
2514
+ }
2515
+ const command = commands[first];
2516
+ if (command === void 0) {
2517
+ process.stderr.write(`packbat: unknown command "${first}"
2518
+
2519
+ ${HELP}`);
2520
+ return 1;
2521
+ }
2522
+ return command(rest);
2523
+ }
2524
+ main(process.argv.slice(2)).then(
2525
+ (code) => {
2526
+ process.exitCode = code;
2527
+ },
2528
+ (error) => {
2529
+ if (error instanceof PackbatError) {
2530
+ process.stderr.write(`packbat: ${error.message}
2531
+ `);
2532
+ } else {
2533
+ process.stderr.write(
2534
+ `packbat: unexpected error
2535
+ ${error instanceof Error ? error.stack ?? error.message : String(error)}
2536
+ `
2537
+ );
2538
+ }
2539
+ process.exitCode = 1;
2540
+ }
2541
+ );