opencode-sessions-explorer 0.1.2 → 0.1.3

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.
@@ -3,8 +3,8 @@
3
3
 
4
4
  // src/bin/check-deps.ts
5
5
  import { spawnSync } from "child_process";
6
- import { existsSync as existsSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
7
- import { join as join3 } from "path";
6
+ import { existsSync as existsSync7, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
7
+ import { join as join6 } from "path";
8
8
 
9
9
  // src/lib/db.ts
10
10
  import { Database } from "bun:sqlite";
@@ -248,8 +248,8 @@ function decodeModel(modelStr) {
248
248
  }
249
249
 
250
250
  // src/lib/export.ts
251
- import { mkdirSync, existsSync as existsSync2, renameSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "fs";
252
- import { join as join2 } from "path";
251
+ import { mkdirSync, existsSync as existsSync5, renameSync as renameSync2, writeFileSync as writeFileSync3, unlinkSync as unlinkSync3, readdirSync as readdirSync2 } from "fs";
252
+ import { join as join5 } from "path";
253
253
  import { homedir as homedir2 } from "os";
254
254
 
255
255
  // src/lib/channel.ts
@@ -275,55 +275,511 @@ function compactPath(path, baseDir) {
275
275
  return { path, rel_path: rel || "." };
276
276
  }
277
277
 
278
+ // src/lib/export-constants.ts
279
+ var SEARCHABLE_TYPES = ["text", "reasoning", "tool", "file", "patch", "subtask"];
280
+
281
+ // src/lib/export-lock.ts
282
+ import { closeSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
283
+ import { hostname } from "os";
284
+ import { join as join2 } from "path";
285
+ import { randomUUID } from "crypto";
286
+ var LOCK_FILE = ".export.lock";
287
+ var DEFAULT_STALE_MS = 2 * 60 * 1000;
288
+ var HEARTBEAT_MS = 15000;
289
+ function acquireExportLock(root, staleMs = DEFAULT_STALE_MS) {
290
+ const path = join2(root, LOCK_FILE);
291
+ const first = tryCreateLock(path);
292
+ if (first)
293
+ return first;
294
+ const stale = staleCandidate(path, staleMs);
295
+ if (!stale)
296
+ return null;
297
+ if (!removeStaleLock(path, stale, staleMs))
298
+ return tryCreateLock(path);
299
+ return tryCreateLock(path);
300
+ }
301
+ function tryCreateLock(path) {
302
+ let fd = null;
303
+ try {
304
+ const token = randomUUID();
305
+ const now = Date.now();
306
+ const record = { token, pid: process.pid, hostname: hostname(), created_at: now, updated_at: now };
307
+ fd = openSync(path, "wx");
308
+ writeFileSync(fd, JSON.stringify(record));
309
+ closeSync(fd);
310
+ fd = null;
311
+ let lastHeartbeat = now;
312
+ return {
313
+ token,
314
+ release: () => releaseLock(path, token),
315
+ heartbeat: () => {
316
+ const current = Date.now();
317
+ if (current - lastHeartbeat < HEARTBEAT_MS)
318
+ return;
319
+ lastHeartbeat = current;
320
+ heartbeatLock(path, token, current);
321
+ }
322
+ };
323
+ } catch {
324
+ if (fd != null)
325
+ try {
326
+ closeSync(fd);
327
+ } catch {}
328
+ return null;
329
+ }
330
+ }
331
+ function staleCandidate(path, staleMs) {
332
+ try {
333
+ const stat = statSync(path);
334
+ const now = Date.now();
335
+ if (now - stat.mtimeMs <= staleMs)
336
+ return null;
337
+ const parsed = readLockRecord(path);
338
+ if (!parsed)
339
+ return { token: null, updatedAt: null, mtimeMs: stat.mtimeMs };
340
+ if (now - parsed.updated_at <= staleMs)
341
+ return null;
342
+ if (isLiveLocalOwner(parsed))
343
+ return null;
344
+ return { token: parsed.token, updatedAt: parsed.updated_at, mtimeMs: stat.mtimeMs };
345
+ } catch {
346
+ return { token: null, updatedAt: null, mtimeMs: 0 };
347
+ }
348
+ }
349
+ function removeStaleLock(path, stale, staleMs) {
350
+ try {
351
+ const stat = statSync(path);
352
+ if (stat.mtimeMs !== stale.mtimeMs)
353
+ return false;
354
+ if (stale.token) {
355
+ const current = readLockRecord(path);
356
+ if (current?.token !== stale.token)
357
+ return false;
358
+ if (current.updated_at !== stale.updatedAt)
359
+ return false;
360
+ const now = Date.now();
361
+ if (now - current.updated_at <= staleMs)
362
+ return false;
363
+ if (now - stat.mtimeMs <= staleMs)
364
+ return false;
365
+ if (isLiveLocalOwner(current))
366
+ return false;
367
+ } else {
368
+ if (Date.now() - stat.mtimeMs <= staleMs)
369
+ return false;
370
+ }
371
+ unlinkSync(path);
372
+ return true;
373
+ } catch {
374
+ return false;
375
+ }
376
+ }
377
+ function releaseLock(path, token) {
378
+ try {
379
+ const current = readLockRecord(path);
380
+ if (current?.token === token)
381
+ unlinkSync(path);
382
+ } catch {}
383
+ }
384
+ function heartbeatLock(path, token, now) {
385
+ try {
386
+ const current = readLockRecord(path);
387
+ if (current?.token !== token)
388
+ return;
389
+ writeFileSync(path, JSON.stringify({ ...current, updated_at: now }));
390
+ } catch {}
391
+ }
392
+ function readLockRecord(path) {
393
+ try {
394
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
395
+ if (typeof parsed.token !== "string")
396
+ return null;
397
+ if (typeof parsed.pid !== "number" || !Number.isFinite(parsed.pid))
398
+ return null;
399
+ if (typeof parsed.hostname !== "string")
400
+ return null;
401
+ if (typeof parsed.created_at !== "number" || !Number.isFinite(parsed.created_at))
402
+ return null;
403
+ const updated = typeof parsed.updated_at === "number" && Number.isFinite(parsed.updated_at) ? parsed.updated_at : parsed.created_at;
404
+ return { token: parsed.token, pid: parsed.pid, hostname: parsed.hostname, created_at: parsed.created_at, updated_at: updated };
405
+ } catch {
406
+ return null;
407
+ }
408
+ }
409
+ function isLiveLocalOwner(record) {
410
+ if (record.hostname !== hostname())
411
+ return false;
412
+ if (!Number.isSafeInteger(record.pid) || record.pid <= 0)
413
+ return false;
414
+ try {
415
+ process.kill(record.pid, 0);
416
+ return true;
417
+ } catch (error) {
418
+ return error?.code === "EPERM";
419
+ }
420
+ }
421
+
422
+ // src/lib/export-tombstones.ts
423
+ import { existsSync as existsSync2, readdirSync, rmSync, statSync as statSync2, unlinkSync as unlinkSync2 } from "fs";
424
+ import { join as join3 } from "path";
425
+ var PART_FILE_RE = /^(?:\d{5}-)?(prt_[A-Za-z0-9_-]+)\.txt$/;
426
+ function reconcileTombstones(root, heartbeat = () => {}) {
427
+ const progress = {
428
+ scanned_sessions: 0,
429
+ removed_sessions: 0,
430
+ removed_parts: 0,
431
+ removed_channel_sessions: 0
432
+ };
433
+ const bySession = join3(root, "by-session");
434
+ const sessions = loadSessionIds();
435
+ if (existsSync2(bySession)) {
436
+ for (const entry of safeReadDir(bySession)) {
437
+ const dir = join3(bySession, entry);
438
+ if (!isDirectory(dir))
439
+ continue;
440
+ if (!sessions.has(entry)) {
441
+ rmSync(dir, { recursive: true, force: true });
442
+ removeChannelSessionDirs(root, entry);
443
+ progress.removed_sessions++;
444
+ heartbeat();
445
+ continue;
446
+ }
447
+ progress.scanned_sessions++;
448
+ progress.removed_parts += removeOrphanPartFiles(root, entry, dir, heartbeat);
449
+ heartbeat();
450
+ }
451
+ }
452
+ progress.removed_channel_sessions += removeOrphanChannelSessions(root, sessions, heartbeat);
453
+ return progress;
454
+ }
455
+ function removeOrphanPartFiles(root, sessionId, dir, heartbeat) {
456
+ const livePartIds = loadSearchablePartIds(sessionId);
457
+ let removed = 0;
458
+ for (const file of safeReadDir(dir)) {
459
+ heartbeat();
460
+ const partId = partIdFromFile(file);
461
+ if (!partId || livePartIds.has(partId))
462
+ continue;
463
+ try {
464
+ unlinkSync2(join3(dir, file));
465
+ removeChannelPartFiles(root, sessionId, partId);
466
+ removed++;
467
+ } catch {}
468
+ }
469
+ return removed;
470
+ }
471
+ function removeOrphanChannelSessions(root, sessions, heartbeat) {
472
+ let removed = 0;
473
+ for (const channel of CHANNELS) {
474
+ const base = join3(root, "by-channel", channel, "by-session");
475
+ if (!existsSync2(base))
476
+ continue;
477
+ for (const sessionId of safeReadDir(base)) {
478
+ heartbeat();
479
+ const dir = join3(base, sessionId);
480
+ if (!isDirectory(dir) || sessions.has(sessionId))
481
+ continue;
482
+ rmSync(dir, { recursive: true, force: true });
483
+ removed++;
484
+ }
485
+ }
486
+ return removed;
487
+ }
488
+ function removeChannelSessionDirs(root, sessionId) {
489
+ for (const channel of CHANNELS) {
490
+ rmSync(channelDir(root, channel, sessionId), { recursive: true, force: true });
491
+ }
492
+ }
493
+ function removeChannelPartFiles(root, sessionId, partId) {
494
+ for (const channel of CHANNELS) {
495
+ const dir = channelDir(root, channel, sessionId);
496
+ if (!existsSync2(dir))
497
+ continue;
498
+ for (const file of safeReadDir(dir)) {
499
+ if (file === `${partId}.txt` || file.endsWith(`-${partId}.txt`)) {
500
+ try {
501
+ unlinkSync2(join3(dir, file));
502
+ } catch {}
503
+ }
504
+ }
505
+ }
506
+ }
507
+ function channelDir(root, channel, sessionId) {
508
+ return join3(root, "by-channel", channel, "by-session", sessionId);
509
+ }
510
+ function loadSessionIds() {
511
+ const rows = stmt(`SELECT id FROM session`).all();
512
+ return new Set(rows.map((row) => row.id));
513
+ }
514
+ function loadSearchablePartIds(sessionId) {
515
+ const placeholders = SEARCHABLE_TYPES.map(() => "?").join(",");
516
+ const rows = stmt(`
517
+ SELECT id
518
+ FROM part
519
+ WHERE session_id = ?
520
+ AND json_extract(data,'$.type') IN (${placeholders})
521
+ ORDER BY id ASC`).all(sessionId, ...SEARCHABLE_TYPES);
522
+ return new Set(rows.map((row) => row.id));
523
+ }
524
+ function safeReadDir(dir) {
525
+ try {
526
+ return readdirSync(dir);
527
+ } catch {
528
+ return [];
529
+ }
530
+ }
531
+ function isDirectory(path) {
532
+ try {
533
+ return statSync2(path).isDirectory();
534
+ } catch {
535
+ return false;
536
+ }
537
+ }
538
+ function partIdFromFile(file) {
539
+ const match = PART_FILE_RE.exec(file);
540
+ return match ? match[1] : null;
541
+ }
542
+
543
+ // src/lib/export-background.ts
544
+ import { existsSync as existsSync3 } from "fs";
545
+ import { fileURLToPath } from "url";
546
+ var DEFAULT_MIN_INTERVAL_MS = 5 * 60 * 1000;
547
+ var inFlight = false;
548
+ var lastStartedAt = 0;
549
+ function scheduleBackgroundReconcile(opts) {
550
+ const now = Date.now();
551
+ const minIntervalMs = opts.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
552
+ if (inFlight)
553
+ return { scheduled: false, reason: "already_running" };
554
+ if (now - lastStartedAt < minIntervalMs)
555
+ return { scheduled: false, reason: "throttled" };
556
+ const url = resolveWorkerUrl();
557
+ try {
558
+ const worker = new Worker(url, { type: "module" });
559
+ inFlight = true;
560
+ lastStartedAt = now;
561
+ const cleanup = () => {
562
+ inFlight = false;
563
+ worker.terminate();
564
+ };
565
+ worker.addEventListener("message", cleanup, { once: true });
566
+ worker.addEventListener("error", cleanup, { once: true });
567
+ const maybeUnref = worker;
568
+ maybeUnref.unref?.();
569
+ const request = { root: opts.root, batchSize: opts.batchSize ?? 2000 };
570
+ worker.postMessage(request);
571
+ return { scheduled: true };
572
+ } catch {
573
+ inFlight = false;
574
+ return { scheduled: false, reason: "worker_unavailable" };
575
+ }
576
+ }
577
+ function resolveWorkerUrl() {
578
+ const js = new URL("./export-reconcile-worker.js", import.meta.url);
579
+ if (existsSync3(fileURLToPath(js)))
580
+ return js;
581
+ const ts = new URL("./export-reconcile-worker.ts", import.meta.url);
582
+ if (existsSync3(fileURLToPath(ts)))
583
+ return ts;
584
+ const bundled = new URL("./lib/export-reconcile-worker.js", import.meta.url);
585
+ if (existsSync3(fileURLToPath(bundled)))
586
+ return bundled;
587
+ return js;
588
+ }
589
+
590
+ // src/lib/export-state.ts
591
+ import { existsSync as existsSync4, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "fs";
592
+ import { join as join4 } from "path";
593
+ var CURSOR_SCHEMA = "v3";
594
+ var LAST_SYNC_FILE = ".last_sync";
595
+ function freshSyncState(migratedFrom, legacyCursor = null) {
596
+ return {
597
+ schema: CURSOR_SCHEMA,
598
+ insert_cursor: { id: "" },
599
+ session_cursor: null,
600
+ session_dirty_hints: {},
601
+ reconcile_watermark: null,
602
+ failed_parts: {},
603
+ dead_letters: {},
604
+ last_reconcile_at: null,
605
+ legacy_cursor: legacyCursor,
606
+ migrated_from: migratedFrom
607
+ };
608
+ }
609
+ function getSyncState(root) {
610
+ const p = join4(root, LAST_SYNC_FILE);
611
+ if (!existsSync4(p))
612
+ return freshSyncState();
613
+ try {
614
+ return parseSyncState(readFileSync2(p, "utf8"));
615
+ } catch {
616
+ return freshSyncState("unreadable");
617
+ }
618
+ }
619
+ function setSyncState(state, root) {
620
+ const p = join4(root, LAST_SYNC_FILE);
621
+ const tmp = p + ".tmp";
622
+ writeFileSync2(tmp, `${CURSOR_SCHEMA} ${JSON.stringify(normalizeSyncState(state))}`);
623
+ renameSync(tmp, p);
624
+ }
625
+ function getLastSync(root) {
626
+ const p = join4(root, LAST_SYNC_FILE);
627
+ if (!existsSync4(p))
628
+ return null;
629
+ const state = getSyncState(root);
630
+ if (state.legacy_cursor)
631
+ return state.legacy_cursor;
632
+ if (!state.insert_cursor.id)
633
+ return null;
634
+ return { ts: 0, id: state.insert_cursor.id };
635
+ }
636
+ function parseSyncState(rawInput) {
637
+ const raw = rawInput.trim();
638
+ if (!raw)
639
+ return freshSyncState("empty");
640
+ if (raw.startsWith(`${CURSOR_SCHEMA} `)) {
641
+ const parsed = JSON.parse(raw.slice(CURSOR_SCHEMA.length + 1));
642
+ return normalizeSyncState(parsed);
643
+ }
644
+ if (raw.startsWith("{")) {
645
+ return normalizeSyncState(JSON.parse(raw));
646
+ }
647
+ if (raw.startsWith("v2 ")) {
648
+ return freshSyncState("v2", parseLegacyCursor(raw.slice(3)));
649
+ }
650
+ const legacy = parseLegacyCursor(raw);
651
+ return freshSyncState(legacy ? "v1" : "unknown", legacy);
652
+ }
653
+ function normalizeSyncState(input) {
654
+ if (!isRecord(input))
655
+ return freshSyncState("invalid");
656
+ const state = freshSyncState(asString(input.migrated_from) ?? undefined, cursorOrNull(input.legacy_cursor));
657
+ const insert = isRecord(input.insert_cursor) ? input.insert_cursor : null;
658
+ state.insert_cursor.id = asString(insert?.id) ?? "";
659
+ state.session_cursor = cursorOrNull(input.session_cursor);
660
+ state.session_dirty_hints = dirtyHints(input.session_dirty_hints);
661
+ state.reconcile_watermark = reconcileWatermark(input.reconcile_watermark);
662
+ state.failed_parts = failedParts(input.failed_parts);
663
+ state.dead_letters = failedParts(input.dead_letters);
664
+ state.last_reconcile_at = finiteOrNull(input.last_reconcile_at);
665
+ return state;
666
+ }
667
+ function parseLegacyCursor(raw) {
668
+ const idx = raw.indexOf(":");
669
+ if (idx <= 0)
670
+ return null;
671
+ const ts = Number(raw.slice(0, idx));
672
+ const id = raw.slice(idx + 1);
673
+ if (!Number.isFinite(ts) || !id)
674
+ return null;
675
+ return { ts, id };
676
+ }
677
+ function cursorOrNull(value) {
678
+ if (!isRecord(value))
679
+ return null;
680
+ const ts = finiteOrNull(value.ts);
681
+ const id = asString(value.id);
682
+ if (ts == null || !id)
683
+ return null;
684
+ return { ts, id };
685
+ }
686
+ function dirtyHints(value) {
687
+ if (!isRecord(value))
688
+ return {};
689
+ const out = {};
690
+ for (const [id, raw] of Object.entries(value)) {
691
+ if (!id)
692
+ continue;
693
+ if (typeof raw === "number" && Number.isFinite(raw)) {
694
+ out[id] = { time_updated: raw, part_cursor: null };
695
+ continue;
696
+ }
697
+ if (!isRecord(raw))
698
+ continue;
699
+ const timeUpdated = finiteOrNull(raw.time_updated);
700
+ if (timeUpdated == null)
701
+ continue;
702
+ out[id] = { time_updated: timeUpdated, part_cursor: asString(raw.part_cursor) };
703
+ }
704
+ return out;
705
+ }
706
+ function reconcileWatermark(value) {
707
+ if (!isRecord(value))
708
+ return null;
709
+ const at = finiteOrNull(value.at);
710
+ if (at == null)
711
+ return null;
712
+ return {
713
+ part_id: asString(value.part_id),
714
+ session_id: asString(value.session_id),
715
+ at
716
+ };
717
+ }
718
+ function failedParts(value) {
719
+ if (!isRecord(value))
720
+ return {};
721
+ const out = {};
722
+ for (const [id, raw] of Object.entries(value)) {
723
+ if (!id || !isRecord(raw))
724
+ continue;
725
+ const attempts = finiteOrNull(raw.attempts);
726
+ const firstFailedAt = finiteOrNull(raw.first_failed_at);
727
+ const lastFailedAt = finiteOrNull(raw.last_failed_at);
728
+ if (attempts == null || firstFailedAt == null || lastFailedAt == null)
729
+ continue;
730
+ out[id] = {
731
+ id,
732
+ attempts,
733
+ first_failed_at: firstFailedAt,
734
+ last_failed_at: lastFailedAt,
735
+ last_error: asString(raw.last_error) ?? "unknown export failure"
736
+ };
737
+ }
738
+ return out;
739
+ }
740
+ function finiteOrNull(value) {
741
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
742
+ }
743
+ function asString(value) {
744
+ return typeof value === "string" && value.length > 0 ? value : null;
745
+ }
746
+ function isRecord(value) {
747
+ return typeof value === "object" && value !== null && !Array.isArray(value);
748
+ }
749
+
278
750
  // src/lib/export.ts
279
- var DEFAULT_EXPORT_ROOT = join2(homedir2(), ".local/share/opencode-sessions-explorer");
751
+ var DEFAULT_EXPORT_ROOT = join5(homedir2(), ".local/share/opencode-sessions-explorer");
280
752
  var BODY_CAP_BYTES = 256 * 1024;
281
753
  var SAFETY_PART_CAP_BYTES = 50 * 1024 * 1024;
282
754
  var CHANNEL_COMPLETE_MARKER = ".channels_v1_complete";
283
- var SEARCHABLE_TYPES = ["text", "reasoning", "tool", "file", "patch", "subtask"];
755
+ var INSERT_REWIND_MS = 3 * 60 * 1000;
756
+ var INSERT_REWIND_MAX_ROWS = 512;
757
+ var MAX_FAILED_ATTEMPTS = 5;
284
758
  var PART_CHANNELS = CHANNELS.filter((c) => c !== "session-summary" && c !== "raw");
285
759
  function exportRoot() {
286
760
  return process.env.OPENCODE_SESSIONS_EXPLORER_EXPORT_ROOT || DEFAULT_EXPORT_ROOT;
287
761
  }
762
+ function getSyncState2(root = exportRoot()) {
763
+ return getSyncState(root);
764
+ }
765
+ function setSyncState2(state, root = exportRoot()) {
766
+ setSyncState(state, root);
767
+ }
768
+ function getLastSync2(root = exportRoot()) {
769
+ return getLastSync(root);
770
+ }
288
771
  function ensureRoot(root = exportRoot()) {
289
- mkdirSync(join2(root, "by-session"), { recursive: true });
772
+ mkdirSync(join5(root, "by-session"), { recursive: true });
290
773
  return root;
291
774
  }
292
775
  function channelExportComplete(root = exportRoot()) {
293
- return existsSync2(join2(root, CHANNEL_COMPLETE_MARKER));
776
+ return existsSync5(join5(root, CHANNEL_COMPLETE_MARKER));
294
777
  }
295
778
  function markChannelExportComplete(root = exportRoot()) {
296
- const p = join2(root, CHANNEL_COMPLETE_MARKER);
297
- const tmp = p + ".tmp";
298
- writeFileSync(tmp, String(Date.now()));
299
- renameSync(tmp, p);
300
- }
301
- var CURSOR_SCHEMA = "v2";
302
- function getLastSync(root = exportRoot()) {
303
- const p = join2(root, ".last_sync");
304
- if (!existsSync2(p))
305
- return null;
306
- try {
307
- const raw = readFileSync(p, "utf8").trim();
308
- if (!raw)
309
- return null;
310
- if (raw.startsWith(`${CURSOR_SCHEMA} `)) {
311
- const [tsStr, id] = raw.slice(CURSOR_SCHEMA.length + 1).split(":");
312
- const ts = Number(tsStr);
313
- if (!Number.isFinite(ts) || !id)
314
- return null;
315
- return { ts, id };
316
- }
317
- return null;
318
- } catch {
319
- return null;
320
- }
321
- }
322
- function setLastSync(c, root = exportRoot()) {
323
- const p = join2(root, ".last_sync");
779
+ const p = join5(root, CHANNEL_COMPLETE_MARKER);
324
780
  const tmp = p + ".tmp";
325
- writeFileSync(tmp, `${CURSOR_SCHEMA} ${c.ts}:${c.id}`);
326
- renameSync(tmp, p);
781
+ writeFileSync3(tmp, String(Date.now()));
782
+ renameSync2(tmp, p);
327
783
  }
328
784
  var sessionCache = new Map;
329
785
  function getSession(id) {
@@ -566,46 +1022,46 @@ function writeMeta(s, dir) {
566
1022
  time_updated: s.time_updated,
567
1023
  archived: s.time_archived != null
568
1024
  };
569
- const p = join2(dir, "meta.json");
1025
+ const p = join5(dir, "meta.json");
570
1026
  const tmp = p + ".tmp";
571
- writeFileSync(tmp, JSON.stringify(meta, null, 2));
572
- renameSync(tmp, p);
1027
+ writeFileSync3(tmp, JSON.stringify(meta, null, 2));
1028
+ renameSync2(tmp, p);
573
1029
  }
574
1030
  function writePartFile(dir, filename, content) {
575
- const p = join2(dir, filename);
576
- const tmp = join2(dir, "." + filename + ".tmp");
577
- writeFileSync(tmp, content);
578
- renameSync(tmp, p);
1031
+ const p = join5(dir, filename);
1032
+ const tmp = join5(dir, "." + filename + ".tmp");
1033
+ writeFileSync3(tmp, content);
1034
+ renameSync2(tmp, p);
579
1035
  const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(filename);
580
1036
  if (!m)
581
1037
  return;
582
1038
  const myPartId = m[2];
583
1039
  try {
584
- for (const f of readdirSync(dir)) {
1040
+ for (const f of readdirSync2(dir)) {
585
1041
  if (f === filename || !f.endsWith(".txt") || f.startsWith("."))
586
1042
  continue;
587
1043
  const fm = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
588
1044
  if (fm && fm[2] === myPartId) {
589
1045
  try {
590
- unlinkSync(join2(dir, f));
1046
+ unlinkSync3(join5(dir, f));
591
1047
  } catch {}
592
1048
  }
593
1049
  }
594
1050
  } catch {}
595
1051
  }
596
- function channelDir(root, channel, sessionId) {
597
- return join2(root, "by-channel", channel, "by-session", sessionId);
1052
+ function channelDir2(root, channel, sessionId) {
1053
+ return join5(root, "by-channel", channel, "by-session", sessionId);
598
1054
  }
599
1055
  function deleteChannelPartFiles(root, sessionId, partId) {
600
1056
  for (const ch of PART_CHANNELS) {
601
- const dir = channelDir(root, ch, sessionId);
602
- if (!existsSync2(dir))
1057
+ const dir = channelDir2(root, ch, sessionId);
1058
+ if (!existsSync5(dir))
603
1059
  continue;
604
1060
  try {
605
- for (const f of readdirSync(dir)) {
1061
+ for (const f of readdirSync2(dir)) {
606
1062
  if (f === `${partId}.txt` || f.endsWith(`-${partId}.txt`)) {
607
1063
  try {
608
- unlinkSync(join2(dir, f));
1064
+ unlinkSync3(join5(dir, f));
609
1065
  } catch {}
610
1066
  }
611
1067
  }
@@ -615,20 +1071,20 @@ function deleteChannelPartFiles(root, sessionId, partId) {
615
1071
  function writeChannelFiles(root, sessionId, filename, partId, docs) {
616
1072
  deleteChannelPartFiles(root, sessionId, partId);
617
1073
  for (const doc of docs) {
618
- const dir = channelDir(root, doc.channel, sessionId);
619
- if (!existsSync2(dir))
1074
+ const dir = channelDir2(root, doc.channel, sessionId);
1075
+ if (!existsSync5(dir))
620
1076
  mkdirSync(dir, { recursive: true });
621
1077
  writePartFile(dir, filename, doc.content);
622
1078
  }
623
1079
  }
624
1080
  function writeSessionSummaryChannel(s, dirRoot = exportRoot()) {
625
- const dir = channelDir(dirRoot, "session-summary", s.id);
626
- if (!existsSync2(dir))
1081
+ const dir = channelDir2(dirRoot, "session-summary", s.id);
1082
+ if (!existsSync5(dir))
627
1083
  mkdirSync(dir, { recursive: true });
628
- const p = join2(dir, "summary.txt");
1084
+ const p = join5(dir, "summary.txt");
629
1085
  const tmp = p + ".tmp";
630
- writeFileSync(tmp, buildSessionSummaryDocument(s));
631
- renameSync(tmp, p);
1086
+ writeFileSync3(tmp, buildSessionSummaryDocument(s));
1087
+ renameSync2(tmp, p);
632
1088
  }
633
1089
  function buildSessionSummaryDocument(s) {
634
1090
  const firstPrompt = firstUserPrompt(s.id, "ASC");
@@ -682,7 +1138,7 @@ function getFileIndex(sessionId, dir) {
682
1138
  return idx;
683
1139
  idx = { nextSeq: 1, byPartId: new Map };
684
1140
  try {
685
- const files = readdirSync(dir).filter((f) => f.endsWith(".txt") && !f.startsWith("."));
1141
+ const files = readdirSync2(dir).filter((f) => f.endsWith(".txt") && !f.startsWith("."));
686
1142
  let max = 0;
687
1143
  for (const f of files) {
688
1144
  const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
@@ -703,115 +1159,326 @@ function getFileIndex(sessionId, dir) {
703
1159
  }
704
1160
  async function runExport(opts = {}) {
705
1161
  const root = ensureRoot(opts.root ?? exportRoot());
706
- const cursor = opts.fromCursor !== undefined ? opts.fromCursor : getLastSync(root);
707
1162
  const batchSize = opts.batchSize ?? 1000;
708
- const start = Date.now();
709
- const progress = { exported: 0, inserts: 0, updates: 0, skipped_nontext: 0, skipped_oversize: 0, failed: 0, last_cursor: cursor };
710
- let where = "";
711
- const params = [];
712
- if (cursor) {
713
- where = "WHERE (p.time_updated > ? OR (p.time_updated = ? AND p.id > ?))";
714
- params.push(cursor.ts, cursor.ts, cursor.id);
715
- }
716
- let updates = 0;
717
- let inserts = 0;
718
- const touchedSessions = new Set;
719
- while (true) {
720
- if (opts.budgetMs && Date.now() - start > opts.budgetMs)
1163
+ const progress = emptyProgress(getLastSync2(root));
1164
+ const lock = acquireExportLock(root);
1165
+ if (!lock) {
1166
+ progress.lock_skipped = true;
1167
+ return progress;
1168
+ }
1169
+ try {
1170
+ const state = getSyncState2(root);
1171
+ applyCursorOverride(state, opts.fromCursor);
1172
+ const start = Date.now();
1173
+ const touchedSessions = new Set;
1174
+ retryFailedParts(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
1175
+ runInsertFastPath(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
1176
+ runSessionDirtyFastPath(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
1177
+ refreshTouchedSessions(root, touchedSessions);
1178
+ if (!opts.budgetMs) {
1179
+ lock.heartbeat();
1180
+ const tombstones = reconcileTombstones(root, lock.heartbeat);
1181
+ applyTombstoneProgress(progress, tombstones);
1182
+ state.last_reconcile_at = Date.now();
1183
+ state.reconcile_watermark = {
1184
+ part_id: state.insert_cursor.id || null,
1185
+ session_id: state.session_cursor?.id ?? null,
1186
+ at: state.last_reconcile_at
1187
+ };
1188
+ }
1189
+ progress.last_cursor = state.legacy_cursor;
1190
+ setSyncState2(state, root);
1191
+ } finally {
1192
+ lock.release();
1193
+ }
1194
+ if (opts.budgetMs && !opts.skipBackgroundReconcile) {
1195
+ scheduleBackgroundReconcile({ root });
1196
+ }
1197
+ return progress;
1198
+ }
1199
+ function emptyProgress(cursor) {
1200
+ return {
1201
+ exported: 0,
1202
+ inserts: 0,
1203
+ updates: 0,
1204
+ skipped_nontext: 0,
1205
+ skipped_oversize: 0,
1206
+ failed: 0,
1207
+ retried: 0,
1208
+ dead_lettered: 0,
1209
+ tombstones_removed_parts: 0,
1210
+ tombstones_removed_sessions: 0,
1211
+ lock_skipped: false,
1212
+ last_cursor: cursor
1213
+ };
1214
+ }
1215
+ function applyCursorOverride(state, cursor) {
1216
+ if (cursor === undefined)
1217
+ return;
1218
+ state.legacy_cursor = cursor;
1219
+ state.insert_cursor.id = cursor?.id ?? "";
1220
+ state.session_cursor = cursor && cursor.ts > 0 ? cursor : null;
1221
+ state.session_dirty_hints = {};
1222
+ }
1223
+ function retryFailedParts(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
1224
+ const ids = Object.keys(state.failed_parts).sort().slice(0, batchSize);
1225
+ for (const id of ids) {
1226
+ if (timeExceeded(start, budgetMs))
721
1227
  break;
722
- const rows = stmt(`
723
- SELECT p.id, p.session_id, p.message_id, p.time_created, p.time_updated, p.data, LENGTH(p.data) AS data_bytes,
724
- json_extract(m.data,'$.role') AS role
725
- FROM part p
726
- LEFT JOIN message m ON m.id = p.message_id
727
- ${where}
728
- ORDER BY p.time_updated ASC, p.id ASC
729
- LIMIT ?`).all(...params, batchSize);
1228
+ progress.retried++;
1229
+ const row = loadPartById(id);
1230
+ if (!row) {
1231
+ clearPartFailure(state, id);
1232
+ continue;
1233
+ }
1234
+ exportPartRow(root, state, row, progress, touchedSessions);
1235
+ reportProgress(progress, onProgress, heartbeat);
1236
+ }
1237
+ }
1238
+ function runInsertFastPath(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
1239
+ const recentSafeRows = [];
1240
+ let scanCursor = state.insert_cursor.id;
1241
+ while (!timeExceeded(start, budgetMs)) {
1242
+ const rows = loadPartRowsAfterId(scanCursor, batchSize);
730
1243
  if (rows.length === 0)
731
1244
  break;
732
- for (const r of rows) {
733
- if (opts.budgetMs && Date.now() - start > opts.budgetMs)
1245
+ for (const row of rows) {
1246
+ if (timeExceeded(start, budgetMs))
1247
+ break;
1248
+ scanCursor = row.id;
1249
+ const safe = exportPartRow(root, state, row, progress, touchedSessions);
1250
+ if (safe)
1251
+ rememberSafeRow(recentSafeRows, row);
1252
+ reportProgress(progress, onProgress, heartbeat);
1253
+ }
1254
+ if (rows.length < batchSize)
1255
+ break;
1256
+ }
1257
+ if (recentSafeRows.length > 0) {
1258
+ state.insert_cursor.id = chooseInsertCursor(state.insert_cursor.id, recentSafeRows, budgetMs !== undefined);
1259
+ }
1260
+ }
1261
+ function runSessionDirtyFastPath(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
1262
+ scanDirtySessionHints(state, start, budgetMs, Math.min(batchSize, 500));
1263
+ for (const [sessionId, hint] of sortedDirtyHints(state.session_dirty_hints)) {
1264
+ while (!timeExceeded(start, budgetMs)) {
1265
+ const rows = loadSessionPartRows(sessionId, hint.part_cursor, batchSize);
1266
+ if (rows.length === 0) {
1267
+ delete state.session_dirty_hints[sessionId];
1268
+ break;
1269
+ }
1270
+ for (const row of rows) {
1271
+ if (timeExceeded(start, budgetMs))
1272
+ break;
1273
+ hint.part_cursor = row.id;
1274
+ exportPartRow(root, state, row, progress, touchedSessions);
1275
+ reportProgress(progress, onProgress, heartbeat);
1276
+ }
1277
+ if (rows.length < batchSize) {
1278
+ delete state.session_dirty_hints[sessionId];
734
1279
  break;
735
- if (r.data_bytes > SAFETY_PART_CAP_BYTES) {
736
- progress.skipped_oversize++;
737
- } else {
738
- try {
739
- const s = getSession(r.session_id);
740
- if (!s) {
741
- progress.failed++;
742
- continue;
743
- }
744
- const built = buildPartFile(r.id, r.session_id, r.message_id, r.data, s.time_archived != null);
745
- if (!built) {
746
- progress.skipped_nontext++;
747
- continue;
748
- }
749
- const channelDocs = buildChannelDocuments(r.id, r.session_id, r.message_id, r.data, s.time_archived != null, r.role ?? null, s.directory);
750
- const dir = join2(root, "by-session", r.session_id);
751
- if (!existsSync2(dir)) {
752
- mkdirSync(dir, { recursive: true });
753
- writeMeta(s, dir);
754
- }
755
- const idx = getFileIndex(r.session_id, dir);
756
- const existing = idx.byPartId.get(r.id);
757
- if (existing) {
758
- writePartFile(dir, existing, built.content);
759
- updates++;
760
- } else {
761
- const seq = idx.nextSeq++;
762
- const filename2 = safePartFilename(seq, r.id);
763
- writePartFile(dir, filename2, built.content);
764
- idx.byPartId.set(r.id, filename2);
765
- inserts++;
766
- }
767
- const filename = idx.byPartId.get(r.id);
768
- if (filename)
769
- writeChannelFiles(root, r.session_id, filename, r.id, channelDocs);
770
- touchedSessions.add(r.session_id);
771
- progress.exported++;
772
- } catch {
773
- progress.failed++;
774
- }
775
1280
  }
776
- progress.last_cursor = { ts: r.time_updated, id: r.id };
777
1281
  }
778
- const last = rows[rows.length - 1];
779
- where = "WHERE (p.time_updated > ? OR (p.time_updated = ? AND p.id > ?))";
780
- params.length = 0;
781
- params.push(last.time_updated, last.time_updated, last.id);
782
- if (opts.onProgress && progress.exported % 5000 === 0)
783
- opts.onProgress(progress);
784
- if (progress.last_cursor && progress.exported > 0 && progress.exported % 5000 === 0) {
785
- setLastSync(progress.last_cursor, root);
1282
+ if (timeExceeded(start, budgetMs))
1283
+ break;
1284
+ }
1285
+ }
1286
+ function scanDirtySessionHints(state, start, budgetMs, limit) {
1287
+ while (!timeExceeded(start, budgetMs)) {
1288
+ const rows = loadDirtySessionsAfter(state.session_cursor, limit);
1289
+ if (rows.length === 0)
1290
+ break;
1291
+ for (const row of rows) {
1292
+ state.session_dirty_hints[row.id] = { time_updated: row.time_updated, part_cursor: null };
1293
+ state.session_cursor = { ts: row.time_updated, id: row.id };
1294
+ }
1295
+ if (rows.length < limit)
1296
+ break;
1297
+ }
1298
+ }
1299
+ function exportPartRow(root, state, row, progress, touchedSessions) {
1300
+ if (row.data_bytes > SAFETY_PART_CAP_BYTES) {
1301
+ removeExistingPartExport(root, row.session_id, row.id);
1302
+ progress.skipped_oversize++;
1303
+ markSafeCursor(state, progress, row);
1304
+ clearPartFailure(state, row.id);
1305
+ return true;
1306
+ }
1307
+ try {
1308
+ const session = getSession(row.session_id);
1309
+ if (!session)
1310
+ throw new Error(`missing session ${row.session_id}`);
1311
+ const archived = session.time_archived != null;
1312
+ const built = buildPartFile(row.id, row.session_id, row.message_id, row.data, archived);
1313
+ if (!built) {
1314
+ removeExistingPartExport(root, row.session_id, row.id);
1315
+ progress.skipped_nontext++;
1316
+ markSafeCursor(state, progress, row);
1317
+ clearPartFailure(state, row.id);
1318
+ return true;
1319
+ }
1320
+ const channelDocs = buildChannelDocuments(row.id, row.session_id, row.message_id, row.data, archived, row.role, session.directory);
1321
+ const dir = join5(root, "by-session", row.session_id);
1322
+ if (!existsSync5(dir)) {
1323
+ mkdirSync(dir, { recursive: true });
1324
+ writeMeta(session, dir);
1325
+ }
1326
+ const idx = getFileIndex(row.session_id, dir);
1327
+ const existing = idx.byPartId.get(row.id);
1328
+ if (existing) {
1329
+ writePartFile(dir, existing, built.content);
1330
+ progress.updates++;
1331
+ } else {
1332
+ const filename2 = safePartFilename(idx.nextSeq++, row.id);
1333
+ writePartFile(dir, filename2, built.content);
1334
+ idx.byPartId.set(row.id, filename2);
1335
+ progress.inserts++;
786
1336
  }
1337
+ const filename = idx.byPartId.get(row.id);
1338
+ if (filename)
1339
+ writeChannelFiles(root, row.session_id, filename, row.id, channelDocs);
1340
+ touchedSessions.add(row.session_id);
1341
+ progress.exported++;
1342
+ markSafeCursor(state, progress, row);
1343
+ clearPartFailure(state, row.id);
1344
+ return true;
1345
+ } catch (error) {
1346
+ progress.failed++;
1347
+ markPartFailure(state, row.id, errorMessage(error), progress);
1348
+ return false;
787
1349
  }
1350
+ }
1351
+ function removeExistingPartExport(root, sessionId, partId) {
1352
+ const dir = join5(root, "by-session", sessionId);
1353
+ try {
1354
+ if (existsSync5(dir)) {
1355
+ const idx = getFileIndex(sessionId, dir);
1356
+ const existing = idx.byPartId.get(partId);
1357
+ if (existing)
1358
+ unlinkSync3(join5(dir, existing));
1359
+ idx.byPartId.delete(partId);
1360
+ }
1361
+ deleteChannelPartFiles(root, sessionId, partId);
1362
+ } catch {}
1363
+ }
1364
+ function markSafeCursor(state, progress, row) {
1365
+ const cursor = { ts: row.time_updated, id: row.id };
1366
+ state.legacy_cursor = cursor;
1367
+ progress.last_cursor = cursor;
1368
+ }
1369
+ function markPartFailure(state, partId, message, progress) {
1370
+ if (state.dead_letters[partId])
1371
+ return;
1372
+ const now = Date.now();
1373
+ const existing = state.failed_parts[partId];
1374
+ const failure = {
1375
+ id: partId,
1376
+ attempts: (existing?.attempts ?? 0) + 1,
1377
+ first_failed_at: existing?.first_failed_at ?? now,
1378
+ last_failed_at: now,
1379
+ last_error: message
1380
+ };
1381
+ if (failure.attempts >= MAX_FAILED_ATTEMPTS) {
1382
+ state.dead_letters[partId] = failure;
1383
+ delete state.failed_parts[partId];
1384
+ progress.dead_lettered++;
1385
+ } else {
1386
+ state.failed_parts[partId] = failure;
1387
+ }
1388
+ }
1389
+ function clearPartFailure(state, partId) {
1390
+ delete state.failed_parts[partId];
1391
+ }
1392
+ function rememberSafeRow(rows, row) {
1393
+ rows.push(row);
1394
+ const maxRows = INSERT_REWIND_MAX_ROWS * 4;
1395
+ if (rows.length > maxRows)
1396
+ rows.splice(0, rows.length - maxRows);
1397
+ }
1398
+ function chooseInsertCursor(previousId, rows, useRewind) {
1399
+ const last = rows[rows.length - 1];
1400
+ if (!last || !useRewind || rows.length <= INSERT_REWIND_MAX_ROWS)
1401
+ return last?.id ?? previousId;
1402
+ const maxCreated = rows.reduce((max, row) => Math.max(max, row.time_created), 0);
1403
+ const cutoff = maxCreated - INSERT_REWIND_MS;
1404
+ const timeIndex = rows.findIndex((row) => row.time_created >= cutoff);
1405
+ const rewindIndex = Math.max(timeIndex <= 0 ? rows.length - INSERT_REWIND_MAX_ROWS : timeIndex - 1, rows.length - INSERT_REWIND_MAX_ROWS);
1406
+ return rows[Math.max(0, rewindIndex)]?.id ?? last.id;
1407
+ }
1408
+ function sortedDirtyHints(hints) {
1409
+ return Object.entries(hints).sort((a, b) => a[1].time_updated - b[1].time_updated || a[0].localeCompare(b[0]));
1410
+ }
1411
+ function refreshTouchedSessions(root, touchedSessions) {
788
1412
  for (const sid of touchedSessions) {
789
- const s = getSession(sid);
790
- if (s) {
791
- const dir = join2(root, "by-session", sid);
792
- const fresh = stmt(`
793
- SELECT id, title, project_id, directory, agent, model, cost,
794
- time_created, time_updated, time_archived, parent_id
795
- FROM session WHERE id = ?`).get(sid);
796
- if (fresh)
797
- writeMeta(fresh, dir);
798
- if (fresh)
799
- writeSessionSummaryChannel(fresh, root);
1413
+ const dir = join5(root, "by-session", sid);
1414
+ const fresh = stmt(`
1415
+ SELECT id, title, project_id, directory, agent, model, cost,
1416
+ time_created, time_updated, time_archived, parent_id
1417
+ FROM session WHERE id = ?`).get(sid);
1418
+ if (fresh) {
1419
+ writeMeta(fresh, dir);
1420
+ writeSessionSummaryChannel(fresh, root);
800
1421
  }
801
1422
  }
802
- if (progress.last_cursor)
803
- setLastSync(progress.last_cursor, root);
804
- progress.updates = updates;
805
- progress.inserts = inserts;
806
- return progress;
1423
+ }
1424
+ function applyTombstoneProgress(progress, tombstones) {
1425
+ progress.tombstones_removed_parts = tombstones.removed_parts;
1426
+ progress.tombstones_removed_sessions = tombstones.removed_sessions;
1427
+ }
1428
+ function loadPartById(partId) {
1429
+ return stmt(partSelectSql("WHERE p.id = ?")).get(partId);
1430
+ }
1431
+ function loadPartRowsAfterId(afterId, limit) {
1432
+ if (!afterId)
1433
+ return stmt(`${partSelectSql("")} ORDER BY p.id ASC LIMIT ?`).all(limit);
1434
+ return stmt(`${partSelectSql("WHERE p.id > ?")} ORDER BY p.id ASC LIMIT ?`).all(afterId, limit);
1435
+ }
1436
+ function loadSessionPartRows(sessionId, afterId, limit) {
1437
+ if (!afterId) {
1438
+ return stmt(`${partSelectSql("WHERE p.session_id = ?")} ORDER BY p.id ASC LIMIT ?`).all(sessionId, limit);
1439
+ }
1440
+ return stmt(`${partSelectSql("WHERE p.session_id = ? AND p.id > ?")} ORDER BY p.id ASC LIMIT ?`).all(sessionId, afterId, limit);
1441
+ }
1442
+ function loadDirtySessionsAfter(cursor, limit) {
1443
+ if (!cursor) {
1444
+ return stmt(`SELECT id, time_updated FROM session ORDER BY time_updated ASC, id ASC LIMIT ?`).all(limit);
1445
+ }
1446
+ return stmt(`
1447
+ SELECT id, time_updated
1448
+ FROM session
1449
+ WHERE (time_updated > ? OR (time_updated = ? AND id > ?))
1450
+ ORDER BY time_updated ASC, id ASC
1451
+ LIMIT ?`).all(cursor.ts, cursor.ts, cursor.id, limit);
1452
+ }
1453
+ function partSelectSql(where) {
1454
+ return `
1455
+ SELECT p.id, p.session_id, p.message_id, p.time_created, p.time_updated,
1456
+ p.data, LENGTH(p.data) AS data_bytes,
1457
+ json_extract(m.data,'$.role') AS role
1458
+ FROM part p
1459
+ LEFT JOIN message m ON m.id = p.message_id
1460
+ ${where}`;
1461
+ }
1462
+ function timeExceeded(start, budgetMs) {
1463
+ return budgetMs !== undefined && Date.now() - start > budgetMs;
1464
+ }
1465
+ function reportProgress(progress, onProgress, heartbeat) {
1466
+ heartbeat();
1467
+ if (!onProgress)
1468
+ return;
1469
+ const processed = progress.exported + progress.skipped_nontext + progress.skipped_oversize + progress.failed;
1470
+ if (processed > 0 && processed % 5000 === 0)
1471
+ onProgress(progress);
1472
+ }
1473
+ function errorMessage(error) {
1474
+ return error instanceof Error ? error.message : String(error);
807
1475
  }
808
1476
 
809
1477
  // src/lib/ck.ts
810
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
1478
+ import { existsSync as existsSync6, readFileSync as readFileSync3, statSync as statSync3 } from "fs";
811
1479
  function defaultCkCandidates() {
812
1480
  const home = process.env.HOME ?? "";
813
1481
  const candidates = [
814
- process.env.OPENCODE_SESSIONS_EXPLORER_CK_BIN,
815
1482
  home ? `${home}/.cargo/bin/ck` : null,
816
1483
  "/usr/local/bin/ck",
817
1484
  "/opt/homebrew/bin/ck",
@@ -820,18 +1487,20 @@ function defaultCkCandidates() {
820
1487
  return candidates;
821
1488
  }
822
1489
  function locateCk() {
1490
+ if (process.env.OPENCODE_SESSIONS_EXPLORER_CK_BIN)
1491
+ return process.env.OPENCODE_SESSIONS_EXPLORER_CK_BIN;
823
1492
  for (const c of defaultCkCandidates()) {
824
- if (c.startsWith("/") && existsSync3(c))
1493
+ if (c.startsWith("/") && existsSync6(c))
825
1494
  return c;
826
1495
  }
827
1496
  return "ck";
828
1497
  }
829
1498
  function ckIndexPresent(root = exportRoot()) {
830
1499
  const manifestPath = `${root}/.ck/manifest.json`;
831
- if (!existsSync3(manifestPath))
1500
+ if (!existsSync6(manifestPath))
832
1501
  return { present: false, embedded_chunks: null };
833
1502
  try {
834
- const m = JSON.parse(readFileSync2(manifestPath, "utf8"));
1503
+ const m = JSON.parse(readFileSync3(manifestPath, "utf8"));
835
1504
  return { present: true, embedded_chunks: m?.totals?.embedded_chunks ?? null };
836
1505
  } catch {
837
1506
  return { present: true, embedded_chunks: null };
@@ -853,7 +1522,7 @@ function fail(name, detail, fix) {
853
1522
  var dbPath = null;
854
1523
  try {
855
1524
  dbPath = locateDb();
856
- pass("OpenCode DB", `${dbPath} (${(statSync2(dbPath).size / 1024 / 1024).toFixed(1)} MB)`);
1525
+ pass("OpenCode DB", `${dbPath} (${(statSync4(dbPath).size / 1024 / 1024).toFixed(1)} MB)`);
857
1526
  } catch (e) {
858
1527
  fail("OpenCode DB", e.message, "Set $OPENCODE_SESSIONS_EXPLORER_DB to the absolute path of opencode.db, or install OpenCode and run it at least once.");
859
1528
  }
@@ -880,14 +1549,14 @@ if (dbPath) {
880
1549
  }
881
1550
  }
882
1551
  var root = exportRoot();
883
- if (existsSync4(root)) {
884
- const bySession = join3(root, "by-session");
885
- if (existsSync4(bySession)) {
886
- const sessionDirs = readdirSync2(bySession).filter((f) => f.startsWith("ses_")).length;
1552
+ if (existsSync7(root)) {
1553
+ const bySession = join6(root, "by-session");
1554
+ if (existsSync7(bySession)) {
1555
+ const sessionDirs = readdirSync3(bySession).filter((f) => f.startsWith("ses_")).length;
887
1556
  pass("Export tree", `${root} (${sessionDirs} session dirs)`);
888
- const byChannel = join3(root, "by-channel");
889
- if (existsSync4(byChannel)) {
890
- const channels = readdirSync2(byChannel).filter((f) => !f.startsWith(".")).length;
1557
+ const byChannel = join6(root, "by-channel");
1558
+ if (existsSync7(byChannel)) {
1559
+ const channels = readdirSync3(byChannel).filter((f) => !f.startsWith(".")).length;
891
1560
  if (channelExportComplete(root))
892
1561
  pass("Channel views", `${channels} channel dirs (complete)`);
893
1562
  else
@@ -907,7 +1576,7 @@ try {
907
1576
  if (r.status === 0) {
908
1577
  const ver = (r.stdout ?? "").trim();
909
1578
  pass("ck CLI", `${ckBin} (${ver})`);
910
- if (existsSync4(root)) {
1579
+ if (existsSync7(root)) {
911
1580
  const idx = ckIndexPresent(root);
912
1581
  if (idx.present)
913
1582
  pass("ck index", idx.embedded_chunks != null ? `present (${idx.embedded_chunks} embedded chunks)` : "present");
@@ -924,9 +1593,9 @@ var toolOutputDir = (() => {
924
1593
  if (process.env.OPENCODE_SESSIONS_EXPLORER_TOOL_OUTPUT_DIR)
925
1594
  return process.env.OPENCODE_SESSIONS_EXPLORER_TOOL_OUTPUT_DIR;
926
1595
  const home = process.env.HOME ?? "";
927
- return join3(home, ".local/share/opencode/tool-output");
1596
+ return join6(home, ".local/share/opencode/tool-output");
928
1597
  })();
929
- if (existsSync4(toolOutputDir))
1598
+ if (existsSync7(toolOutputDir))
930
1599
  pass("tool-output dir", toolOutputDir);
931
1600
  else
932
1601
  warn("tool-output dir", `${toolOutputDir} not yet created`, "Will be auto-created by OpenCode when needed.");