@wrongstack/core 0.273.0 → 0.274.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.
Files changed (64) hide show
  1. package/dist/{agent-bridge-BZ2enORi.d.ts → agent-bridge-DFo21wmY.d.ts} +1 -1
  2. package/dist/{agent-subagent-runner-ehb4xGvd.d.ts → agent-subagent-runner-BwmkIDEd.d.ts} +7 -7
  3. package/dist/{brain-BxN2k2HP.d.ts → brain-gfZX3the.d.ts} +11 -5
  4. package/dist/{provider-model-resolve-DFd3IPpw.d.ts → codex-catalog-CooZ6mOl.d.ts} +47 -4
  5. package/dist/{compactor-72ug-ZRB.d.ts → compactor-riTOds0f.d.ts} +1 -1
  6. package/dist/{config-C8IYxlO8.d.ts → config-BxcrDzri.d.ts} +60 -4
  7. package/dist/{context-Dw55zZ_Q.d.ts → context-DERiLofu.d.ts} +60 -1
  8. package/dist/coordination/index.d.ts +27 -16
  9. package/dist/coordination/index.js +1641 -1398
  10. package/dist/coordination/index.js.map +1 -1
  11. package/dist/defaults/index.d.ts +26 -26
  12. package/dist/defaults/index.js +2154 -1466
  13. package/dist/defaults/index.js.map +1 -1
  14. package/dist/execution/index.d.ts +15 -15
  15. package/dist/execution/index.js +77 -9
  16. package/dist/execution/index.js.map +1 -1
  17. package/dist/execution/prompt-enhancer.d.ts +1 -1
  18. package/dist/extension/index.d.ts +6 -6
  19. package/dist/{global-mailbox-C9dsc9Y_.d.ts → global-mailbox-Cr8TW4t_.d.ts} +1 -1
  20. package/dist/{goal-preamble-NhflDjYb.d.ts → goal-preamble-CEhROp1e.d.ts} +9 -9
  21. package/dist/{goal-store-Cx363x7Z.d.ts → goal-store-xNSVxmpV.d.ts} +1 -1
  22. package/dist/hq/index.d.ts +5 -5
  23. package/dist/{index-B7fHDt0B.d.ts → index-00KPKAlm.d.ts} +5 -5
  24. package/dist/{index-BbVprU-9.d.ts → index-pDBSBE1r.d.ts} +2 -2
  25. package/dist/index.d.ts +72 -45
  26. package/dist/index.js +2840 -1769
  27. package/dist/index.js.map +1 -1
  28. package/dist/infrastructure/index.d.ts +6 -6
  29. package/dist/kernel/index.d.ts +11 -11
  30. package/dist/kernel/index.js.map +1 -1
  31. package/dist/{mcp-servers-B6fSRNC1.d.ts → mcp-servers-BIwRiOxe.d.ts} +3 -3
  32. package/dist/models/index.d.ts +5 -5
  33. package/dist/models/index.js +40 -2
  34. package/dist/models/index.js.map +1 -1
  35. package/dist/{models-registry-4C6Wr91w.d.ts → models-registry-8OorW51H.d.ts} +1 -1
  36. package/dist/{multi-agent-coordinator-q1skFeNP.d.ts → multi-agent-coordinator-CNx48Zoz.d.ts} +1 -1
  37. package/dist/{null-fleet-bus-C9rrgQwc.d.ts → null-fleet-bus-B4ZUJYL6.d.ts} +6 -6
  38. package/dist/observability/index.d.ts +2 -2
  39. package/dist/{parallel-eternal-engine-CtXly2Sf.d.ts → parallel-eternal-engine-rsIclDqO.d.ts} +9 -9
  40. package/dist/{path-resolver-Bim6G5Jz.d.ts → path-resolver-BqU-fwzD.d.ts} +3 -3
  41. package/dist/{permission-CC7XFYWG.d.ts → permission-Dgs3v-Xq.d.ts} +1 -1
  42. package/dist/{permission-policy-cYR4RJmw.d.ts → permission-policy-Dtht2k0e.d.ts} +2 -2
  43. package/dist/{pipeline-CNVKuQDQ.d.ts → pipeline-B-dpCFYS.d.ts} +2 -2
  44. package/dist/{plan-templates-C4wXMmiM.d.ts → plan-templates-B7MxyY2o.d.ts} +58 -5
  45. package/dist/{provider-runner-BpM0mdBE.d.ts → provider-runner-DrmpBE5l.d.ts} +3 -3
  46. package/dist/{retry-policy-BV7nzeAd.d.ts → retry-policy-hYxsm10a.d.ts} +1 -1
  47. package/dist/sdd/index.d.ts +12 -10
  48. package/dist/sdd/index.js +7 -0
  49. package/dist/sdd/index.js.map +1 -1
  50. package/dist/{secret-vault-eMBKfheR.d.ts → secret-vault-DnqIFhPF.d.ts} +1 -1
  51. package/dist/security/index.d.ts +5 -5
  52. package/dist/{selector-C4ORTOid.d.ts → selector-D21RjDIg.d.ts} +1 -1
  53. package/dist/{session-event-bridge-CeNpUL9w.d.ts → session-event-bridge-Dt54CTvq.d.ts} +1 -1
  54. package/dist/{session-reader-BepLSnGL.d.ts → session-reader-CceH13Kq.d.ts} +5 -4
  55. package/dist/storage/index.d.ts +46 -12
  56. package/dist/storage/index.js +2281 -1476
  57. package/dist/storage/index.js.map +1 -1
  58. package/dist/tools/index.d.ts +2 -2
  59. package/dist/types/index.d.ts +19 -19
  60. package/dist/types/index.js +162 -42
  61. package/dist/types/index.js.map +1 -1
  62. package/dist/utils/index.d.ts +2 -2
  63. package/dist/{worktree-manager-BDuXTaWL.d.ts → worktree-manager-DUfBbKzk.d.ts} +1 -1
  64. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { createReadStream } from 'fs';
2
- import * as fsp from 'fs/promises';
2
+ import * as fsp2 from 'fs/promises';
3
3
  import * as path2 from 'path';
4
4
  import { createInterface } from 'readline';
5
5
  import { randomBytes, randomUUID, createHash } from 'crypto';
@@ -9,16 +9,16 @@ import { hostname } from 'os';
9
9
  // src/storage/session-store.ts
10
10
  async function atomicWrite(targetPath, content, opts = {}) {
11
11
  const dir = path2.dirname(targetPath);
12
- await fsp.mkdir(dir, { recursive: true });
12
+ await fsp2.mkdir(dir, { recursive: true });
13
13
  const tmp = path2.join(dir, `.${path2.basename(targetPath)}.${randomBytes(6).toString("hex")}.tmp`);
14
14
  try {
15
15
  if (typeof content === "string") {
16
- await fsp.writeFile(tmp, content, { flag: "wx", encoding: opts.encoding ?? "utf8" });
16
+ await fsp2.writeFile(tmp, content, { flag: "wx", encoding: opts.encoding ?? "utf8" });
17
17
  } else {
18
- await fsp.writeFile(tmp, content, { flag: "wx" });
18
+ await fsp2.writeFile(tmp, content, { flag: "wx" });
19
19
  }
20
20
  try {
21
- const fh = await fsp.open(tmp, "r+");
21
+ const fh = await fsp2.open(tmp, "r+");
22
22
  try {
23
23
  await fh.sync();
24
24
  } finally {
@@ -28,29 +28,29 @@ async function atomicWrite(targetPath, content, opts = {}) {
28
28
  }
29
29
  let mode;
30
30
  try {
31
- const stat7 = await fsp.stat(targetPath);
32
- mode = stat7.mode & 511;
31
+ const stat10 = await fsp2.stat(targetPath);
32
+ mode = stat10.mode & 511;
33
33
  } catch {
34
34
  mode = opts.mode;
35
35
  }
36
36
  if (mode !== void 0) {
37
- await fsp.chmod(tmp, mode);
37
+ await fsp2.chmod(tmp, mode);
38
38
  }
39
39
  await renameWithRetry(tmp, targetPath);
40
40
  } catch (err) {
41
41
  try {
42
- await fsp.unlink(tmp);
42
+ await fsp2.unlink(tmp);
43
43
  } catch {
44
44
  }
45
45
  throw err;
46
46
  }
47
47
  }
48
48
  async function ensureDir(dir) {
49
- await fsp.mkdir(dir, { recursive: true });
49
+ await fsp2.mkdir(dir, { recursive: true });
50
50
  }
51
51
  async function withFileLock(targetPath, fn, opts = {}) {
52
52
  const dir = path2.dirname(targetPath);
53
- await fsp.mkdir(dir, { recursive: true });
53
+ await fsp2.mkdir(dir, { recursive: true });
54
54
  const lockPath = path2.join(dir, `.${path2.basename(targetPath)}.lock`);
55
55
  const timeoutMs = opts.timeoutMs ?? 5e3;
56
56
  const staleMs = opts.staleMs ?? 3e4;
@@ -58,20 +58,20 @@ async function withFileLock(targetPath, fn, opts = {}) {
58
58
  let handle;
59
59
  for (; ; ) {
60
60
  try {
61
- handle = await fsp.open(lockPath, "wx");
61
+ handle = await fsp2.open(lockPath, "wx");
62
62
  await handle.writeFile(`${process.pid}:${Date.now()}`);
63
63
  break;
64
64
  } catch (err) {
65
65
  const code = err.code;
66
66
  if (code === "ENOENT") {
67
- await fsp.mkdir(dir, { recursive: true });
67
+ await fsp2.mkdir(dir, { recursive: true });
68
68
  continue;
69
69
  }
70
70
  if (code !== "EEXIST") throw err;
71
71
  try {
72
- const stat7 = await fsp.stat(lockPath);
73
- if (Date.now() - stat7.mtimeMs > staleMs) {
74
- await fsp.unlink(lockPath);
72
+ const stat10 = await fsp2.stat(lockPath);
73
+ if (Date.now() - stat10.mtimeMs > staleMs) {
74
+ await fsp2.unlink(lockPath);
75
75
  continue;
76
76
  }
77
77
  } catch {
@@ -91,7 +91,7 @@ async function withFileLock(targetPath, fn, opts = {}) {
91
91
  } catch {
92
92
  }
93
93
  try {
94
- await fsp.unlink(lockPath);
94
+ await fsp2.unlink(lockPath);
95
95
  } catch {
96
96
  }
97
97
  }
@@ -99,14 +99,14 @@ async function withFileLock(targetPath, fn, opts = {}) {
99
99
  var TRANSIENT_RENAME_CODES = /* @__PURE__ */ new Set(["EPERM", "EBUSY", "EACCES", "ENOTEMPTY"]);
100
100
  async function renameWithRetry(from, to) {
101
101
  if (process.platform !== "win32") {
102
- await fsp.rename(from, to);
102
+ await fsp2.rename(from, to);
103
103
  return;
104
104
  }
105
105
  const delays = [10, 25, 60, 120, 250];
106
106
  let lastErr;
107
107
  for (let i = 0; i <= delays.length; i++) {
108
108
  try {
109
- await fsp.rename(from, to);
109
+ await fsp2.rename(from, to);
110
110
  return;
111
111
  } catch (err) {
112
112
  lastErr = err;
@@ -240,7 +240,7 @@ function envFlag(value) {
240
240
  return !/^(0|false|no|off)$/i.test(value.trim());
241
241
  }
242
242
  var COLOR = isColorTty();
243
- var wrap = (open7, close) => (s) => COLOR ? `\x1B[${open7}m${s}\x1B[${close}m` : s;
243
+ var wrap = (open8, close) => (s) => COLOR ? `\x1B[${open8}m${s}\x1B[${close}m` : s;
244
244
  var color = {
245
245
  reset: wrap("0", "0"),
246
246
  bold: wrap("1", "22"),
@@ -432,1456 +432,1674 @@ function resolveWstackPaths(opts) {
432
432
  projectStatus: (projectHash2) => path2.join(globalRoot, "projects", projectHash2, "status.json")
433
433
  };
434
434
  }
435
- function sanitizeModel(model) {
436
- return model.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
437
- }
438
- function generateSessionId(startedAt, model) {
439
- const date = startedAt.slice(0, 10);
440
- const time = startedAt.slice(11, 19).replace(/:/g, "-");
441
- const suffix = randomBytes(2).toString("hex");
442
- const modelPart = model ? `_${sanitizeModel(model)}` : "";
443
- return `${date}/${time}Z${modelPart}_${suffix}`;
435
+
436
+ // src/storage/session-helpers.ts
437
+ function userInputTitle(content) {
438
+ const text = typeof content === "string" ? content : content.filter((b) => b.type === "text").map((b) => b.text).join(" ");
439
+ return (text || "(non-text input)").slice(0, 60);
444
440
  }
445
441
 
446
- // src/storage/session-store.ts
447
- var DefaultSessionStore = class _DefaultSessionStore {
448
- dir;
442
+ // src/storage/file-session-writer.ts
443
+ var FileSessionWriter = class _FileSessionWriter {
444
+ constructor(id, handle, startedAt, meta, events, opts = {}, traceId) {
445
+ this.id = id;
446
+ this.handle = handle;
447
+ this.startedAt = startedAt;
448
+ this.meta = meta;
449
+ this.events = events;
450
+ this.resumed = opts.resumed ?? false;
451
+ this.manifestFile = opts.dir ? path2.join(opts.dir, `${path2.basename(id)}.summary.json`) : "";
452
+ this.filePath = opts.filePath ?? "";
453
+ this.secretScrubber = opts.secretScrubber;
454
+ this.onCloseCb = opts.onClose;
455
+ this.summary = {
456
+ id,
457
+ title: "(empty session)",
458
+ startedAt,
459
+ model: meta.model ?? "unknown",
460
+ provider: meta.provider ?? "unknown",
461
+ tokenTotal: 0
462
+ };
463
+ this.traceId = traceId;
464
+ }
465
+ id;
466
+ handle;
467
+ startedAt;
468
+ meta;
449
469
  events;
450
- secretScrubber;
470
+ closed = false;
471
+ closePromise = null;
472
+ manifestFile;
473
+ summary;
474
+ tokenIn = 0;
475
+ tokenOut = 0;
476
+ filePath;
477
+ get transcriptPath() {
478
+ return this.filePath || void 0;
479
+ }
451
480
  /**
452
- * In-memory cache for load() results, keyed by session ID. The cache is
453
- * invalidated when the file's mtimeMs or size changes (indicating the
454
- * file was written to). This eliminates redundant full-file reads and
455
- * JSON parses when the same session is loaded multiple times within the
456
- * store's lifetime (e.g., webui session detail views, list() fallbacks).
457
- *
458
- * Max size is capped to prevent unbounded memory growth in long-running
459
- * processes. When the limit is reached, the oldest entry is evicted.
481
+ * Lazy session_start/session_resumed init, shared by all appenders.
482
+ * A single promise (not a boolean) so a second append racing the first
483
+ * can't push its event into the buffer BEFORE the first append's event —
484
+ * every appender awaits the same init and resumes in FIFO call order.
460
485
  */
461
- _loadCache = /* @__PURE__ */ new Map();
462
- _indexCache = null;
463
- static LOAD_CACHE_MAX_ENTRIES = 50;
464
- static LIST_SCAN_CONCURRENCY = 32;
465
- constructor(opts) {
466
- this.dir = opts.dir;
467
- this.events = opts.events;
468
- this.secretScrubber = opts.secretScrubber;
486
+ initPromise = null;
487
+ ensureInit() {
488
+ if (!this.initPromise) this.initPromise = this.writeSessionStartLazy();
489
+ return this.initPromise;
490
+ }
491
+ resumed;
492
+ appendFailCount = 0;
493
+ lastAppendWarnAt = 0;
494
+ secretScrubber;
495
+ onCloseCb;
496
+ /** Implements SessionWriter.traceId — propagated from ContextInit.traceId. */
497
+ traceId;
498
+ // ── Write buffer — batches events to reduce per-event disk I/O ──────────────
499
+ //
500
+ // Every append() pushes the scrubbed event into an in-memory buffer instead
501
+ // of calling handle.appendFile() synchronously. The buffer flushes to disk
502
+ // when it reaches FLUSH_SIZE events OR after FLUSH_INTERVAL_MS of inactivity.
503
+ // This cuts the number of disk writes by ~95% without changing the on-disk
504
+ // format — the JSONL is still one JSON object per line.
505
+ writeBuffer = [];
506
+ flushTimer = null;
507
+ static FLUSH_INTERVAL_MS = 500;
508
+ static FLUSH_SIZE = 50;
509
+ // ── Write serialization ─────────────────────────────────────────────────────
510
+ //
511
+ // All disk writes are funneled through a FIFO promise chain. Without it,
512
+ // a timer-driven flush racing an explicit flush()/close() issues two
513
+ // concurrent appendFile() calls on the shared O_APPEND handle — the kernel
514
+ // may complete them out of order (chronology breaks) or, for large
515
+ // batches, interleave partial writes (torn JSONL lines). The chain keeps
516
+ // exactly one write in flight; failures don't break the chain.
517
+ writeChain = Promise.resolve();
518
+ /** Enqueue a write on the FIFO chain. Resolves/rejects with that write. */
519
+ enqueueWrite(data) {
520
+ const write = this.writeChain.then(() => this.handle.appendFile(data, "utf8"));
521
+ this.writeChain = write.then(
522
+ () => void 0,
523
+ () => void 0
524
+ );
525
+ return write;
469
526
  }
527
+ // ── Enriched summary tracking ───────────────────────────────────────────────
528
+ iterationCount = 0;
529
+ toolCallCount = 0;
530
+ toolErrorCount = 0;
531
+ toolBreakdown = {};
532
+ fileChangeCount = 0;
533
+ compactionCount = 0;
534
+ outcome = void 0;
470
535
  /**
471
- * Clear the load() cache. Useful for testing or when the caller knows
472
- * the file has changed externally (e.g., another process wrote to it).
536
+ * Scrub secrets out of conversation-turn events before they are observed
537
+ * for the summary, written to the JSONL log, or surfaced on resume. Only
538
+ * `user_input` / `llm_response` carry free-form user/model text; other event
539
+ * types either have no secret-bearing content or are already scrubbed
540
+ * upstream (tool results). Returns the event unchanged when no scrubber is
541
+ * configured.
473
542
  */
474
- clearLoadCache(sessionId) {
475
- if (sessionId !== void 0) {
476
- this._loadCache.delete(sessionId);
477
- } else {
478
- this._loadCache.clear();
543
+ scrubEvent(event) {
544
+ const s = this.secretScrubber;
545
+ if (!s) return event;
546
+ if (event.type === "user_input") {
547
+ return {
548
+ ...event,
549
+ content: typeof event.content === "string" ? s.scrub(event.content) : s.scrubObject(event.content)
550
+ };
551
+ }
552
+ if (event.type === "llm_response") {
553
+ return { ...event, content: s.scrubObject(event.content) };
479
554
  }
555
+ return event;
480
556
  }
481
- // ── Storage event helpers ───────────────────────────────────────────────────
482
- emitRead(sessionId, filePath, operation, outcome, durationMs, error) {
483
- this.events?.emit("storage.read", {
484
- sessionId,
485
- store: "session",
486
- filePath,
487
- operation,
488
- outcome,
489
- durationMs,
490
- ...error !== void 0 ? { error } : {}
491
- });
557
+ pendingFileSnapshots = [];
558
+ /** Tracks open tool_use IDs during the current run to serialize on close for resume. */
559
+ openToolUses = /* @__PURE__ */ new Set();
560
+ recordFileChange(input) {
561
+ this.pendingFileSnapshots.push(input);
492
562
  }
493
- emitWrite(sessionId, filePath, operation, outcome, durationMs, eventCount, error) {
494
- this.events?.emit("storage.write", {
495
- sessionId,
496
- store: "session",
497
- filePath,
498
- operation,
499
- outcome,
500
- durationMs,
501
- ...eventCount !== void 0 ? { eventCount } : {},
502
- ...error !== void 0 ? { error } : {}
503
- });
563
+ get pendingToolUses() {
564
+ return Array.from(this.openToolUses);
504
565
  }
505
- emitError(sessionId, filePath, operation, error, recoverable) {
506
- this.events?.emit("storage.error", {
507
- sessionId,
508
- store: "session",
509
- filePath,
510
- operation,
511
- error,
512
- recoverable
513
- });
566
+ async writeSessionStartLazy() {
567
+ const record = `${JSON.stringify({
568
+ type: this.resumed ? "session_resumed" : "session_start",
569
+ ts: this.startedAt,
570
+ id: this.id,
571
+ model: this.meta.model ?? "unknown",
572
+ provider: this.meta.provider ?? "unknown"
573
+ })}
574
+ `;
575
+ try {
576
+ await this.enqueueWrite(record);
577
+ } catch {
578
+ }
514
579
  }
515
- /** Absolute path to the session index file. */
516
- get indexFile() {
517
- return path2.join(this.dir, "_index.jsonl");
580
+ async append(event) {
581
+ if (this.closed) return;
582
+ await this.ensureInit();
583
+ const scrubbed = this.scrubEvent(event);
584
+ this.observeForSummary(scrubbed);
585
+ this.writeBuffer.push(scrubbed);
586
+ if (this.writeBuffer.length >= _FileSessionWriter.FLUSH_SIZE) {
587
+ if (this.flushTimer) {
588
+ clearTimeout(this.flushTimer);
589
+ this.flushTimer = null;
590
+ }
591
+ await this.flushBuffer();
592
+ } else {
593
+ this.scheduleFlush();
594
+ }
518
595
  }
519
- /** Join session ID to its absolute path within the store directory. */
520
- sessionPath(id, ext) {
521
- return path2.join(this.dir, `${id}${ext}`);
596
+ async appendBatch(events) {
597
+ if (this.closed || events.length === 0) return;
598
+ await this.ensureInit();
599
+ for (const event of events) {
600
+ const scrubbed = this.scrubEvent(event);
601
+ this.observeForSummary(scrubbed);
602
+ this.writeBuffer.push(scrubbed);
603
+ }
604
+ if (this.writeBuffer.length >= _FileSessionWriter.FLUSH_SIZE) {
605
+ if (this.flushTimer) {
606
+ clearTimeout(this.flushTimer);
607
+ this.flushTimer = null;
608
+ }
609
+ await this.flushBuffer();
610
+ } else {
611
+ this.scheduleFlush();
612
+ }
522
613
  }
523
614
  /**
524
- * Ensure the directory implied by the session ID exists. When the ID
525
- * contains a date prefix like `2026-06-06/...`, this creates the date
526
- * subdirectory so sessions group naturally by day.
615
+ * Flush buffered events to disk immediately. Critical events
616
+ * (user_input, llm_response) call this so they survive SIGKILL/crash
617
+ * instead of sitting in the in-memory buffer for up to 500ms.
618
+ *
619
+ * Idempotent — cancels any pending timer and writes whatever has
620
+ * accumulated in the buffer. Safe to call even when the buffer
621
+ * is empty (no-op).
527
622
  */
528
- async ensureShardDir(id) {
529
- const dirPath = path2.dirname(path2.join(this.dir, id));
530
- await ensureDir(dirPath);
531
- return dirPath;
623
+ async flush() {
624
+ if (this.flushTimer) {
625
+ clearTimeout(this.flushTimer);
626
+ this.flushTimer = null;
627
+ }
628
+ await this.flushBuffer();
532
629
  }
533
- async create(meta) {
534
- const startedAt = (/* @__PURE__ */ new Date()).toISOString();
535
- const id = meta.id && meta.id.length > 0 ? meta.id : generateSessionId(startedAt, meta.model ?? meta.provider);
536
- const shardDir = await this.ensureShardDir(id);
537
- const file = path2.join(shardDir, `${path2.basename(id)}.jsonl`);
538
- const t0 = Date.now();
539
- let handle;
540
- try {
541
- handle = await fsp.open(file, "a", 384);
542
- } catch (err) {
543
- this.emitError(id, file, "create", toErrorMessage(err), false);
544
- throw new Error(
545
- `Failed to open session file: ${toErrorMessage(err)}`,
546
- { cause: err }
547
- );
548
- }
549
- try {
550
- const writer = new FileSessionWriter(id, handle, startedAt, meta, this.events, {
551
- dir: shardDir,
552
- filePath: file,
553
- secretScrubber: this.secretScrubber,
554
- onClose: (s) => this.appendToIndex(s)
630
+ /** Schedule a deferred flush. No-op if a timer is already pending. */
631
+ scheduleFlush() {
632
+ if (this.flushTimer) return;
633
+ this.flushTimer = setTimeout(() => {
634
+ this.flushTimer = null;
635
+ this.flushBuffer().catch(() => {
555
636
  });
556
- this.emitWrite(id, file, "create", "success", Date.now() - t0);
557
- return writer;
558
- } catch (err) {
559
- await handle.close().catch((e) => console.warn(JSON.stringify({
560
- level: "warn",
561
- event: "session_store.handle_close_failed",
562
- message: e instanceof Error ? e.message : String(e),
563
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
564
- })));
565
- this.emitError(id, file, "create", toErrorMessage(err), true);
566
- throw err;
567
- }
568
- }
569
- async resume(id) {
570
- const file = this.sessionPath(id, ".jsonl");
571
- const t0 = Date.now();
572
- const data = await this.load(id);
573
- let handle;
574
- try {
575
- handle = await fsp.open(file, "a", 384);
576
- } catch (err) {
577
- this.emitError(id, file, "resume", toErrorMessage(err), false);
578
- throw new Error(
579
- `Failed to open session "${id}" for append: ${toErrorMessage(err)}`,
580
- { cause: err }
581
- );
582
- }
583
- try {
584
- const writer = new FileSessionWriter(
585
- id,
586
- handle,
587
- (/* @__PURE__ */ new Date()).toISOString(),
588
- {
589
- id,
590
- model: data.metadata.model,
591
- provider: data.metadata.provider
592
- },
593
- this.events,
594
- {
595
- resumed: true,
596
- // Shard directory (sessions/<date>/) — must match create() so the
597
- // .summary.json sidecar lands next to the JSONL instead of the
598
- // sessions root (where summaryFor() would never find it).
599
- dir: path2.dirname(file),
600
- filePath: file,
601
- secretScrubber: this.secretScrubber,
602
- onClose: (s) => this.appendToIndex(s)
603
- }
604
- );
605
- this.emitWrite(id, file, "resume", "success", Date.now() - t0);
606
- return { writer, data };
607
- } catch (err) {
608
- await handle.close().catch((e) => console.warn(JSON.stringify({
609
- level: "warn",
610
- event: "session_store.handle_close_failed",
611
- message: e instanceof Error ? e.message : String(e),
612
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
613
- })));
614
- this.emitError(id, file, "resume", toErrorMessage(err), true);
615
- throw err;
616
- }
637
+ }, _FileSessionWriter.FLUSH_INTERVAL_MS);
617
638
  }
618
- async load(id) {
619
- const file = this.sessionPath(id, ".jsonl");
639
+ /**
640
+ * Flush all buffered events to disk as a single appendFile call.
641
+ * Errors use the same throttled-warning pattern the old per-event
642
+ * append path used — one warning every 5s with a suppressed count.
643
+ * On failure the buffer is cleared (events are best-effort, same as
644
+ * the old per-event path where a failed write was silently dropped).
645
+ */
646
+ async flushBuffer() {
647
+ if (this.writeBuffer.length === 0) return;
648
+ const eventCount = this.writeBuffer.length;
649
+ const batch = this.writeBuffer.map((e) => JSON.stringify(e)).join("\n") + "\n";
650
+ this.writeBuffer = [];
620
651
  const t0 = Date.now();
621
652
  let outcome = "success";
622
653
  let errorMsg;
623
- let cacheHit = false;
624
654
  try {
625
- const s = await fsp.stat(file);
626
- const stat7 = { mtimeMs: s.mtimeMs, size: s.size };
627
- const cached = this._loadCache.get(id);
628
- if (cached && cached.mtimeMs === stat7.mtimeMs && cached.size === stat7.size) {
629
- cacheHit = true;
630
- this._loadCache.delete(id);
631
- this._loadCache.set(id, cached);
632
- return cached.data;
633
- }
634
- const raw = await fsp.readFile(file, "utf8");
635
- const lines = raw.split("\n").filter((l) => l.trim());
636
- const events = [];
637
- let sessionStartEvent;
638
- let sessionEndEvent;
639
- let sessionModel;
640
- let sessionProvider;
641
- let sessionPendingToolUses;
642
- const messages = [];
643
- const openToolUses = /* @__PURE__ */ new Set();
644
- let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
645
- for (const line of lines) {
646
- try {
647
- const parsed = JSON.parse(line);
648
- if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
649
- const ev = parsed;
650
- events.push(ev);
651
- if (ev.type === "session_start" && !sessionStartEvent) {
652
- sessionStartEvent = ev;
653
- sessionModel = ev.model;
654
- sessionProvider = ev.provider;
655
- }
656
- if (ev.type === "session_end") {
657
- sessionEndEvent = ev;
658
- sessionPendingToolUses = ev.pendingToolUses;
659
- }
660
- if (ev.type === "user_input") {
661
- openToolUses.clear();
662
- messages.push({ role: "user", content: ev.content, ts: ev.ts });
663
- } else if (ev.type === "llm_response") {
664
- messages.push({ role: "assistant", content: ev.content, ts: ev.ts });
665
- for (const b of ev.content) {
666
- if (b.type === "tool_use") openToolUses.add(b.id);
667
- }
668
- usage = {
669
- input: usage.input + (ev.usage.input ?? 0),
670
- output: usage.output + (ev.usage.output ?? 0),
671
- cacheRead: (usage.cacheRead ?? 0) + (ev.usage.cacheRead ?? 0),
672
- cacheWrite: (usage.cacheWrite ?? 0) + (ev.usage.cacheWrite ?? 0)
673
- };
674
- } else if (ev.type === "tool_result") {
675
- if (!openToolUses.has(ev.id)) {
676
- this.events?.emit("session.damaged", {
677
- sessionId: id,
678
- detail: `Orphan tool_result "${ev.id}" has no matching tool_use`
679
- });
680
- continue;
681
- }
682
- openToolUses.delete(ev.id);
683
- const resultBlock = {
684
- type: "tool_result",
685
- tool_use_id: ev.id,
686
- content: typeof ev.content === "string" ? ev.content : JSON.stringify(ev.content),
687
- is_error: ev.isError
688
- };
689
- const last = messages[messages.length - 1];
690
- const lastIsToolResultUser = last?.role === "user" && Array.isArray(last.content) && last.content.every((b) => b.type === "tool_result");
691
- if (lastIsToolResultUser && Array.isArray(last.content)) {
692
- last.content.push(resultBlock);
693
- } else {
694
- messages.push({ role: "user", content: [resultBlock], ts: ev.ts });
695
- }
696
- }
697
- }
698
- } catch {
699
- }
700
- }
701
- if (openToolUses.size > 0) {
702
- this.events?.emit("session.damaged", {
703
- sessionId: id,
704
- detail: `${openToolUses.size} tool_use blocks without matching results - replay repaired`
705
- });
706
- }
707
- const repaired = repairToolUseAdjacency(messages);
708
- if (repaired.report.changed) {
709
- this.events?.emit("session.damaged", {
710
- sessionId: id,
711
- detail: `Repaired replay adjacency: removed ${repaired.report.removedToolUses.length} tool_use, ${repaired.report.removedToolResults.length} tool_result, ${repaired.report.removedMessages} empty messages`
712
- });
713
- }
714
- const meta = {
715
- id,
716
- startedAt: sessionStartEvent?.ts ?? (/* @__PURE__ */ new Date(0)).toISOString(),
717
- endedAt: sessionEndEvent?.ts,
718
- model: sessionModel,
719
- provider: sessionProvider,
720
- pendingToolUses: sessionPendingToolUses
721
- };
722
- const toolCallEnds = extractToolCallEnds(events);
723
- const data = { metadata: meta, events, messages: repaired.messages, usage, toolCallEnds };
724
- if (this._loadCache.size >= _DefaultSessionStore.LOAD_CACHE_MAX_ENTRIES) {
725
- const oldest = this._loadCache.keys().next().value;
726
- if (oldest !== void 0) {
727
- this._loadCache.delete(oldest);
728
- }
729
- }
730
- this._loadCache.set(id, { mtimeMs: stat7.mtimeMs, size: stat7.size, data });
731
- return data;
655
+ await this.enqueueWrite(batch);
732
656
  } catch (err) {
733
657
  outcome = "failure";
734
658
  errorMsg = toErrorMessage(err);
735
- throw err;
736
- } finally {
737
- this.emitRead(id, file, "load", outcome, Date.now() - t0, errorMsg);
738
- if (cacheHit) {
739
- this.events?.emit("storage.cache_hit", {
740
- sessionId: id,
741
- store: "session",
742
- filePath: file,
743
- operation: "load",
744
- durationMs: Date.now() - t0
745
- });
659
+ this.appendFailCount += eventCount;
660
+ const now = Date.now();
661
+ if (now - this.lastAppendWarnAt > 5e3) {
662
+ const suppressed = this.appendFailCount - 1;
663
+ const tail = suppressed > 0 ? ` (+${suppressed} suppressed)` : "";
664
+ console.warn(
665
+ "[session] flush failed:",
666
+ toErrorMessage(err),
667
+ tail
668
+ );
669
+ this.lastAppendWarnAt = now;
670
+ this.appendFailCount = 0;
746
671
  }
672
+ } finally {
673
+ this.events?.emit("storage.write", {
674
+ sessionId: this.id,
675
+ store: "session",
676
+ filePath: this.filePath,
677
+ operation: "flush",
678
+ outcome,
679
+ durationMs: Date.now() - t0,
680
+ ...errorMsg !== void 0 ? { error: errorMsg } : {},
681
+ ...eventCount !== void 0 ? { eventCount } : {},
682
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
683
+ });
747
684
  }
748
685
  }
749
- async list(limit = 20) {
750
- try {
751
- await ensureDir(this.dir);
752
- const indexed = await this.readIndex();
753
- if (indexed.length > 0) {
754
- indexed.sort((a, b) => {
755
- if (a.startedAt < b.startedAt) return 1;
756
- if (a.startedAt > b.startedAt) return -1;
757
- return a.id.localeCompare(b.id);
758
- });
759
- return indexed.slice(0, limit);
686
+ observeForSummary(event) {
687
+ if (event.type === "llm_response") {
688
+ for (const block of event.content) {
689
+ if (block.type === "tool_use") this.openToolUses.add(block.id);
760
690
  }
761
- return await this.listFromDirectoryScan(limit);
762
- } catch {
763
- return [];
764
691
  }
765
- }
766
- // ── Session index (_index.jsonl) ─────────────────────────────────────────
767
- //
768
- // One JSON line per closed session, appended atomically on close().
769
- // When a session is deleted, a tombstone {action:"delete",id:"..."} is
770
- // appended. On read, tombstones filter out matching session entries.
771
- // This keeps listing O(lines-in-index) instead of O(files-on-disk).
772
- //
773
- // The index auto-compacts every N appends to prevent unbounded growth
774
- // from tombstones and duplicate entries (resume cycles).
775
- indexAppendCount = 0;
776
- static COMPACT_EVERY = 30;
777
- /** Append a session summary to the index. */
778
- async appendToIndex(summary) {
779
- try {
780
- await ensureDir(this.dir);
781
- const line = JSON.stringify(summary) + "\n";
782
- await fsp.appendFile(this.indexFile, line, "utf8");
783
- this._indexCache = null;
784
- this.indexAppendCount++;
785
- if (this.indexAppendCount >= _DefaultSessionStore.COMPACT_EVERY) {
786
- await this.compactIndex();
787
- this.indexAppendCount = 0;
692
+ if (event.type === "tool_use") {
693
+ this.openToolUses.add(event.id);
694
+ } else if (event.type === "tool_call_start") {
695
+ this.toolCallCount++;
696
+ this.toolBreakdown[event.name] = (this.toolBreakdown[event.name] ?? 0) + 1;
697
+ } else if (event.type === "tool_result") {
698
+ this.openToolUses.delete(event.id);
699
+ if (event.isError) {
700
+ this.toolErrorCount++;
701
+ this.outcome = "error";
788
702
  }
789
- } catch {
703
+ } else if (event.type === "file_snapshot") {
704
+ this.fileChangeCount += event.files.length;
705
+ } else if (event.type === "compaction") {
706
+ this.compactionCount++;
790
707
  }
791
- }
792
- /** Append a tombstone entry for a deleted session. */
793
- async writeTombstone(id) {
794
- try {
795
- await ensureDir(this.dir);
796
- const line = JSON.stringify({ action: "delete", id }) + "\n";
797
- await fsp.appendFile(this.indexFile, line, "utf8");
798
- this._indexCache = null;
799
- this.indexAppendCount++;
800
- } catch {
708
+ if (event.type === "error" || event.type === "provider_error") {
709
+ this.outcome = "error";
710
+ }
711
+ if (event.type === "user_input" && this.summary.title === "(empty session)") {
712
+ this.summary = { ...this.summary, title: userInputTitle(event.content) };
713
+ } else if (event.type === "llm_response") {
714
+ this.tokenIn += event.usage.input;
715
+ this.tokenOut += event.usage.output;
716
+ this.summary = { ...this.summary, tokenTotal: this.tokenIn + this.tokenOut };
717
+ } else if (event.type === "session_end") {
718
+ const total = event.usage.input + event.usage.output;
719
+ if (total > 0) this.summary = { ...this.summary, tokenTotal: total };
720
+ } else if (event.type === "in_flight_start") {
721
+ this.iterationCount++;
801
722
  }
802
723
  }
803
- /**
804
- * Compact the index: read all entries, drop tombstones, deduplicate
805
- * (keep latest per session), and rewrite. Atomic via temp+rename.
806
- */
807
- async compactIndex() {
808
- const t0 = Date.now();
809
- let outcome = "success";
810
- let errorMsg;
724
+ async close() {
725
+ if (this.closePromise) return this.closePromise;
726
+ this.closePromise = this.doClose();
727
+ return this.closePromise;
728
+ }
729
+ async doClose() {
730
+ this.closed = true;
731
+ if (this.flushTimer) {
732
+ clearTimeout(this.flushTimer);
733
+ this.flushTimer = null;
734
+ }
735
+ await this.flushBuffer();
736
+ await this.writeChain;
737
+ this.summary = {
738
+ ...this.summary,
739
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
740
+ iterationCount: this.iterationCount,
741
+ toolCallCount: this.toolCallCount,
742
+ toolErrorCount: this.toolErrorCount,
743
+ fileChangeCount: this.fileChangeCount,
744
+ compactionCount: this.compactionCount > 0 ? this.compactionCount : void 0,
745
+ toolBreakdown: { ...this.toolBreakdown },
746
+ outcome: this.outcome ?? "completed"
747
+ };
748
+ if (this.manifestFile) {
749
+ const t0 = Date.now();
750
+ let outcome = "success";
751
+ let errorMsg;
752
+ try {
753
+ await atomicWrite(this.manifestFile, JSON.stringify(this.summary), { mode: 384 });
754
+ } catch (err) {
755
+ outcome = "failure";
756
+ errorMsg = toErrorMessage(err);
757
+ } finally {
758
+ this.events?.emit("storage.write", {
759
+ sessionId: this.id,
760
+ store: "session",
761
+ filePath: this.manifestFile,
762
+ operation: "close",
763
+ outcome,
764
+ durationMs: Date.now() - t0,
765
+ ...errorMsg !== void 0 ? { error: errorMsg } : {},
766
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
767
+ });
768
+ }
769
+ }
770
+ const idxT0 = Date.now();
771
+ let idxOutcome = "success";
772
+ let idxError;
811
773
  try {
812
- const entries = await this.readIndex();
813
- if (entries.length === 0) return;
814
- const tmp = `${this.indexFile}.compact.tmp`;
815
- const lines = entries.map((s) => JSON.stringify(s)).join("\n") + "\n";
816
- await fsp.writeFile(tmp, lines, "utf8");
817
- await fsp.rename(tmp, this.indexFile);
818
- this._indexCache = null;
774
+ await this.onCloseCb?.(this.summary);
819
775
  } catch (err) {
820
- outcome = "failure";
821
- errorMsg = toErrorMessage(err);
776
+ idxOutcome = "failure";
777
+ idxError = toErrorMessage(err);
822
778
  } finally {
823
- this.emitWrite("~compact~", this.indexFile, "compact", outcome, Date.now() - t0, void 0, errorMsg);
779
+ this.events?.emit("storage.write", {
780
+ sessionId: this.summary.id,
781
+ store: "session",
782
+ filePath: this.filePath,
783
+ operation: "index_append",
784
+ outcome: idxOutcome,
785
+ durationMs: Date.now() - idxT0,
786
+ ...idxError !== void 0 ? { error: idxError } : {},
787
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
788
+ });
824
789
  }
825
- }
826
- /**
827
- * Read the index file and return deduplicated session summaries.
828
- * Entries with a matching tombstone are filtered out.
829
- * Returns empty array when the index doesn't exist or is corrupt.
830
- */
831
- async readIndex() {
832
- let stat7;
833
790
  try {
834
- const s = await fsp.stat(this.indexFile);
835
- stat7 = { mtimeMs: s.mtimeMs, size: s.size };
791
+ await this.handle.close();
836
792
  } catch {
837
- this._indexCache = null;
838
- return [];
839
793
  }
840
- if (this._indexCache !== null && this._indexCache.mtimeMs === stat7.mtimeMs && this._indexCache.size === stat7.size) {
841
- return [...this._indexCache.summaries];
794
+ }
795
+ async writeCheckpoint(promptIndex, promptPreview) {
796
+ const fileCount = this.pendingFileSnapshots.length;
797
+ if (fileCount > 0) {
798
+ await this.writeFileSnapshot(promptIndex, [...this.pendingFileSnapshots]);
799
+ this.pendingFileSnapshots = [];
842
800
  }
843
- let raw;
844
- try {
845
- raw = await fsp.readFile(this.indexFile, "utf8");
846
- } catch {
847
- this._indexCache = null;
848
- return [];
801
+ await this.append({
802
+ type: "checkpoint",
803
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
804
+ promptIndex,
805
+ promptPreview
806
+ });
807
+ this.events?.emit("checkpoint.written", {
808
+ promptIndex,
809
+ promptPreview,
810
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
811
+ fileCount
812
+ });
813
+ }
814
+ async writeFileSnapshot(promptIndex, files) {
815
+ await this.append({
816
+ type: "file_snapshot",
817
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
818
+ promptIndex,
819
+ files
820
+ });
821
+ }
822
+ /**
823
+ * Truncate the session file to the checkpoint with the given promptIndex,
824
+ * removing all events that follow it. Uses a single-pass byte-offset scan
825
+ * so post-checkpoint content is never read or parsed — O(1) memory instead
826
+ * of O(N) JSON.parse calls over the full file.
827
+ */
828
+ async truncateToCheckpoint(targetPromptIndex) {
829
+ if (!this.filePath) return 0;
830
+ if (this.flushTimer) {
831
+ clearTimeout(this.flushTimer);
832
+ this.flushTimer = null;
849
833
  }
850
- const deleted = /* @__PURE__ */ new Set();
851
- const seen = /* @__PURE__ */ new Map();
852
- for (const line of raw.split("\n")) {
853
- if (!line.trim()) continue;
854
- try {
855
- const entry = JSON.parse(line);
856
- if (entry.action === "delete" && entry.id) {
857
- deleted.add(entry.id);
858
- seen.delete(entry.id);
859
- continue;
834
+ await this.flushBuffer();
835
+ await this.writeChain;
836
+ const CHUNK_SIZE = 65536;
837
+ let fd;
838
+ let fileOffset = 0;
839
+ let lineStartOffset = 0;
840
+ let checkpointByteOffset = -1;
841
+ let removedCount = 0;
842
+ let targetCheckpointSeen = false;
843
+ try {
844
+ fd = await fsp2.open(this.filePath, "r", 384);
845
+ while (true) {
846
+ const buf = Buffer.alloc(CHUNK_SIZE);
847
+ const { bytesRead } = await fd.read(buf, 0, CHUNK_SIZE, fileOffset);
848
+ if (bytesRead === 0) break;
849
+ let chunkPos = 0;
850
+ while (chunkPos < bytesRead) {
851
+ const idx = buf.indexOf("\n", chunkPos);
852
+ if (idx === -1) {
853
+ lineStartOffset = fileOffset + chunkPos;
854
+ break;
855
+ }
856
+ if (checkpointByteOffset !== -1) {
857
+ removedCount++;
858
+ } else {
859
+ const lineBytes = buf.subarray(chunkPos, idx);
860
+ const line = new TextDecoder("utf-8", { fatal: false }).decode(lineBytes);
861
+ if (line.trim()) {
862
+ try {
863
+ const event = JSON.parse(line);
864
+ if (event.type === "checkpoint") {
865
+ if (event.promptIndex === targetPromptIndex) {
866
+ checkpointByteOffset = lineStartOffset;
867
+ targetCheckpointSeen = true;
868
+ } else if (event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
869
+ checkpointByteOffset = lineStartOffset;
870
+ }
871
+ } else if (targetCheckpointSeen && event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
872
+ removedCount++;
873
+ } else if (targetCheckpointSeen && event.promptIndex === void 0) {
874
+ removedCount++;
875
+ } else if (!targetCheckpointSeen && event.promptIndex === void 0) {
876
+ removedCount++;
877
+ } else if (!targetCheckpointSeen && event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
878
+ removedCount++;
879
+ }
880
+ } catch {
881
+ }
882
+ }
883
+ }
884
+ chunkPos = idx + 1;
885
+ lineStartOffset = fileOffset + chunkPos;
860
886
  }
861
- if (entry.id && !deleted.has(entry.id)) {
862
- seen.set(entry.id, entry);
887
+ fileOffset += bytesRead;
888
+ if (chunkPos >= bytesRead) {
889
+ lineStartOffset = fileOffset;
863
890
  }
864
- } catch {
865
891
  }
892
+ } finally {
893
+ await fd?.close();
866
894
  }
867
- const summaries = Array.from(seen.values());
868
- this._indexCache = { ...stat7, summaries };
869
- return [...summaries];
870
- }
871
- /**
872
- * Rebuild the index from disk by scanning all sessions and writing a
873
- * fresh _index.jsonl. Useful after manual cleanup or index corruption.
874
- */
875
- async rebuildIndex() {
876
- const ids = await this.collectSessionIds(this.dir);
877
- const summaries = await Promise.all(ids.map((id) => this.summaryFor(id).catch(() => null)));
878
- const valid = summaries.filter((s) => s !== null);
879
- const tmp = `${this.indexFile}.tmp`;
880
- const lines = valid.map((s) => JSON.stringify(s)).join("\n") + "\n";
881
- await fsp.writeFile(tmp, lines, "utf8");
882
- await fsp.rename(tmp, this.indexFile);
883
- this._indexCache = null;
884
- return valid.length;
885
- }
886
- async listFromDirectoryScan(limit) {
887
- const refs = await this.collectSessionFiles(this.dir);
888
- const candidates = await mapWithConcurrency(
889
- refs,
890
- _DefaultSessionStore.LIST_SCAN_CONCURRENCY,
891
- async (ref) => {
892
- const manifest = await this.readSummaryManifest(ref.id);
893
- if (manifest) return { summary: manifest, needsBackfill: false };
894
- const summary = await this.summaryHeaderFor(ref);
895
- return summary ? { summary, needsBackfill: true } : null;
896
- }
897
- );
898
- const out = candidates.filter((s) => s !== null);
899
- out.sort((a, b) => compareSessionSummaries(a.summary, b.summary));
900
- const selected = out.slice(0, limit);
901
- const summaries = await mapWithConcurrency(
902
- selected,
903
- Math.min(_DefaultSessionStore.LIST_SCAN_CONCURRENCY, Math.max(1, limit)),
904
- async (candidate) => {
905
- if (!candidate.needsBackfill) return candidate.summary;
906
- return await this.summaryFor(candidate.summary.id).catch(() => candidate.summary);
907
- }
908
- );
909
- return summaries.filter((s) => s !== null);
910
- }
911
- async collectSessionFiles(dir, prefix = "", depth = 0) {
912
- let entries;
895
+ if (checkpointByteOffset === -1) return 0;
896
+ await this.writeChain;
897
+ await this.handle.close();
898
+ const tmpPath = `${this.filePath}.rewind.tmp`;
899
+ const src = await fsp2.open(this.filePath, "r", 384);
913
900
  try {
914
- entries = await fsp.readdir(dir, { withFileTypes: true });
915
- } catch {
916
- return [];
917
- }
918
- const dirEntries = [];
919
- const files = [];
920
- for (const entry of entries) {
921
- if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
922
- if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
923
- continue;
924
- if (entry.isDirectory()) {
925
- dirEntries.push(entry);
926
- } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
927
- if (entry.name === "_index.jsonl") continue;
928
- const base = entry.name.replace(/\.jsonl$/, "");
929
- const id = prefix ? `${prefix}/${base}` : base;
930
- files.push({ id, filePath: path2.join(dir, entry.name) });
901
+ const statResult = await src.stat();
902
+ const totalSize = statResult.size;
903
+ const prefixBytes = checkpointByteOffset;
904
+ let newlineAfterCheckpoint = prefixBytes;
905
+ if (prefixBytes < totalSize) {
906
+ const probeBuf = Buffer.alloc(Math.min(CHUNK_SIZE, totalSize - prefixBytes));
907
+ const { bytesRead: probeRead } = await src.read(probeBuf, 0, probeBuf.length, prefixBytes);
908
+ if (probeRead > 0) {
909
+ const nl = probeBuf.indexOf("\n");
910
+ newlineAfterCheckpoint = nl !== -1 ? prefixBytes + nl + 1 : totalSize;
911
+ }
912
+ } else {
913
+ newlineAfterCheckpoint = totalSize;
931
914
  }
932
- }
933
- const childFileArrays = await Promise.all(
934
- dirEntries.map((entry) => {
935
- const childPrefix = depth === 0 ? entry.name : `${prefix}/${entry.name}`;
936
- return this.collectSessionFiles(path2.join(dir, entry.name), childPrefix, depth + 1);
937
- })
938
- );
939
- return [...childFileArrays.flat(), ...files];
940
- }
941
- /** Recursively collect session IDs from date-shard subdirectories.
942
- * IDs include the date-prefix path (e.g. "2026-06-06/17-46-57Z_…").
943
- * Skips `.jsonl`/`.summary.json` root files, dot-files, and
944
- * sub-directories that belong to fleet/subagent sessions. */
945
- async collectSessionIds(dir, prefix = "", depth = 0) {
946
- let entries;
947
- try {
948
- entries = await fsp.readdir(dir, { withFileTypes: true });
949
- } catch {
950
- return [];
951
- }
952
- const dirEntries = [];
953
- const fileIds = [];
954
- for (const entry of entries) {
955
- if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
956
- if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
957
- continue;
958
- if (entry.isDirectory()) {
959
- dirEntries.push(entry);
960
- } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
961
- if (entry.name === "_index.jsonl") continue;
962
- const base = entry.name.replace(/\.jsonl$/, "");
963
- fileIds.push(prefix ? `${prefix}/${base}` : base);
915
+ const writeFd = await fsp2.open(tmpPath, "w", 384);
916
+ try {
917
+ let readOffset = 0;
918
+ while (readOffset < newlineAfterCheckpoint) {
919
+ const toCopy = Math.min(CHUNK_SIZE, newlineAfterCheckpoint - readOffset);
920
+ const copyBuf = Buffer.alloc(toCopy);
921
+ const { bytesRead: r } = await src.read(copyBuf, 0, toCopy, readOffset);
922
+ if (r === 0) break;
923
+ await writeFd.write(copyBuf, 0, r);
924
+ readOffset += r;
925
+ }
926
+ let tailOffset = newlineAfterCheckpoint;
927
+ let leftover = "";
928
+ while (tailOffset < totalSize) {
929
+ const toRead = Math.min(CHUNK_SIZE, totalSize - tailOffset);
930
+ const tailBuf = Buffer.alloc(toRead);
931
+ const { bytesRead: tr } = await src.read(tailBuf, 0, toRead, tailOffset);
932
+ if (tr === 0) break;
933
+ const chunk = leftover + tailBuf.subarray(0, tr).toString("utf8");
934
+ const lastNl = chunk.lastIndexOf("\n");
935
+ if (lastNl === -1) {
936
+ leftover = chunk;
937
+ } else {
938
+ for (const line of chunk.slice(0, lastNl + 1).split("\n")) {
939
+ if (!line.trim()) continue;
940
+ try {
941
+ JSON.parse(line);
942
+ } catch {
943
+ await writeFd.write(`${line}
944
+ `, void 0, "utf8");
945
+ }
946
+ }
947
+ leftover = chunk.slice(lastNl + 1);
948
+ }
949
+ tailOffset += tr;
950
+ }
951
+ if (leftover.trim()) {
952
+ try {
953
+ JSON.parse(leftover);
954
+ } catch {
955
+ await writeFd.write(`${leftover}
956
+ `, void 0, "utf8");
957
+ }
958
+ }
959
+ } finally {
960
+ await writeFd.close();
964
961
  }
965
- }
966
- const childIdArrays = await Promise.all(
967
- dirEntries.map((entry) => {
968
- const childPrefix = depth === 0 ? entry.name : `${prefix}/${entry.name}`;
969
- return this.collectSessionIds(path2.join(dir, entry.name), childPrefix, depth + 1);
970
- })
971
- );
972
- return [...childIdArrays.flat(), ...fileIds];
973
- }
974
- async summaryFor(id) {
975
- const manifest = this.sessionPath(id, ".summary.json");
976
- const t0 = Date.now();
977
- let outcome = "success";
978
- let errorMsg;
979
- const fromManifest = await this.readSummaryManifest(id, t0);
980
- if (fromManifest) return fromManifest;
981
- try {
982
- const full = this.sessionPath(id, ".jsonl");
983
- const stat7 = await fsp.stat(full);
984
- const summary = await this.summarize(id, stat7.mtime.toISOString());
985
- await atomicWrite(manifest, JSON.stringify(summary), { mode: 384 }).catch((err) => {
986
- const msg = toErrorMessage(err);
987
- this.emitError(id, manifest, "summary_fallback", msg, true);
988
- console.warn(JSON.stringify({
989
- level: "warn",
990
- event: "session_store.manifest_write_failed",
991
- sessionId: id,
992
- message: msg,
993
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
994
- }));
995
- });
996
- outcome = "failure";
997
- errorMsg = "summary fallback \xE2\u20AC\u201D manifest rebuilt";
998
- this.emitRead(id, manifest, "summary", outcome, Date.now() - t0, errorMsg);
999
- return summary;
962
+ await src.close();
963
+ await fsp2.rename(tmpPath, this.filePath);
964
+ this.handle = await fsp2.open(this.filePath, "a", 384);
1000
965
  } catch (err) {
1001
- outcome = "failure";
1002
- errorMsg = toErrorMessage(err);
1003
- this.emitRead(id, manifest, "summary", outcome, Date.now() - t0, errorMsg);
1004
- return {
1005
- id,
1006
- title: "(damaged)",
1007
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1008
- model: "unknown",
1009
- provider: "unknown",
1010
- tokenTotal: 0
1011
- };
966
+ await fsp2.unlink(tmpPath).catch(() => void 0);
967
+ this.handle = await fsp2.open(this.filePath, "a", 384).catch(() => this.handle);
968
+ throw err;
1012
969
  }
970
+ await this.append({
971
+ type: "rewound",
972
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
973
+ toPromptIndex: targetPromptIndex,
974
+ revertedFiles: []
975
+ });
976
+ this.events?.emit("session.rewound", {
977
+ toPromptIndex: targetPromptIndex,
978
+ revertedFiles: [],
979
+ removedEvents: removedCount
980
+ });
981
+ return removedCount;
1013
982
  }
1014
- async readSummaryManifest(id, startTime = Date.now()) {
1015
- const manifest = this.sessionPath(id, ".summary.json");
1016
- try {
1017
- const raw = await fsp.readFile(manifest, "utf8");
1018
- this.emitRead(id, manifest, "summary", "success", Date.now() - startTime);
1019
- return JSON.parse(raw);
1020
- } catch {
1021
- return null;
983
+ async clearSession() {
984
+ if (!this.filePath) return;
985
+ if (this.flushTimer) {
986
+ clearTimeout(this.flushTimer);
987
+ this.flushTimer = null;
1022
988
  }
989
+ this.writeBuffer = [];
990
+ await this.writeChain;
991
+ const record = `${JSON.stringify({
992
+ type: "session_start",
993
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
994
+ id: this.id,
995
+ model: this.meta.model ?? "unknown",
996
+ provider: this.meta.provider ?? "unknown"
997
+ })}
998
+ `;
999
+ await fsp2.writeFile(this.filePath, record, "utf8");
1023
1000
  }
1024
- async summaryHeaderFor(ref) {
1025
- let mtime = (/* @__PURE__ */ new Date(0)).toISOString();
1026
- try {
1027
- const stat7 = await fsp.stat(ref.filePath);
1028
- if (!stat7.isFile()) {
1029
- return {
1030
- id: ref.id,
1031
- title: "(damaged)",
1032
- startedAt: stat7.mtime.toISOString(),
1033
- model: "unknown",
1034
- provider: "unknown",
1035
- tokenTotal: 0
1036
- };
1037
- }
1038
- mtime = stat7.mtime.toISOString();
1039
- } catch {
1040
- return null;
1041
- }
1042
- try {
1043
- for await (const event of this.iterSessionEvents(ref.filePath)) {
1044
- if (event.type === "session_start") {
1045
- return {
1046
- id: ref.id,
1047
- title: "(empty session)",
1048
- startedAt: event.ts,
1049
- model: event.model ?? "unknown",
1050
- provider: event.provider ?? "unknown",
1051
- tokenTotal: 0
1052
- };
1053
- }
1054
- }
1055
- return {
1056
- id: ref.id,
1057
- title: "(empty session)",
1058
- startedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
1059
- model: "unknown",
1060
- provider: "unknown",
1061
- tokenTotal: 0
1062
- };
1063
- } catch {
1064
- return {
1065
- id: ref.id,
1066
- title: "(damaged)",
1067
- startedAt: mtime,
1068
- model: "unknown",
1069
- provider: "unknown",
1070
- tokenTotal: 0
1071
- };
1001
+ /**
1002
+ * Write an in-flight marker. The agent loop should call
1003
+ * this at the start of each long-running operation; a matching
1004
+ * `clearInFlightMarker` follows on clean exit. A stale marker
1005
+ * (no end) is what `SessionRecovery.detectStale` looks for.
1006
+ */
1007
+ async writeInFlightMarker(context) {
1008
+ if (!context || context.length > 500) {
1009
+ throw new Error("In-flight context must be 1..500 chars");
1072
1010
  }
1011
+ await this.append({
1012
+ type: "in_flight_start",
1013
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1014
+ context
1015
+ });
1016
+ this.events?.emit("in_flight.started", { context, ts: (/* @__PURE__ */ new Date()).toISOString() });
1073
1017
  }
1074
1018
  /**
1075
- * Delete a session and all associated files: JSONL, summary, plan/todos
1076
- * sidecars, and the session directory (fleet.json, shared/, subagents/).
1019
+ * Close the in-flight marker. Idempotent in spirit
1020
+ * (you can call it after a successful iteration even if you
1021
+ * didn't open one this round) — but the session log records
1022
+ * every call so postmortem tooling can see "the agent finished
1023
+ * cleanly X times, then died without finishing Y".
1024
+ */
1025
+ async clearInFlightMarker(reason) {
1026
+ await this.append({
1027
+ type: "in_flight_end",
1028
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1029
+ reason
1030
+ });
1031
+ this.events?.emit("in_flight.ended", { reason, ts: (/* @__PURE__ */ new Date()).toISOString() });
1032
+ }
1033
+ };
1034
+ function sanitizeModel(model) {
1035
+ return model.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
1036
+ }
1037
+ function generateSessionId(startedAt, model) {
1038
+ const date = startedAt.slice(0, 10);
1039
+ const time = startedAt.slice(11, 19).replace(/:/g, "-");
1040
+ const suffix = randomBytes(2).toString("hex");
1041
+ const modelPart = model ? `_${sanitizeModel(model)}` : "";
1042
+ return `${date}/${time}Z${modelPart}_${suffix}`;
1043
+ }
1044
+
1045
+ // src/storage/session-store.ts
1046
+ var DefaultSessionStore = class _DefaultSessionStore {
1047
+ dir;
1048
+ events;
1049
+ secretScrubber;
1050
+ /**
1051
+ * In-memory cache for load() results, keyed by session ID. The cache is
1052
+ * invalidated when the file's mtimeMs or size changes (indicating the
1053
+ * file was written to). This eliminates redundant full-file reads and
1054
+ * JSON parses when the same session is loaded multiple times within the
1055
+ * store's lifetime (e.g., webui session detail views, list() fallbacks).
1077
1056
  *
1078
- * Individual file deletions are best-effort (logged as structured warnings),
1079
- * but a tombstone is always written so readIndex() filters this session out.
1080
- * If the session directory itself can't be removed, the error is surfaced
1081
- * to the caller so prune() can report it.
1057
+ * Max size is capped to prevent unbounded memory growth in long-running
1058
+ * processes. When the limit is reached, the oldest entry is evicted.
1082
1059
  */
1083
- async deleteSession(id) {
1084
- const jsonlPath = this.sessionPath(id, ".jsonl");
1085
- const summaryPath = this.sessionPath(id, ".summary.json");
1086
- const shardDir = path2.dirname(path2.join(this.dir, id));
1087
- const base = path2.basename(id);
1088
- const sessDir = path2.join(shardDir, base);
1089
- const deletions = [
1090
- fsp.unlink(jsonlPath),
1091
- fsp.unlink(summaryPath),
1092
- fsp.unlink(path2.join(shardDir, `${base}.plan.json`)),
1093
- fsp.unlink(path2.join(shardDir, `${base}.todos.json`))
1094
- ];
1095
- const results = await Promise.allSettled(deletions);
1096
- for (const r of results) {
1097
- if (r.status === "rejected") {
1098
- const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
1099
- if (r.reason?.code !== "ENOENT") {
1100
- console.warn(JSON.stringify({
1101
- level: "warn",
1102
- event: "session_store.delete_failed",
1103
- sessionId: id,
1104
- message: msg,
1105
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1106
- }));
1107
- }
1108
- }
1060
+ _loadCache = /* @__PURE__ */ new Map();
1061
+ _indexCache = null;
1062
+ shardManifestCache = /* @__PURE__ */ new Map();
1063
+ static LOAD_CACHE_MAX_ENTRIES = 50;
1064
+ static LIST_SCAN_CONCURRENCY = 32;
1065
+ constructor(opts) {
1066
+ this.dir = opts.dir;
1067
+ this.events = opts.events;
1068
+ this.secretScrubber = opts.secretScrubber;
1069
+ }
1070
+ /**
1071
+ * Clear the load() cache. Useful for testing or when the caller knows
1072
+ * the file has changed externally (e.g., another process wrote to it).
1073
+ */
1074
+ clearLoadCache(sessionId) {
1075
+ if (sessionId !== void 0) {
1076
+ this._loadCache.delete(sessionId);
1077
+ } else {
1078
+ this._loadCache.clear();
1109
1079
  }
1110
- await fsp.rm(sessDir, { recursive: true, force: true }).catch((err) => {
1111
- console.warn(JSON.stringify({
1080
+ }
1081
+ // ── Storage event helpers ───────────────────────────────────────────────────
1082
+ emitRead(sessionId, filePath, operation, outcome, durationMs, error) {
1083
+ this.events?.emit("storage.read", {
1084
+ sessionId,
1085
+ store: "session",
1086
+ filePath,
1087
+ operation,
1088
+ outcome,
1089
+ durationMs,
1090
+ ...error !== void 0 ? { error } : {}
1091
+ });
1092
+ }
1093
+ emitWrite(sessionId, filePath, operation, outcome, durationMs, eventCount, error) {
1094
+ this.events?.emit("storage.write", {
1095
+ sessionId,
1096
+ store: "session",
1097
+ filePath,
1098
+ operation,
1099
+ outcome,
1100
+ durationMs,
1101
+ ...eventCount !== void 0 ? { eventCount } : {},
1102
+ ...error !== void 0 ? { error } : {}
1103
+ });
1104
+ }
1105
+ emitError(sessionId, filePath, operation, error, recoverable) {
1106
+ this.events?.emit("storage.error", {
1107
+ sessionId,
1108
+ store: "session",
1109
+ filePath,
1110
+ operation,
1111
+ error,
1112
+ recoverable
1113
+ });
1114
+ }
1115
+ /** Absolute path to the session index file. */
1116
+ get indexFile() {
1117
+ return path2.join(this.dir, "_index.jsonl");
1118
+ }
1119
+ /** Join session ID to its absolute path within the store directory. */
1120
+ sessionPath(id, ext) {
1121
+ return path2.join(this.dir, `${id}${ext}`);
1122
+ }
1123
+ shardManifestPath(shardKey) {
1124
+ return shardKey ? path2.join(this.dir, shardKey, "_manifest.json") : path2.join(this.dir, "_manifest.json");
1125
+ }
1126
+ shardKeyForSessionId(id) {
1127
+ const dirName = path2.dirname(id);
1128
+ return dirName === "." ? "" : dirName;
1129
+ }
1130
+ invalidateShardManifestBySessionId(id) {
1131
+ this.shardManifestCache.delete(this.shardKeyForSessionId(id));
1132
+ }
1133
+ /**
1134
+ * Ensure the directory implied by the session ID exists. When the ID
1135
+ * contains a date prefix like `2026-06-06/...`, this creates the date
1136
+ * subdirectory so sessions group naturally by day.
1137
+ */
1138
+ async ensureShardDir(id) {
1139
+ const dirPath = path2.dirname(path2.join(this.dir, id));
1140
+ await ensureDir(dirPath);
1141
+ return dirPath;
1142
+ }
1143
+ async create(meta) {
1144
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
1145
+ const id = meta.id && meta.id.length > 0 ? meta.id : generateSessionId(startedAt, meta.model ?? meta.provider);
1146
+ const shardDir = await this.ensureShardDir(id);
1147
+ const file = path2.join(shardDir, `${path2.basename(id)}.jsonl`);
1148
+ const t0 = Date.now();
1149
+ let handle;
1150
+ try {
1151
+ handle = await fsp2.open(file, "a", 384);
1152
+ } catch (err) {
1153
+ this.emitError(id, file, "create", toErrorMessage(err), false);
1154
+ throw new Error(
1155
+ `Failed to open session file: ${toErrorMessage(err)}`,
1156
+ { cause: err }
1157
+ );
1158
+ }
1159
+ try {
1160
+ const writer = new FileSessionWriter(id, handle, startedAt, meta, this.events, {
1161
+ dir: shardDir,
1162
+ filePath: file,
1163
+ secretScrubber: this.secretScrubber,
1164
+ onClose: (s) => this.appendToIndex(s)
1165
+ });
1166
+ this.emitWrite(id, file, "create", "success", Date.now() - t0);
1167
+ return writer;
1168
+ } catch (err) {
1169
+ await handle.close().catch((e) => console.warn(JSON.stringify({
1112
1170
  level: "warn",
1113
- event: "session_store.rmdir_failed",
1114
- sessionId: id,
1115
- message: toErrorMessage(err),
1171
+ event: "session_store.handle_close_failed",
1172
+ message: e instanceof Error ? e.message : String(e),
1116
1173
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1117
- }));
1118
- });
1119
- await this.writeTombstone(id);
1120
- }
1121
- async delete(id) {
1122
- await this.deleteSession(id);
1174
+ })));
1175
+ this.emitError(id, file, "create", toErrorMessage(err), true);
1176
+ throw err;
1177
+ }
1123
1178
  }
1124
- async prune(maxAgeDays = 30) {
1125
- const cutoff = Date.now() - maxAgeDays * 864e5;
1126
- let deleted = 0;
1127
- let activeSessionId = null;
1179
+ async resume(id) {
1180
+ const file = this.sessionPath(id, ".jsonl");
1181
+ const t0 = Date.now();
1182
+ const data = await this.load(id);
1183
+ let handle;
1128
1184
  try {
1129
- const raw = await fsp.readFile(path2.join(this.dir, "active.json"), "utf8");
1130
- const active = JSON.parse(raw);
1131
- activeSessionId = active.sessionId ?? null;
1132
- } catch {
1133
- }
1134
- const isPrunableJsonl = (name) => name.endsWith(".jsonl") && name !== "_index.jsonl" && name !== "_mailbox.jsonl" && !name.endsWith(".replay.jsonl") && !name.endsWith(".audit.jsonl");
1135
- const pruneFile = async (dir, name, prefix) => {
1136
- const jsonlPath = path2.join(dir, name);
1137
- try {
1138
- const stat7 = await fsp.stat(jsonlPath);
1139
- if (stat7.mtimeMs >= cutoff) return;
1140
- } catch {
1141
- return;
1142
- }
1143
- const base = name.replace(/\.jsonl$/, "");
1144
- const id = prefix ? `${prefix}/${base}` : base;
1145
- if (activeSessionId && id === activeSessionId) return;
1146
- await this.deleteSession(id);
1147
- deleted++;
1148
- };
1149
- const entries = await fsp.readdir(this.dir, { withFileTypes: true }).catch(() => []);
1150
- for (const entry of entries) {
1151
- if (entry.isFile()) {
1152
- if (isPrunableJsonl(entry.name)) await pruneFile(this.dir, entry.name, "");
1153
- continue;
1154
- }
1155
- if (!entry.isDirectory()) continue;
1156
- const dateDir = path2.join(this.dir, entry.name);
1157
- const files = await fsp.readdir(dateDir, { withFileTypes: true }).catch(() => []);
1158
- for (const file of files) {
1159
- if (!file.isFile() || !isPrunableJsonl(file.name)) continue;
1160
- await pruneFile(dateDir, file.name, entry.name);
1161
- }
1162
- }
1163
- if (deleted > 0) {
1164
- await this.compactIndex().catch(() => void 0);
1185
+ handle = await fsp2.open(file, "a", 384);
1186
+ } catch (err) {
1187
+ this.emitError(id, file, "resume", toErrorMessage(err), false);
1188
+ throw new Error(
1189
+ `Failed to open session "${id}" for append: ${toErrorMessage(err)}`,
1190
+ { cause: err }
1191
+ );
1165
1192
  }
1166
- for (const entry of entries) {
1167
- if (!entry.isDirectory()) continue;
1168
- const dateDir = path2.join(this.dir, entry.name);
1169
- try {
1170
- const remaining = await fsp.readdir(dateDir);
1171
- if (remaining.length === 0) {
1172
- await fsp.rmdir(dateDir).catch(() => void 0);
1193
+ try {
1194
+ const writer = new FileSessionWriter(
1195
+ id,
1196
+ handle,
1197
+ (/* @__PURE__ */ new Date()).toISOString(),
1198
+ {
1199
+ id,
1200
+ model: data.metadata.model,
1201
+ provider: data.metadata.provider
1202
+ },
1203
+ this.events,
1204
+ {
1205
+ resumed: true,
1206
+ // Shard directory (sessions/<date>/) — must match create() so the
1207
+ // .summary.json sidecar lands next to the JSONL instead of the
1208
+ // sessions root (where summaryFor() would never find it).
1209
+ dir: path2.dirname(file),
1210
+ filePath: file,
1211
+ secretScrubber: this.secretScrubber,
1212
+ onClose: (s) => this.appendToIndex(s)
1173
1213
  }
1174
- } catch {
1175
- }
1214
+ );
1215
+ this.emitWrite(id, file, "resume", "success", Date.now() - t0);
1216
+ return { writer, data };
1217
+ } catch (err) {
1218
+ await handle.close().catch((e) => console.warn(JSON.stringify({
1219
+ level: "warn",
1220
+ event: "session_store.handle_close_failed",
1221
+ message: e instanceof Error ? e.message : String(e),
1222
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1223
+ })));
1224
+ this.emitError(id, file, "resume", toErrorMessage(err), true);
1225
+ throw err;
1176
1226
  }
1177
- return deleted;
1178
1227
  }
1179
- async clearHistory(id) {
1180
- await this.ensureShardDir(id);
1228
+ async load(id) {
1181
1229
  const file = this.sessionPath(id, ".jsonl");
1182
- const meta = this.sessionPath(id, ".summary.json");
1183
- const record = `${JSON.stringify({
1184
- type: "session_start",
1185
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1186
- id,
1187
- model: "unknown",
1188
- provider: "unknown"
1189
- })}
1190
- `;
1191
- await fsp.writeFile(file, record, "utf8");
1192
- await fsp.unlink(meta).catch(() => void 0);
1193
- }
1194
- async summarize(id, mtime) {
1230
+ const t0 = Date.now();
1231
+ let outcome = "success";
1232
+ let errorMsg;
1233
+ let cacheHit = false;
1195
1234
  try {
1196
- const file = this.sessionPath(id, ".jsonl");
1197
- let title = "(empty session)";
1198
- let startedAt = (/* @__PURE__ */ new Date(0)).toISOString();
1199
- let endedAt;
1200
- let model = "unknown";
1201
- let provider = "unknown";
1202
- let tokenIn = 0;
1203
- let tokenOut = 0;
1204
- let iterationCount = 0;
1205
- let toolCallCount = 0;
1206
- let toolErrorCount = 0;
1207
- let fileChangeCount = 0;
1208
- const toolBreakdown = {};
1209
- let outcome;
1210
- let lastEventType;
1211
- let hasError = false;
1212
- let sawStart = false;
1213
- for await (const e of this.iterSessionEvents(file)) {
1214
- lastEventType = e.type;
1215
- if (e.type === "session_start") {
1216
- if (!sawStart) {
1217
- sawStart = true;
1218
- startedAt = e.ts;
1219
- model = e.model ?? "unknown";
1220
- provider = e.provider ?? "unknown";
1235
+ const s = await fsp2.stat(file);
1236
+ const stat10 = { mtimeMs: s.mtimeMs, size: s.size };
1237
+ const cached = this._loadCache.get(id);
1238
+ if (cached && cached.mtimeMs === stat10.mtimeMs && cached.size === stat10.size) {
1239
+ cacheHit = true;
1240
+ this._loadCache.delete(id);
1241
+ this._loadCache.set(id, cached);
1242
+ return cached.data;
1243
+ }
1244
+ const raw = await fsp2.readFile(file, "utf8");
1245
+ const lines = raw.split("\n").filter((l) => l.trim());
1246
+ const events = [];
1247
+ let sessionStartEvent;
1248
+ let sessionEndEvent;
1249
+ let sessionModel;
1250
+ let sessionProvider;
1251
+ let sessionPendingToolUses;
1252
+ const messages = [];
1253
+ const openToolUses = /* @__PURE__ */ new Set();
1254
+ let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1255
+ for (const line of lines) {
1256
+ try {
1257
+ const parsed = JSON.parse(line);
1258
+ if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
1259
+ const ev = parsed;
1260
+ events.push(ev);
1261
+ if (ev.type === "session_start" && !sessionStartEvent) {
1262
+ sessionStartEvent = ev;
1263
+ sessionModel = ev.model;
1264
+ sessionProvider = ev.provider;
1265
+ }
1266
+ if (ev.type === "session_end") {
1267
+ sessionEndEvent = ev;
1268
+ sessionPendingToolUses = ev.pendingToolUses;
1269
+ }
1270
+ if (ev.type === "user_input") {
1271
+ openToolUses.clear();
1272
+ messages.push({ role: "user", content: ev.content, ts: ev.ts });
1273
+ } else if (ev.type === "llm_response") {
1274
+ messages.push({ role: "assistant", content: ev.content, ts: ev.ts });
1275
+ for (const b of ev.content) {
1276
+ if (b.type === "tool_use") openToolUses.add(b.id);
1277
+ }
1278
+ usage = {
1279
+ input: usage.input + (ev.usage.input ?? 0),
1280
+ output: usage.output + (ev.usage.output ?? 0),
1281
+ cacheRead: (usage.cacheRead ?? 0) + (ev.usage.cacheRead ?? 0),
1282
+ cacheWrite: (usage.cacheWrite ?? 0) + (ev.usage.cacheWrite ?? 0)
1283
+ };
1284
+ } else if (ev.type === "tool_result") {
1285
+ if (!openToolUses.has(ev.id)) {
1286
+ this.events?.emit("session.damaged", {
1287
+ sessionId: id,
1288
+ detail: `Orphan tool_result "${ev.id}" has no matching tool_use`
1289
+ });
1290
+ continue;
1291
+ }
1292
+ openToolUses.delete(ev.id);
1293
+ const resultBlock = {
1294
+ type: "tool_result",
1295
+ tool_use_id: ev.id,
1296
+ content: typeof ev.content === "string" ? ev.content : JSON.stringify(ev.content),
1297
+ is_error: ev.isError
1298
+ };
1299
+ const last = messages[messages.length - 1];
1300
+ const lastIsToolResultUser = last?.role === "user" && Array.isArray(last.content) && last.content.every((b) => b.type === "tool_result");
1301
+ if (lastIsToolResultUser && Array.isArray(last.content)) {
1302
+ last.content.push(resultBlock);
1303
+ } else {
1304
+ messages.push({ role: "user", content: [resultBlock], ts: ev.ts });
1305
+ }
1306
+ }
1221
1307
  }
1222
- } else if (e.type === "session_end") {
1223
- endedAt = e.ts;
1224
- } else if (e.type === "user_input") {
1225
- if (title === "(empty session)") title = userInputTitle(e.content);
1226
- } else if (e.type === "llm_response") {
1227
- tokenIn += e.usage.input ?? 0;
1228
- tokenOut += e.usage.output ?? 0;
1229
- } else if (e.type === "in_flight_start") iterationCount++;
1230
- else if (e.type === "tool_call_start") {
1231
- toolCallCount++;
1232
- toolBreakdown[e.name] = (toolBreakdown[e.name] ?? 0) + 1;
1233
- } else if (e.type === "tool_result" && e.isError) toolErrorCount++;
1234
- else if (e.type === "file_snapshot") fileChangeCount += e.files.length;
1235
- else if (e.type === "error" || e.type === "provider_error") hasError = true;
1308
+ } catch {
1309
+ }
1236
1310
  }
1237
- if (lastEventType === "session_end") {
1238
- outcome = "completed";
1239
- } else if (lastEventType === "in_flight_start") {
1240
- outcome = "aborted";
1241
- } else if (hasError) {
1242
- outcome = "error";
1311
+ if (openToolUses.size > 0) {
1312
+ this.events?.emit("session.damaged", {
1313
+ sessionId: id,
1314
+ detail: `${openToolUses.size} tool_use blocks without matching results - replay repaired`
1315
+ });
1243
1316
  }
1244
- return {
1245
- id,
1246
- title,
1247
- startedAt,
1248
- endedAt,
1249
- model,
1250
- provider,
1251
- tokenTotal: tokenIn + tokenOut,
1252
- iterationCount: iterationCount > 0 ? iterationCount : void 0,
1253
- toolCallCount: toolCallCount > 0 ? toolCallCount : void 0,
1254
- toolErrorCount: toolErrorCount > 0 ? toolErrorCount : void 0,
1255
- fileChangeCount: fileChangeCount > 0 ? fileChangeCount : void 0,
1256
- toolBreakdown: Object.keys(toolBreakdown).length > 0 ? toolBreakdown : {},
1257
- outcome
1258
- };
1259
- } catch {
1260
- return {
1317
+ const repaired = repairToolUseAdjacency(messages);
1318
+ if (repaired.report.changed) {
1319
+ this.events?.emit("session.damaged", {
1320
+ sessionId: id,
1321
+ detail: `Repaired replay adjacency: removed ${repaired.report.removedToolUses.length} tool_use, ${repaired.report.removedToolResults.length} tool_result, ${repaired.report.removedMessages} empty messages`
1322
+ });
1323
+ }
1324
+ const meta = {
1261
1325
  id,
1262
- title: "(damaged)",
1263
- startedAt: mtime,
1264
- model: "unknown",
1265
- provider: "unknown",
1266
- tokenTotal: 0
1326
+ startedAt: sessionStartEvent?.ts ?? (/* @__PURE__ */ new Date(0)).toISOString(),
1327
+ endedAt: sessionEndEvent?.ts,
1328
+ model: sessionModel,
1329
+ provider: sessionProvider,
1330
+ pendingToolUses: sessionPendingToolUses
1267
1331
  };
1332
+ const toolCallEnds = extractToolCallEnds(events);
1333
+ const data = { metadata: meta, events, messages: repaired.messages, usage, toolCallEnds };
1334
+ if (this._loadCache.size >= _DefaultSessionStore.LOAD_CACHE_MAX_ENTRIES) {
1335
+ const oldest = this._loadCache.keys().next().value;
1336
+ if (oldest !== void 0) {
1337
+ this._loadCache.delete(oldest);
1338
+ }
1339
+ }
1340
+ this._loadCache.set(id, { mtimeMs: stat10.mtimeMs, size: stat10.size, data });
1341
+ return data;
1342
+ } catch (err) {
1343
+ outcome = "failure";
1344
+ errorMsg = toErrorMessage(err);
1345
+ throw err;
1346
+ } finally {
1347
+ this.emitRead(id, file, "load", outcome, Date.now() - t0, errorMsg);
1348
+ if (cacheHit) {
1349
+ this.events?.emit("storage.cache_hit", {
1350
+ sessionId: id,
1351
+ store: "session",
1352
+ filePath: file,
1353
+ operation: "load",
1354
+ durationMs: Date.now() - t0
1355
+ });
1356
+ }
1268
1357
  }
1269
1358
  }
1270
- async *iterSessionEvents(file) {
1271
- const stream = createReadStream(file, { encoding: "utf8" });
1272
- const lines = createInterface({ input: stream, crlfDelay: Infinity });
1359
+ /**
1360
+ * Streaming search over a session's JSONL. Walks the file once, parses
1361
+ * each event lazily, and yields only the events that match `predicate`.
1362
+ * Stops as soon as `opts.limit` matches are collected.
1363
+ *
1364
+ * Why this exists: `load()` parses the entire file into memory and
1365
+ * rebuilds `messages`/`toolCallEnds` for every caller. `search()` only
1366
+ * needs to know which events contain matching text — a per-line
1367
+ * predicate is enough. The full parse work (and the `_loadCache` poll)
1368
+ * is wasted in that case.
1369
+ *
1370
+ * Memory: O(hits) regardless of file size. Disk: one linear scan,
1371
+ * terminated at `limit` if the caller asked for one.
1372
+ *
1373
+ * Errors: missing file yields []. Corrupt lines are skipped (same
1374
+ * policy as `load()`). Aborting via `signal` rejects with `AbortError`.
1375
+ */
1376
+ async searchEvents(id, predicate, opts) {
1377
+ const file = this.sessionPath(id, ".jsonl");
1378
+ const limit = opts?.limit;
1379
+ const signal = opts?.signal;
1380
+ const out = [];
1381
+ let stat10;
1273
1382
  try {
1274
- for await (const line of lines) {
1275
- if (!line.trim()) continue;
1383
+ stat10 = await fsp2.stat(file);
1384
+ } catch (err) {
1385
+ if (err.code === "ENOENT") return [];
1386
+ throw err;
1387
+ }
1388
+ if (stat10.size === 0) return [];
1389
+ let fh;
1390
+ try {
1391
+ fh = await fsp2.open(file, "r");
1392
+ const CHUNK = 64 * 1024;
1393
+ const buf = Buffer.alloc(CHUNK);
1394
+ let leftover = "";
1395
+ let eventIndex = 0;
1396
+ for (let position = 0; ; position += buf.byteLength) {
1397
+ if (signal?.aborted) {
1398
+ const reason = signal.reason ?? new DOMException("Aborted", "AbortError");
1399
+ throw reason;
1400
+ }
1401
+ const { bytesRead } = await fh.read(buf, 0, CHUNK, position);
1402
+ if (bytesRead === 0) break;
1403
+ const text = leftover + buf.subarray(0, bytesRead).toString("utf8");
1404
+ const parts = text.split("\n");
1405
+ leftover = parts.pop() ?? "";
1406
+ for (const line of parts) {
1407
+ if (!line) continue;
1408
+ let ev;
1409
+ try {
1410
+ const parsed = JSON.parse(line);
1411
+ if (parsed === null || typeof parsed !== "object" || typeof parsed.type !== "string" || typeof parsed.ts !== "string") {
1412
+ continue;
1413
+ }
1414
+ ev = parsed;
1415
+ } catch {
1416
+ continue;
1417
+ }
1418
+ if (predicate(ev, eventIndex, ev.ts)) {
1419
+ out.push({ event: ev, eventIndex, ts: ev.ts });
1420
+ if (limit !== void 0 && out.length >= limit) {
1421
+ return out;
1422
+ }
1423
+ }
1424
+ eventIndex++;
1425
+ }
1426
+ }
1427
+ if (leftover.trim()) {
1276
1428
  try {
1277
- const parsed = JSON.parse(line);
1429
+ const parsed = JSON.parse(leftover);
1278
1430
  if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
1279
- yield parsed;
1431
+ const ev = parsed;
1432
+ if (predicate(ev, eventIndex, ev.ts)) {
1433
+ out.push({ event: ev, eventIndex, ts: ev.ts });
1434
+ }
1280
1435
  }
1281
1436
  } catch {
1282
1437
  }
1283
1438
  }
1439
+ return out;
1284
1440
  } finally {
1285
- lines.close();
1286
- stream.destroy();
1287
- }
1288
- }
1289
- };
1290
- function extractToolCallEnds(events) {
1291
- const result = [];
1292
- for (const e of events) {
1293
- if (e.type === "tool_call_end") {
1294
- result.push({
1295
- name: e.name,
1296
- id: e.id,
1297
- durationMs: e.durationMs,
1298
- ok: e.ok ?? false,
1299
- outputBytes: e.outputBytes,
1300
- outputTokens: e.outputTokens,
1301
- outputLines: e.outputLines
1302
- });
1303
- }
1304
- }
1305
- return result;
1306
- }
1307
- var FileSessionWriter = class _FileSessionWriter {
1308
- constructor(id, handle, startedAt, meta, events, opts = {}, traceId) {
1309
- this.id = id;
1310
- this.handle = handle;
1311
- this.startedAt = startedAt;
1312
- this.meta = meta;
1313
- this.events = events;
1314
- this.resumed = opts.resumed ?? false;
1315
- this.manifestFile = opts.dir ? path2.join(opts.dir, `${path2.basename(id)}.summary.json`) : "";
1316
- this.filePath = opts.filePath ?? "";
1317
- this.secretScrubber = opts.secretScrubber;
1318
- this.onCloseCb = opts.onClose;
1319
- this.summary = {
1320
- id,
1321
- title: "(empty session)",
1322
- startedAt,
1323
- model: meta.model ?? "unknown",
1324
- provider: meta.provider ?? "unknown",
1325
- tokenTotal: 0
1326
- };
1327
- this.traceId = traceId;
1328
- }
1329
- id;
1330
- handle;
1331
- startedAt;
1332
- meta;
1333
- events;
1334
- closed = false;
1335
- closePromise = null;
1336
- manifestFile;
1337
- summary;
1338
- tokenIn = 0;
1339
- tokenOut = 0;
1340
- filePath;
1341
- get transcriptPath() {
1342
- return this.filePath || void 0;
1343
- }
1344
- /**
1345
- * Lazy session_start/session_resumed init, shared by all appenders.
1346
- * A single promise (not a boolean) so a second append racing the first
1347
- * can't push its event into the buffer BEFORE the first append's event —
1348
- * every appender awaits the same init and resumes in FIFO call order.
1349
- */
1350
- initPromise = null;
1351
- ensureInit() {
1352
- if (!this.initPromise) this.initPromise = this.writeSessionStartLazy();
1353
- return this.initPromise;
1354
- }
1355
- resumed;
1356
- appendFailCount = 0;
1357
- lastAppendWarnAt = 0;
1358
- secretScrubber;
1359
- onCloseCb;
1360
- /** Implements SessionWriter.traceId — propagated from ContextInit.traceId. */
1361
- traceId;
1362
- // ── Write buffer — batches events to reduce per-event disk I/O ─────────
1363
- //
1364
- // Every append() pushes the scrubbed event into an in-memory buffer instead
1365
- // of calling handle.appendFile() synchronously. The buffer flushes to disk
1366
- // when it reaches FLUSH_SIZE events OR after FLUSH_INTERVAL_MS of inactivity.
1367
- // This cuts the number of disk writes by ~95% without changing the on-disk
1368
- // format — the JSONL is still one JSON object per line.
1369
- writeBuffer = [];
1370
- flushTimer = null;
1371
- static FLUSH_INTERVAL_MS = 500;
1372
- static FLUSH_SIZE = 50;
1373
- // ── Write serialization ─────────────────────────────────────────────────
1374
- //
1375
- // All disk writes are funneled through a FIFO promise chain. Without it,
1376
- // a timer-driven flush racing an explicit flush()/close() issues two
1377
- // concurrent appendFile() calls on the shared O_APPEND handle — the kernel
1378
- // may complete them out of order (chronology breaks) or, for large
1379
- // batches, interleave partial writes (torn JSONL lines). The chain keeps
1380
- // exactly one write in flight; failures don't break the chain.
1381
- writeChain = Promise.resolve();
1382
- /** Enqueue a write on the FIFO chain. Resolves/rejects with that write. */
1383
- enqueueWrite(data) {
1384
- const write = this.writeChain.then(() => this.handle.appendFile(data, "utf8"));
1385
- this.writeChain = write.then(
1386
- () => void 0,
1387
- () => void 0
1388
- );
1389
- return write;
1390
- }
1391
- // ── Enriched summary tracking ──────────────────────────────────────────
1392
- iterationCount = 0;
1393
- toolCallCount = 0;
1394
- toolErrorCount = 0;
1395
- toolBreakdown = {};
1396
- fileChangeCount = 0;
1397
- compactionCount = 0;
1398
- outcome = void 0;
1399
- /**
1400
- * Scrub secrets out of conversation-turn events before they are observed
1401
- * for the summary, written to the JSONL log, or surfaced on resume. Only
1402
- * `user_input` / `llm_response` carry free-form user/model text; other event
1403
- * types either have no secret-bearing content or are already scrubbed
1404
- * upstream (tool results). Returns the event unchanged when no scrubber is
1405
- * configured.
1406
- */
1407
- scrubEvent(event) {
1408
- const s = this.secretScrubber;
1409
- if (!s) return event;
1410
- if (event.type === "user_input") {
1411
- return {
1412
- ...event,
1413
- content: typeof event.content === "string" ? s.scrub(event.content) : s.scrubObject(event.content)
1414
- };
1415
- }
1416
- if (event.type === "llm_response") {
1417
- return { ...event, content: s.scrubObject(event.content) };
1441
+ if (fh) await fh.close().catch(() => void 0);
1418
1442
  }
1419
- return event;
1420
- }
1421
- pendingFileSnapshots = [];
1422
- /** Tracks open tool_use IDs during the current run to serialize on close for resume. */
1423
- openToolUses = /* @__PURE__ */ new Set();
1424
- recordFileChange(input) {
1425
- this.pendingFileSnapshots.push(input);
1426
- }
1427
- get pendingToolUses() {
1428
- return Array.from(this.openToolUses);
1429
1443
  }
1430
- async writeSessionStartLazy() {
1431
- const record = `${JSON.stringify({
1432
- type: this.resumed ? "session_resumed" : "session_start",
1433
- ts: this.startedAt,
1434
- id: this.id,
1435
- model: this.meta.model ?? "unknown",
1436
- provider: this.meta.provider ?? "unknown"
1437
- })}
1438
- `;
1444
+ async list(limit = 20) {
1439
1445
  try {
1440
- await this.enqueueWrite(record);
1441
- } catch {
1442
- }
1443
- }
1444
- async append(event) {
1445
- if (this.closed) return;
1446
- await this.ensureInit();
1447
- const scrubbed = this.scrubEvent(event);
1448
- this.observeForSummary(scrubbed);
1449
- this.writeBuffer.push(scrubbed);
1450
- if (this.writeBuffer.length >= _FileSessionWriter.FLUSH_SIZE) {
1451
- if (this.flushTimer) {
1452
- clearTimeout(this.flushTimer);
1453
- this.flushTimer = null;
1454
- }
1455
- await this.flushBuffer();
1456
- } else {
1457
- this.scheduleFlush();
1458
- }
1459
- }
1460
- async appendBatch(events) {
1461
- if (this.closed || events.length === 0) return;
1462
- await this.ensureInit();
1463
- for (const event of events) {
1464
- const scrubbed = this.scrubEvent(event);
1465
- this.observeForSummary(scrubbed);
1466
- this.writeBuffer.push(scrubbed);
1467
- }
1468
- if (this.writeBuffer.length >= _FileSessionWriter.FLUSH_SIZE) {
1469
- if (this.flushTimer) {
1470
- clearTimeout(this.flushTimer);
1471
- this.flushTimer = null;
1446
+ await ensureDir(this.dir);
1447
+ const indexed = await this.readIndex();
1448
+ if (indexed.length > 0) {
1449
+ indexed.sort((a, b) => {
1450
+ if (a.startedAt < b.startedAt) return 1;
1451
+ if (a.startedAt > b.startedAt) return -1;
1452
+ return a.id.localeCompare(b.id);
1453
+ });
1454
+ return indexed.slice(0, limit);
1472
1455
  }
1473
- await this.flushBuffer();
1474
- } else {
1475
- this.scheduleFlush();
1456
+ return await this.listFromDirectoryScan(limit);
1457
+ } catch {
1458
+ return [];
1476
1459
  }
1477
1460
  }
1478
1461
  /**
1479
- * Flush buffered events to disk immediately. Critical events
1480
- * (user_input, llm_response) call this so they survive SIGKILL/crash
1481
- * instead of sitting in the in-memory buffer for up to 500ms.
1462
+ * List sessions matching filter criteria, using the cached index.
1463
+ * Filters are applied BEFORE sorting and slicing, so the caller gets
1464
+ * exactly `limit` matching sessions not a slice of a larger fetch.
1482
1465
  *
1483
- * Idempotent — cancels any pending timer and writes whatever has
1484
- * accumulated in the buffer. Safe to call even when the buffer
1485
- * is empty (no-op).
1466
+ * This avoids the DefaultSessionReader pattern of fetching 1000 sessions
1467
+ * then linear-filtering: the index is already in memory (readIndex
1468
+ * caches it), and the filter runs over the cached array without any
1469
+ * additional disk I/O.
1486
1470
  */
1487
- async flush() {
1488
- if (this.flushTimer) {
1489
- clearTimeout(this.flushTimer);
1490
- this.flushTimer = null;
1471
+ async listFiltered(criteria) {
1472
+ const limit = criteria.limit ?? 100;
1473
+ try {
1474
+ await ensureDir(this.dir);
1475
+ const indexed = await this.readIndex();
1476
+ if (indexed.length === 0) {
1477
+ const raw = await this.list(Math.max(limit, 100));
1478
+ return raw.filter((s) => matchesSessionFilter(s, criteria)).slice(0, limit);
1479
+ }
1480
+ const filtered = indexed.filter((s) => matchesSessionFilter(s, criteria));
1481
+ filtered.sort((a, b) => {
1482
+ if (a.startedAt < b.startedAt) return 1;
1483
+ if (a.startedAt > b.startedAt) return -1;
1484
+ return a.id.localeCompare(b.id);
1485
+ });
1486
+ return filtered.slice(0, limit);
1487
+ } catch {
1488
+ return [];
1491
1489
  }
1492
- await this.flushBuffer();
1493
1490
  }
1494
- /** Schedule a deferred flush. No-op if a timer is already pending. */
1495
- scheduleFlush() {
1496
- if (this.flushTimer) return;
1497
- this.flushTimer = setTimeout(() => {
1498
- this.flushTimer = null;
1499
- this.flushBuffer().catch(() => {
1500
- });
1501
- }, _FileSessionWriter.FLUSH_INTERVAL_MS);
1491
+ // ── Session index (_index.jsonl) ─────────────────────────────────────────
1492
+ //
1493
+ // One JSON line per closed session, appended atomically on close().
1494
+ // When a session is deleted, a tombstone {action:"delete",id:"..."} is
1495
+ // appended. On read, tombstones filter out matching session entries.
1496
+ // This keeps listing O(lines-in-index) instead of O(files-on-disk).
1497
+ //
1498
+ // The index auto-compacts every N appends to prevent unbounded growth
1499
+ // from tombstones and duplicate entries (resume cycles).
1500
+ indexAppendCount = 0;
1501
+ static COMPACT_EVERY = 30;
1502
+ /** Append a session summary to the index. */
1503
+ async appendToIndex(summary) {
1504
+ try {
1505
+ await ensureDir(this.dir);
1506
+ const line = JSON.stringify(summary) + "\n";
1507
+ await fsp2.appendFile(this.indexFile, line, "utf8");
1508
+ this._indexCache = null;
1509
+ this.invalidateShardManifestBySessionId(summary.id);
1510
+ this.indexAppendCount++;
1511
+ if (this.indexAppendCount >= _DefaultSessionStore.COMPACT_EVERY) {
1512
+ await this.compactIndex();
1513
+ this.indexAppendCount = 0;
1514
+ }
1515
+ } catch {
1516
+ }
1517
+ }
1518
+ /** Append a tombstone entry for a deleted session. */
1519
+ async writeTombstone(id) {
1520
+ try {
1521
+ await ensureDir(this.dir);
1522
+ const line = JSON.stringify({ action: "delete", id }) + "\n";
1523
+ await fsp2.appendFile(this.indexFile, line, "utf8");
1524
+ this._indexCache = null;
1525
+ this.invalidateShardManifestBySessionId(id);
1526
+ this.indexAppendCount++;
1527
+ } catch {
1528
+ }
1502
1529
  }
1503
1530
  /**
1504
- * Flush all buffered events to disk as a single appendFile call.
1505
- * Errors use the same throttled-warning pattern the old per-event
1506
- * append path used — one warning every 5s with a suppressed count.
1507
- * On failure the buffer is cleared (events are best-effort, same as
1508
- * the old per-event path where a failed write was silently dropped).
1531
+ * Compact the index: read all entries, drop tombstones, deduplicate
1532
+ * (keep latest per session), and rewrite. Atomic via temp+rename.
1509
1533
  */
1510
- async flushBuffer() {
1511
- if (this.writeBuffer.length === 0) return;
1512
- const eventCount = this.writeBuffer.length;
1513
- const batch = this.writeBuffer.map((e) => JSON.stringify(e)).join("\n") + "\n";
1514
- this.writeBuffer = [];
1534
+ async compactIndex() {
1515
1535
  const t0 = Date.now();
1516
1536
  let outcome = "success";
1517
1537
  let errorMsg;
1518
1538
  try {
1519
- await this.enqueueWrite(batch);
1539
+ const entries = await this.readIndex();
1540
+ if (entries.length === 0) return;
1541
+ const tmp = `${this.indexFile}.compact.tmp`;
1542
+ const lines = entries.map((s) => JSON.stringify(s)).join("\n") + "\n";
1543
+ await fsp2.writeFile(tmp, lines, "utf8");
1544
+ await fsp2.rename(tmp, this.indexFile);
1545
+ this._indexCache = null;
1520
1546
  } catch (err) {
1521
1547
  outcome = "failure";
1522
1548
  errorMsg = toErrorMessage(err);
1523
- this.appendFailCount += eventCount;
1524
- const now = Date.now();
1525
- if (now - this.lastAppendWarnAt > 5e3) {
1526
- const suppressed = this.appendFailCount - 1;
1527
- const tail = suppressed > 0 ? ` (+${suppressed} suppressed)` : "";
1528
- console.warn(
1529
- "[session] flush failed:",
1530
- toErrorMessage(err),
1531
- tail
1532
- );
1533
- this.lastAppendWarnAt = now;
1534
- this.appendFailCount = 0;
1535
- }
1536
1549
  } finally {
1537
- this.events?.emit("storage.write", {
1538
- sessionId: this.id,
1539
- store: "session",
1540
- filePath: this.filePath,
1541
- operation: "flush",
1542
- outcome,
1543
- durationMs: Date.now() - t0,
1544
- ...errorMsg !== void 0 ? { error: errorMsg } : {},
1545
- ...eventCount !== void 0 ? { eventCount } : {},
1546
- ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
1547
- });
1550
+ this.emitWrite("~compact~", this.indexFile, "compact", outcome, Date.now() - t0, void 0, errorMsg);
1548
1551
  }
1549
1552
  }
1550
- observeForSummary(event) {
1551
- if (event.type === "llm_response") {
1552
- for (const block of event.content) {
1553
- if (block.type === "tool_use") this.openToolUses.add(block.id);
1553
+ /**
1554
+ * Read the index file and return deduplicated session summaries.
1555
+ * Entries with a matching tombstone are filtered out.
1556
+ * Returns empty array when the index doesn't exist or is corrupt.
1557
+ */
1558
+ async readIndex() {
1559
+ let stat10;
1560
+ try {
1561
+ const s = await fsp2.stat(this.indexFile);
1562
+ stat10 = { mtimeMs: s.mtimeMs, size: s.size };
1563
+ } catch {
1564
+ this._indexCache = null;
1565
+ return [];
1566
+ }
1567
+ if (this._indexCache !== null && this._indexCache.mtimeMs === stat10.mtimeMs && this._indexCache.size === stat10.size) {
1568
+ return [...this._indexCache.summaries];
1569
+ }
1570
+ let raw;
1571
+ try {
1572
+ raw = await fsp2.readFile(this.indexFile, "utf8");
1573
+ } catch {
1574
+ this._indexCache = null;
1575
+ return [];
1576
+ }
1577
+ const deleted = /* @__PURE__ */ new Set();
1578
+ const seen = /* @__PURE__ */ new Map();
1579
+ for (const line of raw.split("\n")) {
1580
+ if (!line.trim()) continue;
1581
+ try {
1582
+ const entry = JSON.parse(line);
1583
+ if (entry.action === "delete" && entry.id) {
1584
+ deleted.add(entry.id);
1585
+ seen.delete(entry.id);
1586
+ continue;
1587
+ }
1588
+ if (entry.id && !deleted.has(entry.id)) {
1589
+ seen.set(entry.id, entry);
1590
+ }
1591
+ } catch {
1554
1592
  }
1555
1593
  }
1556
- if (event.type === "tool_use") {
1557
- this.openToolUses.add(event.id);
1558
- } else if (event.type === "tool_call_start") {
1559
- this.toolCallCount++;
1560
- this.toolBreakdown[event.name] = (this.toolBreakdown[event.name] ?? 0) + 1;
1561
- } else if (event.type === "tool_result") {
1562
- this.openToolUses.delete(event.id);
1563
- if (event.isError) {
1564
- this.toolErrorCount++;
1565
- this.outcome = "error";
1594
+ const summaries = Array.from(seen.values());
1595
+ this._indexCache = { ...stat10, summaries };
1596
+ return [...summaries];
1597
+ }
1598
+ /**
1599
+ * Rebuild the index from disk by scanning all sessions and writing a
1600
+ * fresh _index.jsonl. Useful after manual cleanup or index corruption.
1601
+ */
1602
+ async rebuildIndex() {
1603
+ const ids = await this.collectSessionIds(this.dir);
1604
+ const summaries = await Promise.all(ids.map((id) => this.summaryFor(id).catch(() => null)));
1605
+ const valid = summaries.filter((s) => s !== null);
1606
+ const tmp = `${this.indexFile}.tmp`;
1607
+ const lines = valid.map((s) => JSON.stringify(s)).join("\n") + "\n";
1608
+ await fsp2.writeFile(tmp, lines, "utf8");
1609
+ await fsp2.rename(tmp, this.indexFile);
1610
+ this._indexCache = null;
1611
+ return valid.length;
1612
+ }
1613
+ async listFromDirectoryScan(limit) {
1614
+ const shardKeys = await this.collectShardKeys();
1615
+ const shardEntries = await mapWithConcurrency(
1616
+ shardKeys,
1617
+ _DefaultSessionStore.LIST_SCAN_CONCURRENCY,
1618
+ async (shardKey) => await this.readOrBuildShardManifest(shardKey)
1619
+ );
1620
+ const out = [];
1621
+ for (const entry of shardEntries) {
1622
+ for (const summary of entry.summaries) {
1623
+ out.push({ summary, needsBackfill: false });
1566
1624
  }
1567
- } else if (event.type === "file_snapshot") {
1568
- this.fileChangeCount += event.files.length;
1569
- } else if (event.type === "compaction") {
1570
- this.compactionCount++;
1571
1625
  }
1572
- if (event.type === "error" || event.type === "provider_error") {
1573
- this.outcome = "error";
1626
+ out.sort((a, b) => compareSessionSummaries(a.summary, b.summary));
1627
+ const selected = out.slice(0, limit);
1628
+ const summaries = await mapWithConcurrency(
1629
+ selected,
1630
+ Math.min(_DefaultSessionStore.LIST_SCAN_CONCURRENCY, Math.max(1, limit)),
1631
+ async (candidate) => candidate.summary
1632
+ );
1633
+ return summaries.filter((s) => s !== null);
1634
+ }
1635
+ async collectShardKeys() {
1636
+ let entries;
1637
+ try {
1638
+ entries = await fsp2.readdir(this.dir, { withFileTypes: true });
1639
+ } catch {
1640
+ return [""];
1574
1641
  }
1575
- if (event.type === "user_input" && this.summary.title === "(empty session)") {
1576
- this.summary = { ...this.summary, title: userInputTitle(event.content) };
1577
- } else if (event.type === "llm_response") {
1578
- this.tokenIn += event.usage.input;
1579
- this.tokenOut += event.usage.output;
1580
- this.summary = { ...this.summary, tokenTotal: this.tokenIn + this.tokenOut };
1581
- } else if (event.type === "session_end") {
1582
- const total = event.usage.input + event.usage.output;
1583
- if (total > 0) this.summary = { ...this.summary, tokenTotal: total };
1584
- } else if (event.type === "in_flight_start") {
1585
- this.iterationCount++;
1642
+ const shardKeys = [""];
1643
+ for (const entry of entries) {
1644
+ if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
1645
+ if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments") continue;
1646
+ if (entry.isDirectory()) shardKeys.push(entry.name);
1586
1647
  }
1648
+ return shardKeys;
1587
1649
  }
1588
- async close() {
1589
- if (this.closePromise) return this.closePromise;
1590
- this.closePromise = this.doClose();
1591
- return this.closePromise;
1650
+ async readOrBuildShardManifest(shardKey) {
1651
+ const cached = this.shardManifestCache.get(shardKey);
1652
+ if (cached) return cached;
1653
+ const manifestPath = this.shardManifestPath(shardKey);
1654
+ try {
1655
+ const raw = await fsp2.readFile(manifestPath, "utf8");
1656
+ const parsed = JSON.parse(raw);
1657
+ const entry2 = {
1658
+ summaries: Array.isArray(parsed.summaries) ? parsed.summaries : [],
1659
+ ids: Array.isArray(parsed.ids) ? parsed.ids : []
1660
+ };
1661
+ this.shardManifestCache.set(shardKey, entry2);
1662
+ return entry2;
1663
+ } catch {
1664
+ }
1665
+ const refs = await this.collectSessionFilesInShard(shardKey);
1666
+ const candidates = await mapWithConcurrency(
1667
+ refs,
1668
+ _DefaultSessionStore.LIST_SCAN_CONCURRENCY,
1669
+ async (ref) => {
1670
+ const manifest = await this.readSummaryManifest(ref.id);
1671
+ if (manifest) return { summary: manifest, needsBackfill: false };
1672
+ const summary = await this.summaryHeaderFor(ref);
1673
+ if (!summary) return null;
1674
+ const hydrated = await this.summaryFor(summary.id).catch(() => summary);
1675
+ return { summary: hydrated, needsBackfill: false };
1676
+ }
1677
+ );
1678
+ const summaries = candidates.filter((candidate) => candidate !== null).map((candidate) => candidate.summary);
1679
+ summaries.sort(compareSessionSummaries);
1680
+ const entry = { summaries, ids: summaries.map((summary) => summary.id) };
1681
+ this.shardManifestCache.set(shardKey, entry);
1682
+ await atomicWrite(manifestPath, JSON.stringify(entry), { mode: 384 }).catch(() => void 0);
1683
+ return entry;
1684
+ }
1685
+ async collectSessionFilesInShard(shardKey) {
1686
+ const dir = shardKey ? path2.join(this.dir, shardKey) : this.dir;
1687
+ const entries = await this.collectSessionFiles(dir, shardKey);
1688
+ return shardKey ? entries.filter((entry) => entry.id.startsWith(`${shardKey}/`)) : entries.filter((entry) => !entry.id.includes("/"));
1689
+ }
1690
+ async collectSessionFiles(dir, prefix = "", depth = 0) {
1691
+ let entries;
1692
+ try {
1693
+ entries = await fsp2.readdir(dir, { withFileTypes: true });
1694
+ } catch {
1695
+ return [];
1696
+ }
1697
+ const dirEntries = [];
1698
+ const files = [];
1699
+ for (const entry of entries) {
1700
+ if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
1701
+ if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
1702
+ continue;
1703
+ if (entry.isDirectory()) {
1704
+ dirEntries.push(entry);
1705
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
1706
+ if (entry.name === "_index.jsonl") continue;
1707
+ const base = entry.name.replace(/\.jsonl$/, "");
1708
+ const id = prefix ? `${prefix}/${base}` : base;
1709
+ files.push({ id, filePath: path2.join(dir, entry.name) });
1710
+ }
1711
+ }
1712
+ const childFileArrays = await Promise.all(
1713
+ dirEntries.map((entry) => {
1714
+ const childPrefix = depth === 0 ? entry.name : `${prefix}/${entry.name}`;
1715
+ return this.collectSessionFiles(path2.join(dir, entry.name), childPrefix, depth + 1);
1716
+ })
1717
+ );
1718
+ return [...childFileArrays.flat(), ...files];
1592
1719
  }
1593
- async doClose() {
1594
- this.closed = true;
1595
- if (this.flushTimer) {
1596
- clearTimeout(this.flushTimer);
1597
- this.flushTimer = null;
1720
+ /** Recursively collect session IDs from date-shard subdirectories.
1721
+ * IDs include the date-prefix path (e.g. "2026-06-06/17-46-57Z_…").
1722
+ * Skips `.jsonl`/`.summary.json` root files, dot-files, and
1723
+ * sub-directories that belong to fleet/subagent sessions. */
1724
+ async collectSessionIds(dir, prefix = "", depth = 0) {
1725
+ let entries;
1726
+ try {
1727
+ entries = await fsp2.readdir(dir, { withFileTypes: true });
1728
+ } catch {
1729
+ return [];
1598
1730
  }
1599
- await this.flushBuffer();
1600
- await this.writeChain;
1601
- this.summary = {
1602
- ...this.summary,
1603
- endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1604
- iterationCount: this.iterationCount,
1605
- toolCallCount: this.toolCallCount,
1606
- toolErrorCount: this.toolErrorCount,
1607
- fileChangeCount: this.fileChangeCount,
1608
- compactionCount: this.compactionCount > 0 ? this.compactionCount : void 0,
1609
- toolBreakdown: { ...this.toolBreakdown },
1610
- outcome: this.outcome ?? "completed"
1611
- };
1612
- if (this.manifestFile) {
1613
- const t0 = Date.now();
1614
- let outcome = "success";
1615
- let errorMsg;
1616
- try {
1617
- await atomicWrite(this.manifestFile, JSON.stringify(this.summary), { mode: 384 });
1618
- } catch (err) {
1619
- outcome = "failure";
1620
- errorMsg = toErrorMessage(err);
1621
- } finally {
1622
- this.events?.emit("storage.write", {
1623
- sessionId: this.id,
1624
- store: "session",
1625
- filePath: this.manifestFile,
1626
- operation: "close",
1627
- outcome,
1628
- durationMs: Date.now() - t0,
1629
- ...errorMsg !== void 0 ? { error: errorMsg } : {},
1630
- ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
1631
- });
1731
+ const dirEntries = [];
1732
+ const fileIds = [];
1733
+ for (const entry of entries) {
1734
+ if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
1735
+ if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
1736
+ continue;
1737
+ if (entry.isDirectory()) {
1738
+ dirEntries.push(entry);
1739
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
1740
+ if (entry.name === "_index.jsonl") continue;
1741
+ const base = entry.name.replace(/\.jsonl$/, "");
1742
+ fileIds.push(prefix ? `${prefix}/${base}` : base);
1632
1743
  }
1633
1744
  }
1634
- const idxT0 = Date.now();
1635
- let idxOutcome = "success";
1636
- let idxError;
1745
+ const childIdArrays = await Promise.all(
1746
+ dirEntries.map((entry) => {
1747
+ const childPrefix = depth === 0 ? entry.name : `${prefix}/${entry.name}`;
1748
+ return this.collectSessionIds(path2.join(dir, entry.name), childPrefix, depth + 1);
1749
+ })
1750
+ );
1751
+ return [...childIdArrays.flat(), ...fileIds];
1752
+ }
1753
+ async summaryFor(id) {
1754
+ const manifest = this.sessionPath(id, ".summary.json");
1755
+ const t0 = Date.now();
1756
+ let outcome = "success";
1757
+ let errorMsg;
1758
+ const fromManifest = await this.readSummaryManifest(id, t0);
1759
+ if (fromManifest) return fromManifest;
1637
1760
  try {
1638
- await this.onCloseCb?.(this.summary);
1639
- } catch (err) {
1640
- idxOutcome = "failure";
1641
- idxError = toErrorMessage(err);
1642
- } finally {
1643
- this.events?.emit("storage.write", {
1644
- sessionId: this.summary.id,
1645
- store: "session",
1646
- filePath: this.filePath,
1647
- operation: "index_append",
1648
- outcome: idxOutcome,
1649
- durationMs: Date.now() - idxT0,
1650
- ...idxError !== void 0 ? { error: idxError } : {},
1651
- ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
1761
+ const full = this.sessionPath(id, ".jsonl");
1762
+ const stat10 = await fsp2.stat(full);
1763
+ const summary = await this.summarize(id, stat10.mtime.toISOString());
1764
+ await atomicWrite(manifest, JSON.stringify(summary), { mode: 384 }).catch((err) => {
1765
+ const msg = toErrorMessage(err);
1766
+ this.emitError(id, manifest, "summary_fallback", msg, true);
1767
+ console.warn(JSON.stringify({
1768
+ level: "warn",
1769
+ event: "session_store.manifest_write_failed",
1770
+ sessionId: id,
1771
+ message: msg,
1772
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1773
+ }));
1652
1774
  });
1775
+ outcome = "failure";
1776
+ errorMsg = "summary fallback \xE2\u20AC\u201D manifest rebuilt";
1777
+ this.emitRead(id, manifest, "summary", outcome, Date.now() - t0, errorMsg);
1778
+ return summary;
1779
+ } catch (err) {
1780
+ outcome = "failure";
1781
+ errorMsg = toErrorMessage(err);
1782
+ this.emitRead(id, manifest, "summary", outcome, Date.now() - t0, errorMsg);
1783
+ return {
1784
+ id,
1785
+ title: "(damaged)",
1786
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1787
+ model: "unknown",
1788
+ provider: "unknown",
1789
+ tokenTotal: 0
1790
+ };
1653
1791
  }
1792
+ }
1793
+ async readSummaryManifest(id, startTime = Date.now()) {
1794
+ const manifest = this.sessionPath(id, ".summary.json");
1654
1795
  try {
1655
- await this.handle.close();
1796
+ const raw = await fsp2.readFile(manifest, "utf8");
1797
+ this.emitRead(id, manifest, "summary", "success", Date.now() - startTime);
1798
+ return JSON.parse(raw);
1656
1799
  } catch {
1800
+ return null;
1657
1801
  }
1658
1802
  }
1659
- async writeCheckpoint(promptIndex, promptPreview) {
1660
- const fileCount = this.pendingFileSnapshots.length;
1661
- if (fileCount > 0) {
1662
- await this.writeFileSnapshot(promptIndex, [...this.pendingFileSnapshots]);
1663
- this.pendingFileSnapshots = [];
1664
- }
1665
- await this.append({
1666
- type: "checkpoint",
1667
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1668
- promptIndex,
1669
- promptPreview
1670
- });
1671
- this.events?.emit("checkpoint.written", {
1672
- promptIndex,
1673
- promptPreview,
1674
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1675
- fileCount
1676
- });
1677
- }
1678
- async writeFileSnapshot(promptIndex, files) {
1679
- await this.append({
1680
- type: "file_snapshot",
1681
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1682
- promptIndex,
1683
- files
1684
- });
1685
- }
1686
- /**
1687
- * Truncate the session file to the checkpoint with the given promptIndex,
1688
- * removing all events that follow it. Uses a single-pass byte-offset scan
1689
- * so post-checkpoint content is never read or parsed — O(1) memory instead
1690
- * of O(N) JSON.parse calls over the full file.
1691
- */
1692
- async truncateToCheckpoint(targetPromptIndex) {
1693
- if (!this.filePath) return 0;
1694
- if (this.flushTimer) {
1695
- clearTimeout(this.flushTimer);
1696
- this.flushTimer = null;
1803
+ async summaryHeaderFor(ref) {
1804
+ let mtime = (/* @__PURE__ */ new Date(0)).toISOString();
1805
+ try {
1806
+ const stat10 = await fsp2.stat(ref.filePath);
1807
+ if (!stat10.isFile()) {
1808
+ return {
1809
+ id: ref.id,
1810
+ title: "(damaged)",
1811
+ startedAt: stat10.mtime.toISOString(),
1812
+ model: "unknown",
1813
+ provider: "unknown",
1814
+ tokenTotal: 0
1815
+ };
1816
+ }
1817
+ mtime = stat10.mtime.toISOString();
1818
+ } catch {
1819
+ return null;
1697
1820
  }
1698
- await this.flushBuffer();
1699
- await this.writeChain;
1700
- const CHUNK_SIZE = 65536;
1701
- let fd;
1702
- let fileOffset = 0;
1703
- let lineStartOffset = 0;
1704
- let checkpointByteOffset = -1;
1705
- let removedCount = 0;
1706
- let targetCheckpointSeen = false;
1707
1821
  try {
1708
- fd = await fsp.open(this.filePath, "r", 384);
1709
- while (true) {
1710
- const buf = Buffer.alloc(CHUNK_SIZE);
1711
- const { bytesRead } = await fd.read(buf, 0, CHUNK_SIZE, fileOffset);
1712
- if (bytesRead === 0) break;
1713
- let chunkPos = 0;
1714
- while (chunkPos < bytesRead) {
1715
- const idx = buf.indexOf("\n", chunkPos);
1716
- if (idx === -1) {
1717
- lineStartOffset = fileOffset + chunkPos;
1718
- break;
1719
- }
1720
- if (checkpointByteOffset !== -1) {
1721
- removedCount++;
1722
- } else {
1723
- const lineBytes = buf.subarray(chunkPos, idx);
1724
- const line = new TextDecoder("utf-8", { fatal: false }).decode(lineBytes);
1725
- if (line.trim()) {
1726
- try {
1727
- const event = JSON.parse(line);
1728
- if (event.type === "checkpoint") {
1729
- if (event.promptIndex === targetPromptIndex) {
1730
- checkpointByteOffset = lineStartOffset;
1731
- targetCheckpointSeen = true;
1732
- } else if (event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
1733
- checkpointByteOffset = lineStartOffset;
1734
- }
1735
- } else if (targetCheckpointSeen && event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
1736
- removedCount++;
1737
- } else if (targetCheckpointSeen && event.promptIndex === void 0) {
1738
- removedCount++;
1739
- } else if (!targetCheckpointSeen && event.promptIndex === void 0) {
1740
- removedCount++;
1741
- } else if (!targetCheckpointSeen && event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
1742
- removedCount++;
1743
- }
1744
- } catch {
1745
- }
1746
- }
1747
- }
1748
- chunkPos = idx + 1;
1749
- lineStartOffset = fileOffset + chunkPos;
1822
+ for await (const event of this.iterSessionEvents(ref.filePath)) {
1823
+ if (event.type === "session_start") {
1824
+ return {
1825
+ id: ref.id,
1826
+ title: "(empty session)",
1827
+ startedAt: event.ts,
1828
+ model: event.model ?? "unknown",
1829
+ provider: event.provider ?? "unknown",
1830
+ tokenTotal: 0
1831
+ };
1750
1832
  }
1751
- fileOffset += bytesRead;
1752
- if (chunkPos >= bytesRead) {
1753
- lineStartOffset = fileOffset;
1833
+ }
1834
+ return {
1835
+ id: ref.id,
1836
+ title: "(empty session)",
1837
+ startedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
1838
+ model: "unknown",
1839
+ provider: "unknown",
1840
+ tokenTotal: 0
1841
+ };
1842
+ } catch {
1843
+ return {
1844
+ id: ref.id,
1845
+ title: "(damaged)",
1846
+ startedAt: mtime,
1847
+ model: "unknown",
1848
+ provider: "unknown",
1849
+ tokenTotal: 0
1850
+ };
1851
+ }
1852
+ }
1853
+ /**
1854
+ * Delete a session and all associated files: JSONL, summary, plan/todos
1855
+ * sidecars, and the session directory (fleet.json, shared/, subagents/).
1856
+ *
1857
+ * Individual file deletions are best-effort (logged as structured warnings),
1858
+ * but a tombstone is always written so readIndex() filters this session out.
1859
+ * If the session directory itself can't be removed, the error is surfaced
1860
+ * to the caller so prune() can report it.
1861
+ */
1862
+ async deleteSession(id) {
1863
+ const jsonlPath = this.sessionPath(id, ".jsonl");
1864
+ const summaryPath = this.sessionPath(id, ".summary.json");
1865
+ const shardDir = path2.dirname(path2.join(this.dir, id));
1866
+ const base = path2.basename(id);
1867
+ const sessDir = path2.join(shardDir, base);
1868
+ const deletions = [
1869
+ fsp2.unlink(jsonlPath),
1870
+ fsp2.unlink(summaryPath),
1871
+ fsp2.unlink(path2.join(shardDir, `${base}.plan.json`)),
1872
+ fsp2.unlink(path2.join(shardDir, `${base}.todos.json`))
1873
+ ];
1874
+ const results = await Promise.allSettled(deletions);
1875
+ for (const r of results) {
1876
+ if (r.status === "rejected") {
1877
+ const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
1878
+ if (r.reason?.code !== "ENOENT") {
1879
+ console.warn(JSON.stringify({
1880
+ level: "warn",
1881
+ event: "session_store.delete_failed",
1882
+ sessionId: id,
1883
+ message: msg,
1884
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1885
+ }));
1754
1886
  }
1755
1887
  }
1756
- } finally {
1757
- await fd?.close();
1758
1888
  }
1759
- if (checkpointByteOffset === -1) return 0;
1760
- await this.writeChain;
1761
- await this.handle.close();
1762
- const tmpPath = `${this.filePath}.rewind.tmp`;
1763
- const src = await fsp.open(this.filePath, "r", 384);
1889
+ await fsp2.rm(sessDir, { recursive: true, force: true }).catch((err) => {
1890
+ console.warn(JSON.stringify({
1891
+ level: "warn",
1892
+ event: "session_store.rmdir_failed",
1893
+ sessionId: id,
1894
+ message: toErrorMessage(err),
1895
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1896
+ }));
1897
+ });
1898
+ await this.writeTombstone(id);
1899
+ }
1900
+ async delete(id) {
1901
+ await this.deleteSession(id);
1902
+ }
1903
+ async prune(maxAgeDays = 30) {
1904
+ const cutoff = Date.now() - maxAgeDays * 864e5;
1905
+ let deleted = 0;
1906
+ let activeSessionId = null;
1764
1907
  try {
1765
- const statResult = await src.stat();
1766
- const totalSize = statResult.size;
1767
- const prefixBytes = checkpointByteOffset;
1768
- let newlineAfterCheckpoint = prefixBytes;
1769
- if (prefixBytes < totalSize) {
1770
- const probeBuf = Buffer.alloc(Math.min(CHUNK_SIZE, totalSize - prefixBytes));
1771
- const { bytesRead: probeRead } = await src.read(probeBuf, 0, probeBuf.length, prefixBytes);
1772
- if (probeRead > 0) {
1773
- const nl = probeBuf.indexOf("\n");
1774
- newlineAfterCheckpoint = nl !== -1 ? prefixBytes + nl + 1 : totalSize;
1775
- }
1776
- } else {
1777
- newlineAfterCheckpoint = totalSize;
1908
+ const raw = await fsp2.readFile(path2.join(this.dir, "active.json"), "utf8");
1909
+ const active = JSON.parse(raw);
1910
+ activeSessionId = active.sessionId ?? null;
1911
+ } catch {
1912
+ }
1913
+ const isPrunableJsonl = (name) => name.endsWith(".jsonl") && name !== "_index.jsonl" && name !== "_mailbox.jsonl" && !name.endsWith(".replay.jsonl") && !name.endsWith(".audit.jsonl");
1914
+ const pruneFile = async (dir, name, prefix) => {
1915
+ const jsonlPath = path2.join(dir, name);
1916
+ try {
1917
+ const stat10 = await fsp2.stat(jsonlPath);
1918
+ if (stat10.mtimeMs >= cutoff) return;
1919
+ } catch {
1920
+ return;
1921
+ }
1922
+ const base = name.replace(/\.jsonl$/, "");
1923
+ const id = prefix ? `${prefix}/${base}` : base;
1924
+ if (activeSessionId && id === activeSessionId) return;
1925
+ await this.deleteSession(id);
1926
+ deleted++;
1927
+ };
1928
+ const entries = await fsp2.readdir(this.dir, { withFileTypes: true }).catch(() => []);
1929
+ for (const entry of entries) {
1930
+ if (entry.isFile()) {
1931
+ if (isPrunableJsonl(entry.name)) await pruneFile(this.dir, entry.name, "");
1932
+ continue;
1933
+ }
1934
+ if (!entry.isDirectory()) continue;
1935
+ const dateDir = path2.join(this.dir, entry.name);
1936
+ const files = await fsp2.readdir(dateDir, { withFileTypes: true }).catch(() => []);
1937
+ for (const file of files) {
1938
+ if (!file.isFile() || !isPrunableJsonl(file.name)) continue;
1939
+ await pruneFile(dateDir, file.name, entry.name);
1778
1940
  }
1779
- const writeFd = await fsp.open(tmpPath, "w", 384);
1941
+ }
1942
+ if (deleted > 0) {
1943
+ await this.compactIndex().catch(() => void 0);
1944
+ }
1945
+ for (const entry of entries) {
1946
+ if (!entry.isDirectory()) continue;
1947
+ const dateDir = path2.join(this.dir, entry.name);
1780
1948
  try {
1781
- let readOffset = 0;
1782
- while (readOffset < newlineAfterCheckpoint) {
1783
- const toCopy = Math.min(CHUNK_SIZE, newlineAfterCheckpoint - readOffset);
1784
- const copyBuf = Buffer.alloc(toCopy);
1785
- const { bytesRead: r } = await src.read(copyBuf, 0, toCopy, readOffset);
1786
- if (r === 0) break;
1787
- await writeFd.write(copyBuf, 0, r);
1788
- readOffset += r;
1789
- }
1790
- const raw = await fsp.readFile(this.filePath);
1791
- const tail = raw.subarray(newlineAfterCheckpoint).toString("utf8");
1792
- for (const line of tail.split("\n")) {
1793
- if (!line.trim()) continue;
1794
- try {
1795
- JSON.parse(line);
1796
- } catch {
1797
- await writeFd.write(`${line}
1798
- `, void 0, "utf8");
1799
- }
1949
+ const remaining = await fsp2.readdir(dateDir);
1950
+ if (remaining.length === 0) {
1951
+ await fsp2.rmdir(dateDir).catch(() => void 0);
1800
1952
  }
1801
- } finally {
1802
- await writeFd.close();
1953
+ } catch {
1803
1954
  }
1804
- await src.close();
1805
- await fsp.rename(tmpPath, this.filePath);
1806
- this.handle = await fsp.open(this.filePath, "a", 384);
1807
- } catch (err) {
1808
- await fsp.unlink(tmpPath).catch(() => void 0);
1809
- this.handle = await fsp.open(this.filePath, "a", 384).catch(() => this.handle);
1810
- throw err;
1811
1955
  }
1812
- await this.append({
1813
- type: "rewound",
1814
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1815
- toPromptIndex: targetPromptIndex,
1816
- revertedFiles: []
1817
- });
1818
- this.events?.emit("session.rewound", {
1819
- toPromptIndex: targetPromptIndex,
1820
- revertedFiles: [],
1821
- removedEvents: removedCount
1822
- });
1823
- return removedCount;
1956
+ return deleted;
1824
1957
  }
1825
- async clearSession() {
1826
- if (!this.filePath) return;
1827
- if (this.flushTimer) {
1828
- clearTimeout(this.flushTimer);
1829
- this.flushTimer = null;
1830
- }
1831
- this.writeBuffer = [];
1832
- await this.writeChain;
1958
+ async clearHistory(id) {
1959
+ await this.ensureShardDir(id);
1960
+ const file = this.sessionPath(id, ".jsonl");
1961
+ const meta = this.sessionPath(id, ".summary.json");
1833
1962
  const record = `${JSON.stringify({
1834
1963
  type: "session_start",
1835
1964
  ts: (/* @__PURE__ */ new Date()).toISOString(),
1836
- id: this.id,
1837
- model: this.meta.model ?? "unknown",
1838
- provider: this.meta.provider ?? "unknown"
1965
+ id,
1966
+ model: "unknown",
1967
+ provider: "unknown"
1839
1968
  })}
1840
1969
  `;
1841
- await fsp.writeFile(this.filePath, record, "utf8");
1970
+ await fsp2.writeFile(file, record, "utf8");
1971
+ await fsp2.unlink(meta).catch(() => void 0);
1842
1972
  }
1843
- /**
1844
- * Idea #1 — write an in-flight marker. The agent loop should call
1845
- * this at the start of each long-running operation; a matching
1846
- * `clearInFlightMarker` follows on clean exit. A stale marker
1847
- * (no end) is what `SessionRecovery.detectStale` looks for.
1848
- */
1849
- async writeInFlightMarker(context) {
1850
- if (!context || context.length > 500) {
1851
- throw new Error("In-flight context must be 1..500 chars");
1973
+ async summarize(id, mtime) {
1974
+ try {
1975
+ const file = this.sessionPath(id, ".jsonl");
1976
+ let title = "(empty session)";
1977
+ let startedAt = (/* @__PURE__ */ new Date(0)).toISOString();
1978
+ let endedAt;
1979
+ let model = "unknown";
1980
+ let provider = "unknown";
1981
+ let tokenIn = 0;
1982
+ let tokenOut = 0;
1983
+ let iterationCount = 0;
1984
+ let toolCallCount = 0;
1985
+ let toolErrorCount = 0;
1986
+ let fileChangeCount = 0;
1987
+ const toolBreakdown = {};
1988
+ let outcome;
1989
+ let lastEventType;
1990
+ let hasError = false;
1991
+ let sawStart = false;
1992
+ for await (const e of this.iterSessionEvents(file)) {
1993
+ lastEventType = e.type;
1994
+ if (e.type === "session_start") {
1995
+ if (!sawStart) {
1996
+ sawStart = true;
1997
+ startedAt = e.ts;
1998
+ model = e.model ?? "unknown";
1999
+ provider = e.provider ?? "unknown";
2000
+ }
2001
+ } else if (e.type === "session_end") {
2002
+ endedAt = e.ts;
2003
+ } else if (e.type === "user_input") {
2004
+ if (title === "(empty session)") title = userInputTitle(e.content);
2005
+ } else if (e.type === "llm_response") {
2006
+ tokenIn += e.usage.input ?? 0;
2007
+ tokenOut += e.usage.output ?? 0;
2008
+ } else if (e.type === "in_flight_start") iterationCount++;
2009
+ else if (e.type === "tool_call_start") {
2010
+ toolCallCount++;
2011
+ toolBreakdown[e.name] = (toolBreakdown[e.name] ?? 0) + 1;
2012
+ } else if (e.type === "tool_result" && e.isError) toolErrorCount++;
2013
+ else if (e.type === "file_snapshot") fileChangeCount += e.files.length;
2014
+ else if (e.type === "error" || e.type === "provider_error") hasError = true;
2015
+ }
2016
+ if (lastEventType === "session_end") {
2017
+ outcome = "completed";
2018
+ } else if (lastEventType === "in_flight_start") {
2019
+ outcome = "aborted";
2020
+ } else if (hasError) {
2021
+ outcome = "error";
2022
+ }
2023
+ return {
2024
+ id,
2025
+ title,
2026
+ startedAt,
2027
+ endedAt,
2028
+ model,
2029
+ provider,
2030
+ tokenTotal: tokenIn + tokenOut,
2031
+ iterationCount: iterationCount > 0 ? iterationCount : void 0,
2032
+ toolCallCount: toolCallCount > 0 ? toolCallCount : void 0,
2033
+ toolErrorCount: toolErrorCount > 0 ? toolErrorCount : void 0,
2034
+ fileChangeCount: fileChangeCount > 0 ? fileChangeCount : void 0,
2035
+ toolBreakdown: Object.keys(toolBreakdown).length > 0 ? toolBreakdown : {},
2036
+ outcome
2037
+ };
2038
+ } catch {
2039
+ return {
2040
+ id,
2041
+ title: "(damaged)",
2042
+ startedAt: mtime,
2043
+ model: "unknown",
2044
+ provider: "unknown",
2045
+ tokenTotal: 0
2046
+ };
2047
+ }
2048
+ }
2049
+ async *iterSessionEvents(file) {
2050
+ const stream = createReadStream(file, { encoding: "utf8" });
2051
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
2052
+ try {
2053
+ for await (const line of lines) {
2054
+ if (!line.trim()) continue;
2055
+ try {
2056
+ const parsed = JSON.parse(line);
2057
+ if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
2058
+ yield parsed;
2059
+ }
2060
+ } catch {
2061
+ }
2062
+ }
2063
+ } finally {
2064
+ lines.close();
2065
+ stream.destroy();
1852
2066
  }
1853
- await this.append({
1854
- type: "in_flight_start",
1855
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1856
- context
1857
- });
1858
- this.events?.emit("in_flight.started", { context, ts: (/* @__PURE__ */ new Date()).toISOString() });
1859
- }
1860
- /**
1861
- * Idea #1 — close the in-flight marker. Idempotent in spirit
1862
- * (you can call it after a successful iteration even if you
1863
- * didn't open one this round) — but the session log records
1864
- * every call so postmortem tooling can see "the agent finished
1865
- * cleanly X times, then died without finishing Y".
1866
- */
1867
- async clearInFlightMarker(reason) {
1868
- await this.append({
1869
- type: "in_flight_end",
1870
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1871
- reason
1872
- });
1873
- this.events?.emit("in_flight.ended", { reason, ts: (/* @__PURE__ */ new Date()).toISOString() });
1874
2067
  }
1875
2068
  };
1876
- function userInputTitle(content) {
1877
- const text = typeof content === "string" ? content : content.filter((b) => b.type === "text").map((b) => b.text).join(" ");
1878
- return (text || "(non-text input)").slice(0, 60);
2069
+ function extractToolCallEnds(events) {
2070
+ const result = [];
2071
+ for (const e of events) {
2072
+ if (e.type === "tool_call_end") {
2073
+ result.push({
2074
+ name: e.name,
2075
+ id: e.id,
2076
+ durationMs: e.durationMs,
2077
+ ok: e.ok ?? false,
2078
+ outputBytes: e.outputBytes,
2079
+ outputTokens: e.outputTokens,
2080
+ outputLines: e.outputLines
2081
+ });
2082
+ }
2083
+ }
2084
+ return result;
1879
2085
  }
1880
2086
  function compareSessionSummaries(a, b) {
1881
2087
  if (a.startedAt < b.startedAt) return 1;
1882
2088
  if (a.startedAt > b.startedAt) return -1;
1883
2089
  return a.id.localeCompare(b.id);
1884
2090
  }
2091
+ function matchesSessionFilter(s, criteria) {
2092
+ if (criteria.since && s.startedAt < criteria.since) return false;
2093
+ if (criteria.until && s.startedAt > criteria.until) return false;
2094
+ if (criteria.provider && s.provider !== criteria.provider) return false;
2095
+ if (criteria.model && s.model !== criteria.model) return false;
2096
+ if (criteria.minTokens !== void 0 && s.tokenTotal < criteria.minTokens) return false;
2097
+ if (criteria.titleContains) {
2098
+ const needle = criteria.titleContains.toLowerCase();
2099
+ if (!s.title.toLowerCase().includes(needle)) return false;
2100
+ }
2101
+ return true;
2102
+ }
1885
2103
  async function mapWithConcurrency(items, concurrency, fn) {
1886
2104
  if (items.length === 0) return [];
1887
2105
  const out = new Array(items.length);
@@ -1950,7 +2168,7 @@ var QueueStore = class {
1950
2168
  const t0 = Date.now();
1951
2169
  let raw;
1952
2170
  try {
1953
- raw = await fsp.readFile(this.file, "utf8");
2171
+ raw = await fsp2.readFile(this.file, "utf8");
1954
2172
  } catch (err) {
1955
2173
  const code = err.code;
1956
2174
  if (code === "ENOENT") {
@@ -2031,7 +2249,7 @@ var QueueStore = class {
2031
2249
  async clear() {
2032
2250
  const t0 = Date.now();
2033
2251
  try {
2034
- await fsp.unlink(this.file);
2252
+ await fsp2.unlink(this.file);
2035
2253
  this.events?.emit("storage.write", {
2036
2254
  sessionId: this.traceId ?? "~boot~",
2037
2255
  store: "queue",
@@ -2088,7 +2306,7 @@ var DefaultAttachmentStore = class {
2088
2306
  let spooledPath;
2089
2307
  let data = input.data;
2090
2308
  if (this.spoolDir && bytes >= this.spoolThreshold) {
2091
- await fsp.mkdir(this.spoolDir, { recursive: true });
2309
+ await fsp2.mkdir(this.spoolDir, { recursive: true });
2092
2310
  spooledPath = path2.join(this.spoolDir, `${id}.bin`);
2093
2311
  await atomicWrite(spooledPath, input.data, {
2094
2312
  encoding: input.kind === "image" ? "base64" : "utf8"
@@ -2151,7 +2369,7 @@ var DefaultAttachmentStore = class {
2151
2369
  for (const att of this.items.values()) {
2152
2370
  if (att.path) toDelete.push(att.path);
2153
2371
  }
2154
- await Promise.all(toDelete.map((p) => fsp.unlink(p).catch(() => void 0)));
2372
+ await Promise.all(toDelete.map((p) => fsp2.unlink(p).catch(() => void 0)));
2155
2373
  }
2156
2374
  this.items.clear();
2157
2375
  this.refs.length = 0;
@@ -2159,7 +2377,7 @@ var DefaultAttachmentStore = class {
2159
2377
  }
2160
2378
  async toBlock(att) {
2161
2379
  if (att.kind === "image") {
2162
- const data = att.data ?? (att.path ? await fsp.readFile(att.path, { encoding: "base64" }) : "");
2380
+ const data = att.data ?? (att.path ? await fsp2.readFile(att.path, { encoding: "base64" }) : "");
2163
2381
  return {
2164
2382
  type: "image",
2165
2383
  source: {
@@ -2169,7 +2387,7 @@ var DefaultAttachmentStore = class {
2169
2387
  }
2170
2388
  };
2171
2389
  }
2172
- const raw = att.data ?? (att.path ? await fsp.readFile(att.path, "utf8") : "");
2390
+ const raw = att.data ?? (att.path ? await fsp2.readFile(att.path, "utf8") : "");
2173
2391
  const label = att.meta.filename ? `<file path="${att.meta.filename}">` : "<pasted>";
2174
2392
  const close = att.meta.filename ? "</file>" : "</pasted>";
2175
2393
  return { type: "text", text: `${label}
@@ -2304,7 +2522,7 @@ var FileMemoryBackend = class {
2304
2522
  await ensureDir(path2.dirname(file));
2305
2523
  let existing = "";
2306
2524
  try {
2307
- existing = await fsp.readFile(file, "utf8");
2525
+ existing = await fsp2.readFile(file, "utf8");
2308
2526
  } catch {
2309
2527
  }
2310
2528
  const id = `mem_${Date.now()}_${randomUUID().slice(0, 8)}`;
@@ -2321,7 +2539,7 @@ ${line}`;
2321
2539
  return withFileLock(file, async () => {
2322
2540
  let existing;
2323
2541
  try {
2324
- existing = await fsp.readFile(file, "utf8");
2542
+ existing = await fsp2.readFile(file, "utf8");
2325
2543
  } catch {
2326
2544
  return 0;
2327
2545
  }
@@ -2357,7 +2575,7 @@ ${line}`;
2357
2575
  async readAll(scope, filePath) {
2358
2576
  const file = this.resolveFile(filePath, scope);
2359
2577
  try {
2360
- return await fsp.readFile(file, "utf8");
2578
+ return await fsp2.readFile(file, "utf8");
2361
2579
  } catch {
2362
2580
  return "";
2363
2581
  }
@@ -2392,7 +2610,7 @@ ${line}`;
2392
2610
  const file = this.resolveFile(filePath, scope);
2393
2611
  let existing;
2394
2612
  try {
2395
- existing = await fsp.readFile(file, "utf8");
2613
+ existing = await fsp2.readFile(file, "utf8");
2396
2614
  } catch {
2397
2615
  return 0;
2398
2616
  }
@@ -2412,7 +2630,7 @@ ${line}`;
2412
2630
  const next = lines.join("\n");
2413
2631
  const backup = `${file}.bak.${Date.now()}`;
2414
2632
  try {
2415
- await fsp.copyFile(file, backup);
2633
+ await fsp2.copyFile(file, backup);
2416
2634
  await pruneConsolidateBackups(file);
2417
2635
  } catch {
2418
2636
  }
@@ -2428,11 +2646,11 @@ async function pruneConsolidateBackups(file) {
2428
2646
  const dir = path2.dirname(file);
2429
2647
  const base = path2.basename(file);
2430
2648
  const prefix = `${base}.bak.`;
2431
- const backups = (await fsp.readdir(dir)).filter((name) => name.startsWith(prefix)).sort().reverse();
2649
+ const backups = (await fsp2.readdir(dir)).filter((name) => name.startsWith(prefix)).sort().reverse();
2432
2650
  await Promise.all(
2433
2651
  backups.slice(MAX_MEMORY_CONSOLIDATE_BACKUPS).map(async (name) => {
2434
2652
  try {
2435
- await fsp.unlink(path2.join(dir, name));
2653
+ await fsp2.unlink(path2.join(dir, name));
2436
2654
  } catch {
2437
2655
  }
2438
2656
  })
@@ -2987,9 +3205,9 @@ ${body.trim()}`);
2987
3205
  if (!this.persistBackup || scope === "project-agents") return;
2988
3206
  try {
2989
3207
  const content = await this.backend.readAll(scope, this.files[scope]);
2990
- const { writeFile: writeFile7, mkdir: mkdir7 } = await import('fs/promises');
3208
+ const { writeFile: writeFile8, mkdir: mkdir7 } = await import('fs/promises');
2991
3209
  await mkdir7(this.backupDir, { recursive: true });
2992
- await writeFile7(`${this.backupDir}/${scope}.md`, content, "utf8");
3210
+ await writeFile8(`${this.backupDir}/${scope}.md`, content, "utf8");
2993
3211
  } catch {
2994
3212
  }
2995
3213
  }
@@ -3004,7 +3222,7 @@ function labelOf(scope) {
3004
3222
  return "User memory";
3005
3223
  }
3006
3224
  }
3007
- var GraphMemoryBackend = class {
3225
+ var GraphMemoryBackend = class _GraphMemoryBackend {
3008
3226
  kind = "graph";
3009
3227
  file;
3010
3228
  graphFile;
@@ -3013,6 +3231,14 @@ var GraphMemoryBackend = class {
3013
3231
  edges = [];
3014
3232
  loadedScope = null;
3015
3233
  loaded = false;
3234
+ /**
3235
+ * Inverted index for O(1) term → node lookup during search.
3236
+ * Built incrementally on remember/forget, rebuilt on loadGraph if absent.
3237
+ * Maps lowercase term (min 3 chars) → Set of node IDs containing that term.
3238
+ */
3239
+ invertedIndex = /* @__PURE__ */ new Map();
3240
+ /** Minimum term length to index — avoids noise from 1-2 char fragments. */
3241
+ static MIN_TERM_LEN = 3;
3016
3242
  /**
3017
3243
  * Promise that resolves when the current in-flight _saveGraph completes.
3018
3244
  * Tests call flush() to await this before deleting the backend or its temp dir.
@@ -3035,6 +3261,7 @@ var GraphMemoryBackend = class {
3035
3261
  existing.type = entry.type;
3036
3262
  existing.tags = entry.tags;
3037
3263
  existing.priority = entry.priority;
3264
+ this.updateNodeIndex(nodeId, entry);
3038
3265
  } else {
3039
3266
  this.nodes.set(nodeId, {
3040
3267
  id: nodeId,
@@ -3045,6 +3272,7 @@ var GraphMemoryBackend = class {
3045
3272
  tags: entry.tags,
3046
3273
  priority: entry.priority
3047
3274
  });
3275
+ this.indexNode(nodeId, entry);
3048
3276
  const recentNodes = [...this.nodes.values()].slice(-100);
3049
3277
  for (const other of recentNodes) {
3050
3278
  if (other.id === nodeId) continue;
@@ -3076,7 +3304,10 @@ var GraphMemoryBackend = class {
3076
3304
  toRemove.push(id);
3077
3305
  }
3078
3306
  }
3079
- for (const id of toRemove) this.nodes.delete(id);
3307
+ for (const id of toRemove) {
3308
+ this.removeNodeFromIndex(id, this.nodes.get(id)?.entry);
3309
+ this.nodes.delete(id);
3310
+ }
3080
3311
  this.edges = this.edges.filter((e) => !toRemove.includes(e.from) && !toRemove.includes(e.to));
3081
3312
  this._saveDone = this._saveGraph(scope);
3082
3313
  await this._saveDone;
@@ -3114,20 +3345,36 @@ var GraphMemoryBackend = class {
3114
3345
  }
3115
3346
  async search(scope, query, _filePath, limit) {
3116
3347
  await this.loadGraph(scope);
3117
- const needle = query.toLowerCase().split(/\s+/);
3118
- const scored = [];
3119
- for (const node of this.nodes.values()) {
3348
+ const needle = query.toLowerCase().split(/\s+/).filter((t) => t.length >= _GraphMemoryBackend.MIN_TERM_LEN);
3349
+ const candidates = /* @__PURE__ */ new Map();
3350
+ for (const term of needle) {
3351
+ const nodeIds = this.invertedIndex.get(term);
3352
+ if (!nodeIds) continue;
3353
+ for (const nodeId of nodeIds) {
3354
+ const node = this.nodes.get(nodeId);
3355
+ if (!node || node.entry.scope !== scope) continue;
3356
+ candidates.set(nodeId, (candidates.get(nodeId) ?? 0) + 1);
3357
+ }
3358
+ }
3359
+ for (const [nodeId, node] of this.nodes) {
3120
3360
  if (node.entry.scope !== scope) continue;
3121
- const words = node.entry.text.toLowerCase().split(/\s+/);
3361
+ if (candidates.has(nodeId)) continue;
3362
+ if (node.priority === "critical" || node.priority === "high") {
3363
+ candidates.set(nodeId, 0);
3364
+ }
3365
+ }
3366
+ const scored = [];
3367
+ for (const [nodeId] of candidates) {
3368
+ const node = this.nodes.get(nodeId);
3122
3369
  let score = 0;
3123
- for (const n of needle) {
3124
- if (words.some((w) => w.includes(n))) score += 1;
3125
- if (node.entry.tags?.some((t) => t.toLowerCase().includes(n))) score += 2;
3370
+ score += (candidates.get(nodeId) ?? 0) * 1;
3371
+ for (const term of needle) {
3372
+ if (node.entry.tags?.some((t) => t.toLowerCase().includes(term))) score += 2;
3126
3373
  }
3127
3374
  if (node.priority === "critical") score += 3;
3128
3375
  else if (node.priority === "high") score += 2;
3129
3376
  score += node.count * 0.5;
3130
- if (score > 0) scored.push({ entry: node.entry, score });
3377
+ scored.push({ entry: node.entry, score });
3131
3378
  }
3132
3379
  scored.sort((a, b) => b.score - a.score);
3133
3380
  const matched = scored.map((s) => s.entry);
@@ -3137,10 +3384,11 @@ var GraphMemoryBackend = class {
3137
3384
  await this.file.clear(scope, filePath);
3138
3385
  this.nodes.clear();
3139
3386
  this.edges = [];
3387
+ this.invertedIndex = /* @__PURE__ */ new Map();
3140
3388
  this.loadedScope = scope;
3141
3389
  this.loaded = true;
3142
3390
  try {
3143
- await fsp.unlink(this.graphFile);
3391
+ await fsp2.unlink(this.graphFile);
3144
3392
  } catch {
3145
3393
  }
3146
3394
  }
@@ -3180,7 +3428,7 @@ var GraphMemoryBackend = class {
3180
3428
  async loadGraph(scope) {
3181
3429
  if (this.loaded && this.loadedScope === scope) return;
3182
3430
  try {
3183
- const raw = await fsp.readFile(this.graphFile, "utf8");
3431
+ const raw = await fsp2.readFile(this.graphFile, "utf8");
3184
3432
  const data = JSON.parse(raw);
3185
3433
  this.nodes = new Map(data.nodes);
3186
3434
  this.edges = data.edges;
@@ -3188,6 +3436,7 @@ var GraphMemoryBackend = class {
3188
3436
  this.nodes = /* @__PURE__ */ new Map();
3189
3437
  this.edges = [];
3190
3438
  }
3439
+ this.buildInvertedIndex();
3191
3440
  this.loadedScope = scope;
3192
3441
  this.loaded = true;
3193
3442
  }
@@ -3201,10 +3450,10 @@ var GraphMemoryBackend = class {
3201
3450
  edges: this.edges
3202
3451
  };
3203
3452
  const dir = this.graphFile.substring(0, this.graphFile.lastIndexOf("/"));
3204
- await fsp.mkdir(dir, { recursive: true });
3453
+ await fsp2.mkdir(dir, { recursive: true });
3205
3454
  const tmp = `${this.graphFile}.tmp`;
3206
- await fsp.writeFile(tmp, JSON.stringify(data));
3207
- await fsp.rename(tmp, this.graphFile);
3455
+ await fsp2.writeFile(tmp, JSON.stringify(data));
3456
+ await fsp2.rename(tmp, this.graphFile);
3208
3457
  } catch {
3209
3458
  }
3210
3459
  }
@@ -3215,6 +3464,86 @@ var GraphMemoryBackend = class {
3215
3464
  async flush() {
3216
3465
  await this._saveDone;
3217
3466
  }
3467
+ // ── Inverted Index Helpers ───────────────────────────────────────────
3468
+ /**
3469
+ * Build the inverted index from all currently loaded nodes.
3470
+ * Called on loadGraph() to reconstruct the index after loading from disk.
3471
+ */
3472
+ buildInvertedIndex() {
3473
+ this.invertedIndex = /* @__PURE__ */ new Map();
3474
+ for (const [nodeId, node] of this.nodes) {
3475
+ for (const term of this.extractTerms(node.entry)) {
3476
+ let nodeIds = this.invertedIndex.get(term);
3477
+ if (!nodeIds) {
3478
+ nodeIds = /* @__PURE__ */ new Set();
3479
+ this.invertedIndex.set(term, nodeIds);
3480
+ }
3481
+ nodeIds.add(nodeId);
3482
+ }
3483
+ }
3484
+ }
3485
+ /**
3486
+ * Index a node in the inverted index. Adds the node's ID to all term entries.
3487
+ */
3488
+ indexNode(nodeId, entry) {
3489
+ for (const term of this.extractTerms(entry)) {
3490
+ let nodeIds = this.invertedIndex.get(term);
3491
+ if (!nodeIds) {
3492
+ nodeIds = /* @__PURE__ */ new Set();
3493
+ this.invertedIndex.set(term, nodeIds);
3494
+ }
3495
+ nodeIds.add(nodeId);
3496
+ }
3497
+ }
3498
+ /**
3499
+ * Remove a node's terms from the inverted index before deleting it.
3500
+ * Used in forget() and when updating existing nodes.
3501
+ */
3502
+ removeNodeFromIndex(nodeId, entry) {
3503
+ if (!entry) return;
3504
+ for (const term of this.extractTerms(entry)) {
3505
+ const nodeIds = this.invertedIndex.get(term);
3506
+ if (nodeIds) {
3507
+ nodeIds.delete(nodeId);
3508
+ if (nodeIds.size === 0) {
3509
+ this.invertedIndex.delete(term);
3510
+ }
3511
+ }
3512
+ }
3513
+ }
3514
+ /**
3515
+ * Update an existing node's entries in the inverted index.
3516
+ * Removes old terms and adds new ones.
3517
+ */
3518
+ updateNodeIndex(nodeId, entry) {
3519
+ const existing = this.nodes.get(nodeId);
3520
+ if (existing) {
3521
+ this.removeNodeFromIndex(nodeId, existing.entry);
3522
+ }
3523
+ this.indexNode(nodeId, entry);
3524
+ }
3525
+ /**
3526
+ * Extract searchable terms from a memory entry.
3527
+ * Returns lowercase words from text (min length) and full tag strings.
3528
+ */
3529
+ extractTerms(entry) {
3530
+ const terms = [];
3531
+ const words = entry.text.toLowerCase().split(/\s+/);
3532
+ for (const word of words) {
3533
+ if (word.length >= _GraphMemoryBackend.MIN_TERM_LEN) {
3534
+ terms.push(word);
3535
+ }
3536
+ }
3537
+ if (entry.tags) {
3538
+ for (const tag of entry.tags) {
3539
+ const lower = tag.toLowerCase();
3540
+ if (lower.length >= _GraphMemoryBackend.MIN_TERM_LEN && !terms.includes(lower)) {
3541
+ terms.push(lower);
3542
+ }
3543
+ }
3544
+ }
3545
+ return terms;
3546
+ }
3218
3547
  };
3219
3548
  function wordOverlap(a, b) {
3220
3549
  const wordsA = new Set(a.toLowerCase().split(/\s+/).filter((w) => w.length > 2));
@@ -3660,6 +3989,9 @@ function isContextWindowModeId(id) {
3660
3989
  return CONTEXT_WINDOW_MODES.some((m) => m.id === id);
3661
3990
  }
3662
3991
 
3992
+ // src/types/config.ts
3993
+ var DEFAULT_TUI_THINKING_WORD = "thinking";
3994
+
3663
3995
  // src/types/default-config.ts
3664
3996
  var DEFAULT_TOOLS_CONFIG = Object.freeze({
3665
3997
  defaultExecutionStrategy: "smart",
@@ -3678,6 +4010,10 @@ var DEFAULT_CONTEXT_CONFIG = Object.freeze({
3678
4010
  var DEFAULT_AUTONOMY_CONFIG = Object.freeze({
3679
4011
  autoProceedDelayMs: 45e3
3680
4012
  });
4013
+ var DEFAULT_CIRCUIT_BREAKER_CONFIG = Object.freeze({
4014
+ enabled: false,
4015
+ autoKillResetMs: 6e4
4016
+ });
3681
4017
  var DEFAULT_SESSION_LOGGING_CONFIG = Object.freeze({
3682
4018
  auditLevel: "standard",
3683
4019
  sampling: {
@@ -3704,7 +4040,8 @@ var BEHAVIOR_DEFAULTS = {
3704
4040
  hardThreshold: 0.9,
3705
4041
  autoCompact: true,
3706
4042
  preserveK: DEFAULT_CONTEXT_CONFIG.preserveK,
3707
- eliseThreshold: DEFAULT_CONTEXT_CONFIG.eliseThreshold
4043
+ eliseThreshold: DEFAULT_CONTEXT_CONFIG.eliseThreshold,
4044
+ strategy: "hybrid"
3708
4045
  },
3709
4046
  tools: {
3710
4047
  defaultExecutionStrategy: DEFAULT_TOOLS_CONFIG.defaultExecutionStrategy,
@@ -3727,6 +4064,13 @@ var BEHAVIOR_DEFAULTS = {
3727
4064
  allowOutsideProjectRoot: true
3728
4065
  },
3729
4066
  mcpServers: {},
4067
+ fallbackAuto: true,
4068
+ maxConcurrent: 4,
4069
+ yolo: false,
4070
+ nextPrediction: false,
4071
+ hints: true,
4072
+ debugStream: false,
4073
+ configScope: "global",
3730
4074
  indexing: {
3731
4075
  onSessionStart: true,
3732
4076
  onEdit: true,
@@ -3734,8 +4078,60 @@ var BEHAVIOR_DEFAULTS = {
3734
4078
  debounceMs: 400
3735
4079
  },
3736
4080
  session: { ...DEFAULT_SESSION_LOGGING_CONFIG },
3737
- autonomy: { autoProceedDelayMs: DEFAULT_AUTONOMY_CONFIG.autoProceedDelayMs }
4081
+ autonomy: {
4082
+ defaultMode: "off",
4083
+ autoProceedDelayMs: DEFAULT_AUTONOMY_CONFIG.autoProceedDelayMs,
4084
+ autoProceedMaxIterations: 50,
4085
+ autonomyNextPrompt: "auto {{suggestion}}",
4086
+ terminalTitleAnimation: true,
4087
+ yolo: false,
4088
+ streamFleet: true,
4089
+ chime: false,
4090
+ confirmExit: true,
4091
+ mouseMode: false,
4092
+ enhance: true,
4093
+ enhanceDelayMs: 6e4,
4094
+ enhanceLanguage: "original",
4095
+ statuslineMode: "detailed",
4096
+ thinkingWord: DEFAULT_TUI_THINKING_WORD
4097
+ },
4098
+ circuitBreaker: { ...DEFAULT_CIRCUIT_BREAKER_CONFIG },
4099
+ modelRuntime: {
4100
+ // `effort` is intentionally undefined by default. Leaving it unset lets
4101
+ // each model use its provider-recommended reasoning effort (or none at
4102
+ // all) instead of forcing an opinionated value that may be unsupported,
4103
+ // silently omitted, and surfaced as a per-request warning. Users who
4104
+ // want a specific effort can opt in via `/settings` or the WebUI panel.
4105
+ reasoning: { mode: "auto" },
4106
+ cache: {}
4107
+ }
3738
4108
  };
4109
+ function isPlainRecord(value) {
4110
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4111
+ }
4112
+ function cloneJsonValue(value) {
4113
+ return structuredClone(value);
4114
+ }
4115
+ function fillMissingDefaults(target, defaults) {
4116
+ const value = cloneJsonValue(target);
4117
+ const changed = fillMissingDefaultsInPlace(value, defaults);
4118
+ return { value, changed };
4119
+ }
4120
+ function fillMissingDefaultsInPlace(target, defaults) {
4121
+ let changed = false;
4122
+ for (const [key, defaultValue] of Object.entries(defaults)) {
4123
+ if (!Object.prototype.hasOwnProperty.call(target, key)) {
4124
+ target[key] = cloneJsonValue(defaultValue);
4125
+ changed = true;
4126
+ continue;
4127
+ }
4128
+ const current = target[key];
4129
+ if (isPlainRecord(current) && isPlainRecord(defaultValue)) {
4130
+ changed = fillMissingDefaultsInPlace(current, defaultValue) || changed;
4131
+ }
4132
+ }
4133
+ return changed;
4134
+ }
3739
4135
  function envBool(v) {
3740
4136
  return !/^(0|false|no|off)$/i.test(v.trim());
3741
4137
  }
@@ -3787,27 +4183,139 @@ var defaultIndexing = {
3787
4183
  watchExternal: true,
3788
4184
  debounceMs: 400
3789
4185
  };
3790
- var IN_PROJECT_FORBIDDEN_KEYS = /* @__PURE__ */ new Set([
4186
+ var IN_PROJECT_ALLOWED_KEYS = /* @__PURE__ */ new Set([
4187
+ "version",
4188
+ "model",
4189
+ "cwd",
4190
+ "context",
4191
+ "tools",
4192
+ "features",
4193
+ "autonomy",
4194
+ "indexing",
4195
+ "session",
4196
+ "log",
4197
+ "launch",
4198
+ "nextPrediction",
4199
+ "hints",
4200
+ "debugStream",
4201
+ "configScope",
4202
+ "maxConcurrent",
4203
+ "fallbackModels",
4204
+ "fallbackAuto",
4205
+ "models",
4206
+ "modelMatrix",
4207
+ "circuitBreaker",
4208
+ "adaptiveConcurrency",
4209
+ "modelRuntime"
4210
+ ]);
4211
+ var KNOWN_DENIED_IN_PROJECT = [
4212
+ { key: "provider", reason: "Provider id override; can intercept prompts/responses." },
4213
+ { key: "apiKey", reason: "Overrides user API key; exfiltrates prompts." },
4214
+ { key: "baseUrl", reason: "Redirects provider endpoint; leaks real API key." },
4215
+ { key: "providers", reason: "Per-provider apiKey/baseUrl/oauthConfig; same redirect/exfil." },
4216
+ { key: "mcpServers", reason: "Arbitrary command/args/env spawned at boot (RCE)." },
4217
+ { key: "hooks", reason: "Shell command arrays on lifecycle events (RCE)." },
4218
+ { key: "plugins", reason: "Dynamic npm package load at boot (RCE)." },
4219
+ { key: "sync", reason: "Carries githubToken credential and target repo." },
4220
+ { key: "yolo", reason: "Disables all permission confirmation prompts." },
4221
+ { key: "extensions", reason: "Per-plugin config can carry command/credential fields." },
4222
+ { key: "hq", reason: "Carries HQ client token credential and endpoint URL." }
4223
+ ];
4224
+ var KNOWN_CONFIG_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
4225
+ "version",
3791
4226
  "provider",
4227
+ "model",
3792
4228
  "apiKey",
3793
4229
  "baseUrl",
4230
+ "maxConcurrent",
3794
4231
  "providers",
4232
+ "models",
4233
+ "modelMatrix",
4234
+ "context",
4235
+ "tools",
3795
4236
  "mcpServers",
4237
+ "fallbackModels",
4238
+ "fallbackAuto",
3796
4239
  "hooks",
3797
4240
  "plugins",
3798
- "sync",
4241
+ "log",
4242
+ "features",
3799
4243
  "yolo",
4244
+ "nextPrediction",
4245
+ "cwd",
4246
+ "autonomy",
4247
+ "hints",
4248
+ "debugStream",
4249
+ "configScope",
4250
+ "indexing",
4251
+ "circuitBreaker",
4252
+ "adaptiveConcurrency",
4253
+ "launch",
4254
+ "session",
4255
+ "modelRuntime",
4256
+ "hq",
4257
+ "sync",
3800
4258
  "extensions"
3801
4259
  ]);
4260
+ function assertInProjectAllowListComplete() {
4261
+ const missingFromBoth = [];
4262
+ for (const key of KNOWN_CONFIG_TOP_LEVEL_KEYS) {
4263
+ if (IN_PROJECT_ALLOWED_KEYS.has(key)) continue;
4264
+ const denied = KNOWN_DENIED_IN_PROJECT.find((d) => d.key === key);
4265
+ if (!denied) missingFromBoth.push(key);
4266
+ }
4267
+ const staleDenials = KNOWN_DENIED_IN_PROJECT.filter((d) => !KNOWN_CONFIG_TOP_LEVEL_KEYS.has(d.key)).map((d) => d.key);
4268
+ const duplicate = KNOWN_DENIED_IN_PROJECT.filter((d) => IN_PROJECT_ALLOWED_KEYS.has(d.key)).map((d) => d.key);
4269
+ const problems = [];
4270
+ if (missingFromBoth.length > 0) {
4271
+ problems.push(
4272
+ `new Config field(s) not classified as allowed or denied for in-project config: ` + missingFromBoth.join(", ") + ". Add each to IN_PROJECT_ALLOWED_KEYS (if safe) or KNOWN_DENIED_IN_PROJECT (with a reason)."
4273
+ );
4274
+ }
4275
+ if (staleDenials.length > 0) {
4276
+ problems.push(
4277
+ `KNOWN_DENIED_IN_PROJECT references keys that no longer exist on Config: ` + staleDenials.join(", ") + ". Remove them or restore the field on Config."
4278
+ );
4279
+ }
4280
+ if (duplicate.length > 0) {
4281
+ problems.push(
4282
+ `field(s) appear in BOTH IN_PROJECT_ALLOWED_KEYS and KNOWN_DENIED_IN_PROJECT: ` + duplicate.join(", ") + ". The allow-list wins at runtime; remove from one of the two."
4283
+ );
4284
+ }
4285
+ if (problems.length > 0) {
4286
+ throw new Error(
4287
+ `stripUnsafeInProjectFields drift check failed:
4288
+ - ${problems.join("\n - ")}`
4289
+ );
4290
+ }
4291
+ }
4292
+ var driftChecked = false;
3802
4293
  function stripUnsafeInProjectFields(inProject, sourcePath, warn = (msg) => console.warn(msg)) {
4294
+ if (!driftChecked) {
4295
+ assertInProjectAllowListComplete();
4296
+ driftChecked = true;
4297
+ }
3803
4298
  const stripped = [];
3804
4299
  const out = {};
3805
4300
  for (const [k, v] of Object.entries(inProject)) {
3806
- if (IN_PROJECT_FORBIDDEN_KEYS.has(k)) {
3807
- stripped.push(k);
4301
+ if (IN_PROJECT_ALLOWED_KEYS.has(k)) {
4302
+ out[k] = v;
3808
4303
  continue;
3809
4304
  }
3810
- out[k] = v;
4305
+ stripped.push(k);
4306
+ }
4307
+ const outTools = out["tools"];
4308
+ if (outTools && typeof outTools === "object") {
4309
+ const execCfg = outTools["exec"];
4310
+ if (execCfg && typeof execCfg === "object" && "allow" in execCfg) {
4311
+ const clonedExec = { ...execCfg };
4312
+ delete clonedExec["allow"];
4313
+ out["tools"] = {
4314
+ ...outTools,
4315
+ exec: clonedExec
4316
+ };
4317
+ stripped.push("tools.exec.allow");
4318
+ }
3811
4319
  }
3812
4320
  if (stripped.length > 0) {
3813
4321
  warn(
@@ -3816,7 +4324,7 @@ function stripUnsafeInProjectFields(inProject, sourcePath, warn = (msg) => conso
3816
4324
  event: "config.in_project_unsafe_fields_ignored",
3817
4325
  path: sourcePath,
3818
4326
  ignoredKeys: stripped,
3819
- message: `Ignored ${stripped.length} unsafe field(s) from the repo-committed config "${sourcePath}": ${stripped.join(", ")}. These can only be set in your personal ~/.wrongstack/config.json, not in a project-committed file.`,
4327
+ message: `Ignored ${stripped.length} field(s) from the repo-committed config "${sourcePath}": ${stripped.join(", ")}. Only a small allow-list of benign preferences (model, context, tools limits, features, \u2026) may be set by <project>/.wrongstack/config.json. Everything else must live in your personal ~/.wrongstack/config.json.`,
3820
4328
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3821
4329
  })
3822
4330
  );
@@ -3850,6 +4358,7 @@ var DefaultConfigLoader = class {
3850
4358
  extraSources;
3851
4359
  events;
3852
4360
  traceId;
4361
+ jsonCache = /* @__PURE__ */ new Map();
3853
4362
  constructor(opts) {
3854
4363
  this.paths = opts.paths;
3855
4364
  this.strict = opts.strict ?? false;
@@ -3860,6 +4369,7 @@ var DefaultConfigLoader = class {
3860
4369
  }
3861
4370
  async load(opts = {}) {
3862
4371
  let cfg = { ...BEHAVIOR_DEFAULTS };
4372
+ await this.ensureGlobalDefaults();
3863
4373
  const inProjectCollides = samePath(this.paths.inProjectConfig, this.paths.globalConfig) || samePath(this.paths.inProjectConfig, this.paths.projectLocalConfig);
3864
4374
  const [global, local, inProject] = await Promise.all([
3865
4375
  this.readJson(this.paths.globalConfig),
@@ -3924,6 +4434,80 @@ var DefaultConfigLoader = class {
3924
4434
  }
3925
4435
  return Object.freeze(cfg);
3926
4436
  }
4437
+ async ensureGlobalDefaults() {
4438
+ const fp = this.paths.globalConfig;
4439
+ const t0 = Date.now();
4440
+ try {
4441
+ await withFileLock(fp, async () => {
4442
+ let parsed;
4443
+ try {
4444
+ const raw = await fsp2.readFile(fp, "utf8");
4445
+ const result = safeParse(raw);
4446
+ if (!result.ok || !isPlainRecord(result.value)) {
4447
+ return;
4448
+ }
4449
+ parsed = result.value;
4450
+ } catch (err) {
4451
+ if (err.code !== "ENOENT") {
4452
+ this.events?.emit("storage.error", {
4453
+ sessionId: "~config~",
4454
+ store: "config",
4455
+ filePath: fp,
4456
+ operation: "ensure_defaults",
4457
+ outcome: "failure",
4458
+ error: storageErrorString(err),
4459
+ recoverable: false,
4460
+ durationMs: Date.now() - t0,
4461
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
4462
+ });
4463
+ console.warn(JSON.stringify({
4464
+ level: "warn",
4465
+ event: "config.defaults_read_failed",
4466
+ path: fp,
4467
+ message: toErrorMessage(err),
4468
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4469
+ }));
4470
+ return;
4471
+ }
4472
+ parsed = {};
4473
+ }
4474
+ const { value, changed } = fillMissingDefaults(
4475
+ parsed,
4476
+ BEHAVIOR_DEFAULTS
4477
+ );
4478
+ if (!changed) return;
4479
+ await atomicWrite(fp, JSON.stringify(value, null, 2), { mode: 384 });
4480
+ this.events?.emit("storage.write", {
4481
+ sessionId: "~config~",
4482
+ store: "config",
4483
+ filePath: fp,
4484
+ operation: "ensure_defaults",
4485
+ outcome: "success",
4486
+ durationMs: Date.now() - t0,
4487
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
4488
+ });
4489
+ });
4490
+ } catch (err) {
4491
+ this.events?.emit("storage.error", {
4492
+ sessionId: "~config~",
4493
+ store: "config",
4494
+ filePath: fp,
4495
+ operation: "ensure_defaults",
4496
+ outcome: "failure",
4497
+ error: storageErrorString(err),
4498
+ recoverable: false,
4499
+ durationMs: Date.now() - t0,
4500
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
4501
+ });
4502
+ console.warn(JSON.stringify({
4503
+ level: "warn",
4504
+ event: "config.defaults_write_failed",
4505
+ path: fp,
4506
+ message: toErrorMessage(err),
4507
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4508
+ }));
4509
+ }
4510
+ }
3927
4511
  /**
3928
4512
  * Persist a sync config to ~/.wrongstack/sync.json, with the token encrypted
3929
4513
  * by the vault (if provided). The file is isolated from the main config
@@ -3972,7 +4556,7 @@ var DefaultConfigLoader = class {
3972
4556
  const fp = this.paths.syncConfig;
3973
4557
  const t0 = Date.now();
3974
4558
  try {
3975
- const raw = await fsp.readFile(fp, "utf8");
4559
+ const raw = await fsp2.readFile(fp, "utf8");
3976
4560
  const parsed = safeParse(raw);
3977
4561
  if (!parsed.ok || !parsed.value) {
3978
4562
  this.events?.emit("storage.read", {
@@ -4033,10 +4617,42 @@ var DefaultConfigLoader = class {
4033
4617
  }
4034
4618
  }
4035
4619
  async readJson(file) {
4036
- let raw;
4037
4620
  const t0 = Date.now();
4621
+ let mtimeMs = null;
4622
+ try {
4623
+ const stat10 = await fsp2.stat(file);
4624
+ mtimeMs = stat10.mtimeMs;
4625
+ const cached = this.jsonCache.get(file);
4626
+ if (cached && cached.mtimeMs === mtimeMs) {
4627
+ return structuredClone(cached.value);
4628
+ }
4629
+ } catch (err) {
4630
+ if (err.code === "ENOENT") {
4631
+ this.jsonCache.set(file, { mtimeMs: null, value: {} });
4632
+ return {};
4633
+ }
4634
+ this.events?.emit("storage.read", {
4635
+ sessionId: "~config~",
4636
+ store: "config",
4637
+ filePath: file,
4638
+ operation: "read_json",
4639
+ outcome: "failure",
4640
+ durationMs: Date.now() - t0,
4641
+ error: storageErrorString(err),
4642
+ ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
4643
+ });
4644
+ console.warn(JSON.stringify({
4645
+ level: "warn",
4646
+ event: "config.read_failed",
4647
+ path: file,
4648
+ message: toErrorMessage(err),
4649
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4650
+ }));
4651
+ return {};
4652
+ }
4653
+ let raw;
4038
4654
  try {
4039
- raw = await fsp.readFile(file, "utf8");
4655
+ raw = await fsp2.readFile(file, "utf8");
4040
4656
  } catch (err) {
4041
4657
  if (err.code !== "ENOENT") {
4042
4658
  this.events?.emit("storage.read", {
@@ -4057,6 +4673,7 @@ var DefaultConfigLoader = class {
4057
4673
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
4058
4674
  }));
4059
4675
  }
4676
+ this.jsonCache.set(file, { mtimeMs: null, value: {} });
4060
4677
  return {};
4061
4678
  }
4062
4679
  const parsed = safeParse(raw);
@@ -4080,6 +4697,7 @@ var DefaultConfigLoader = class {
4080
4697
  }));
4081
4698
  return {};
4082
4699
  }
4700
+ this.jsonCache.set(file, { mtimeMs, value: structuredClone(parsed.value) });
4083
4701
  return parsed.value;
4084
4702
  }
4085
4703
  validateBehavior(cfg) {
@@ -4274,7 +4892,7 @@ var RecoveryLock = class {
4274
4892
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
4275
4893
  };
4276
4894
  try {
4277
- await fsp.writeFile(this.file, JSON.stringify(lock), { flag: "wx", mode: 384 });
4895
+ await fsp2.writeFile(this.file, JSON.stringify(lock), { flag: "wx", mode: 384 });
4278
4896
  } catch (err) {
4279
4897
  const code = err.code;
4280
4898
  if (code === "EEXIST") {
@@ -4290,7 +4908,7 @@ var RecoveryLock = class {
4290
4908
  */
4291
4909
  async clear() {
4292
4910
  try {
4293
- await fsp.unlink(this.file);
4911
+ await fsp2.unlink(this.file);
4294
4912
  } catch (err) {
4295
4913
  const code = err.code;
4296
4914
  if (code === "ENOENT") return;
@@ -4300,7 +4918,7 @@ var RecoveryLock = class {
4300
4918
  async readLock() {
4301
4919
  let raw;
4302
4920
  try {
4303
- raw = await fsp.readFile(this.file, "utf8");
4921
+ raw = await fsp2.readFile(this.file, "utf8");
4304
4922
  } catch (err) {
4305
4923
  const code = err.code;
4306
4924
  if (code === "ENOENT") return null;
@@ -4331,26 +4949,82 @@ function defaultIsPidAlive(pid) {
4331
4949
  return false;
4332
4950
  }
4333
4951
  }
4334
-
4335
- // src/storage/session-reader.ts
4336
- var DefaultSessionReader = class {
4952
+ var DefaultSessionReader = class _DefaultSessionReader {
4337
4953
  store;
4954
+ eventCache = /* @__PURE__ */ new Map();
4955
+ eventCacheMtimes = /* @__PURE__ */ new Map();
4956
+ static EVENT_CACHE_MAX_ENTRIES = 32;
4338
4957
  constructor(opts) {
4339
4958
  this.store = opts.store;
4340
4959
  }
4960
+ async loadCachedSessionData(sessionId) {
4961
+ const storeWithPath = this.store;
4962
+ const rootDir = storeWithPath.dir;
4963
+ if (!rootDir) {
4964
+ return await this.store.load(sessionId);
4965
+ }
4966
+ const sessionPath = path2.join(rootDir, `${sessionId}.jsonl`);
4967
+ let mtimeMs = null;
4968
+ try {
4969
+ const stat10 = await fsp2.stat(sessionPath);
4970
+ mtimeMs = stat10.mtimeMs;
4971
+ } catch {
4972
+ this.eventCache.delete(sessionId);
4973
+ this.eventCacheMtimes.delete(sessionId);
4974
+ return await this.store.load(sessionId);
4975
+ }
4976
+ const cachedMtime = this.eventCacheMtimes.get(sessionId);
4977
+ const cachedData = this.eventCache.get(sessionId);
4978
+ if (cachedData && cachedMtime === mtimeMs) {
4979
+ this.eventCache.delete(sessionId);
4980
+ this.eventCacheMtimes.delete(sessionId);
4981
+ this.eventCache.set(sessionId, cachedData);
4982
+ this.eventCacheMtimes.set(sessionId, mtimeMs);
4983
+ return cachedData;
4984
+ }
4985
+ const data = await this.store.load(sessionId);
4986
+ this.eventCache.delete(sessionId);
4987
+ this.eventCacheMtimes.delete(sessionId);
4988
+ this.eventCache.set(sessionId, data);
4989
+ this.eventCacheMtimes.set(sessionId, mtimeMs);
4990
+ while (this.eventCache.size > _DefaultSessionReader.EVENT_CACHE_MAX_ENTRIES) {
4991
+ const oldest = this.eventCache.keys().next().value;
4992
+ if (oldest === void 0) break;
4993
+ this.eventCache.delete(oldest);
4994
+ this.eventCacheMtimes.delete(oldest);
4995
+ }
4996
+ if (data.metadata.endedAt) {
4997
+ storeWithPath.clearLoadCache?.(sessionId);
4998
+ }
4999
+ return data;
5000
+ }
4341
5001
  async query(q = {}) {
4342
- const raw = await this.store.list(q.limit ? Math.max(q.limit, 100) : 1e3);
4343
- const titleNeedle = q.titleContains?.toLowerCase();
4344
- const filtered = raw.filter((s) => {
4345
- if (q.since && s.startedAt < q.since) return false;
4346
- if (q.until && s.startedAt > q.until) return false;
4347
- if (q.provider && s.provider !== q.provider) return false;
4348
- if (q.model && s.model !== q.model) return false;
4349
- if (q.minTokens !== void 0 && s.tokenTotal < q.minTokens) return false;
4350
- if (titleNeedle && !s.title.toLowerCase().includes(titleNeedle)) return false;
4351
- return true;
4352
- });
4353
- const out = filtered.map((s) => ({
5002
+ const storeWithFilter = this.store;
5003
+ let raw;
5004
+ if (typeof storeWithFilter.listFiltered === "function") {
5005
+ raw = await storeWithFilter.listFiltered({
5006
+ since: q.since,
5007
+ until: q.until,
5008
+ provider: q.provider,
5009
+ model: q.model,
5010
+ minTokens: q.minTokens,
5011
+ titleContains: q.titleContains,
5012
+ limit: q.limit
5013
+ });
5014
+ } else {
5015
+ const fetched = await this.store.list(q.limit ? Math.max(q.limit, 100) : 1e3);
5016
+ const titleNeedle = q.titleContains?.toLowerCase();
5017
+ raw = fetched.filter((s) => {
5018
+ if (q.since && s.startedAt < q.since) return false;
5019
+ if (q.until && s.startedAt > q.until) return false;
5020
+ if (q.provider && s.provider !== q.provider) return false;
5021
+ if (q.model && s.model !== q.model) return false;
5022
+ if (q.minTokens !== void 0 && s.tokenTotal < q.minTokens) return false;
5023
+ if (titleNeedle && !s.title.toLowerCase().includes(titleNeedle)) return false;
5024
+ return true;
5025
+ });
5026
+ }
5027
+ const out = raw.map((s) => ({
4354
5028
  id: s.id,
4355
5029
  title: s.title,
4356
5030
  startedAt: s.startedAt,
@@ -4361,7 +5035,7 @@ var DefaultSessionReader = class {
4361
5035
  return q.limit ? out.slice(0, q.limit) : out;
4362
5036
  }
4363
5037
  async *replay(sessionId) {
4364
- const data = await this.store.load(sessionId);
5038
+ const data = await this.loadCachedSessionData(sessionId);
4365
5039
  for (const e of data.events) yield e;
4366
5040
  }
4367
5041
  async search(q, sessionId, sessionQuery) {
@@ -4372,24 +5046,66 @@ var DefaultSessionReader = class {
4372
5046
  if (sessionId) {
4373
5047
  ids = [sessionId];
4374
5048
  } else {
4375
- const sessions = await this.store.list(1e3);
4376
- const titleNeedle = sessionQuery?.titleContains?.toLowerCase();
4377
- const filtered = sessions.filter((s) => {
4378
- if (sessionQuery?.since && s.startedAt < sessionQuery.since) return false;
4379
- if (sessionQuery?.until && s.startedAt > sessionQuery.until) return false;
4380
- if (sessionQuery?.provider && s.provider !== sessionQuery.provider) return false;
4381
- if (sessionQuery?.model && s.model !== sessionQuery.model) return false;
4382
- if (sessionQuery?.minTokens !== void 0 && s.tokenTotal < sessionQuery.minTokens) return false;
4383
- if (titleNeedle && !s.title.toLowerCase().includes(titleNeedle)) return false;
4384
- return true;
4385
- });
4386
- ids = filtered.map((s) => s.id);
5049
+ const storeWithFilter = this.store;
5050
+ let sessions;
5051
+ if (typeof storeWithFilter.listFiltered === "function") {
5052
+ sessions = await storeWithFilter.listFiltered({
5053
+ since: sessionQuery?.since,
5054
+ until: sessionQuery?.until,
5055
+ provider: sessionQuery?.provider,
5056
+ model: sessionQuery?.model,
5057
+ minTokens: sessionQuery?.minTokens,
5058
+ titleContains: sessionQuery?.titleContains,
5059
+ limit: 1e3
5060
+ });
5061
+ } else {
5062
+ sessions = await this.store.list(1e3);
5063
+ const titleNeedle = sessionQuery?.titleContains?.toLowerCase();
5064
+ sessions = sessions.filter((s) => {
5065
+ if (sessionQuery?.since && s.startedAt < sessionQuery.since) return false;
5066
+ if (sessionQuery?.until && s.startedAt > sessionQuery.until) return false;
5067
+ if (sessionQuery?.provider && s.provider !== sessionQuery.provider) return false;
5068
+ if (sessionQuery?.model && s.model !== sessionQuery.model) return false;
5069
+ if (sessionQuery?.minTokens !== void 0 && s.tokenTotal < sessionQuery.minTokens) return false;
5070
+ if (titleNeedle && !s.title.toLowerCase().includes(titleNeedle)) return false;
5071
+ return true;
5072
+ });
5073
+ }
5074
+ ids = sessions.map((s) => s.id);
4387
5075
  }
4388
5076
  const hits = [];
5077
+ const streaming = this.store.searchEvents?.bind(this.store);
5078
+ if (streaming) {
5079
+ for (const id of ids) {
5080
+ const matched = await streaming(
5081
+ id,
5082
+ (ev) => {
5083
+ if (allowedTypes && !allowedTypes.has(ev.type)) return false;
5084
+ const text = eventText(ev);
5085
+ if (text === null) return false;
5086
+ return matcher(text) !== null;
5087
+ },
5088
+ { limit: limit - hits.length }
5089
+ );
5090
+ for (const m of matched) {
5091
+ const text = expectDefined(eventText(m.event));
5092
+ const hit = expectDefined(matcher(text));
5093
+ hits.push({
5094
+ sessionId: id,
5095
+ eventIndex: m.eventIndex,
5096
+ ts: m.ts,
5097
+ type: m.event.type,
5098
+ snippet: snippetOf(text, hit.start, hit.end)
5099
+ });
5100
+ if (hits.length >= limit) return hits;
5101
+ }
5102
+ }
5103
+ return hits;
5104
+ }
4389
5105
  for (const id of ids) {
4390
5106
  let data;
4391
5107
  try {
4392
- data = await this.store.load(id);
5108
+ data = await this.loadCachedSessionData(id);
4393
5109
  } catch {
4394
5110
  continue;
4395
5111
  }
@@ -4413,7 +5129,7 @@ var DefaultSessionReader = class {
4413
5129
  return hits;
4414
5130
  }
4415
5131
  async export(sessionId, opts) {
4416
- const data = await this.store.load(sessionId);
5132
+ const data = await this.loadCachedSessionData(sessionId);
4417
5133
  const includeTools = opts.includeTools ?? true;
4418
5134
  const includeDiagnostics = opts.includeDiagnostics ?? true;
4419
5135
  const filtered = data.events.filter((e) => {
@@ -4434,7 +5150,7 @@ var DefaultSessionReader = class {
4434
5150
  return renderMarkdown(data.metadata, filtered);
4435
5151
  }
4436
5152
  async metadata(sessionId) {
4437
- const data = await this.store.load(sessionId);
5153
+ const data = await this.loadCachedSessionData(sessionId);
4438
5154
  return data.metadata;
4439
5155
  }
4440
5156
  };
@@ -4852,7 +5568,7 @@ var AnnotationsStore = class {
4852
5568
  const fp = this.filePath(sessionId);
4853
5569
  let raw;
4854
5570
  try {
4855
- raw = await fsp.readFile(fp, "utf8");
5571
+ raw = await fsp2.readFile(fp, "utf8");
4856
5572
  } catch (err) {
4857
5573
  if (err.code === "ENOENT") return null;
4858
5574
  throw err;
@@ -4933,7 +5649,7 @@ var ReplayLogStore = class {
4933
5649
  events;
4934
5650
  traceId;
4935
5651
  writeChains = /* @__PURE__ */ new Map();
4936
- /** Per-session hash → entry index, kept in memory after the first load. */
5652
+ /** Per-session hash → on-disk location, with lazy entry hydration. */
4937
5653
  cache = /* @__PURE__ */ new Map();
4938
5654
  /** Per-session entry count on disk, to detect when compaction is needed. */
4939
5655
  diskCount = /* @__PURE__ */ new Map();
@@ -4968,8 +5684,16 @@ var ReplayLogStore = class {
4968
5684
  const currentCount = this.diskCount.get(input.sessionId) ?? 0;
4969
5685
  const willEvict = currentCount + 1 > this.maxEntries;
4970
5686
  if (!willEvict) {
4971
- await fsp.appendFile(fp, JSON.stringify(entry) + "\n", "utf8");
4972
- cache.set(hash, entry);
5687
+ const line = JSON.stringify(entry) + "\n";
5688
+ let offset2 = 0;
5689
+ try {
5690
+ const stat10 = await fsp2.stat(fp);
5691
+ offset2 = stat10.size;
5692
+ } catch (err) {
5693
+ if (err.code !== "ENOENT") throw err;
5694
+ }
5695
+ await fsp2.appendFile(fp, line, "utf8");
5696
+ cache.set(hash, { entry, offset: offset2, length: Buffer.byteLength(line, "utf8") });
4973
5697
  this.diskCount.set(input.sessionId, currentCount + 1);
4974
5698
  this.events?.emit("storage.write", {
4975
5699
  sessionId: input.sessionId,
@@ -4986,7 +5710,12 @@ var ReplayLogStore = class {
4986
5710
  all.push(entry);
4987
5711
  const keep = all.slice(-this.maxEntries);
4988
5712
  const refreshed = /* @__PURE__ */ new Map();
4989
- for (const e of keep) refreshed.set(e.hash, e);
5713
+ let offset = 0;
5714
+ for (const e of keep) {
5715
+ const line = JSON.stringify(e) + "\n";
5716
+ refreshed.set(e.hash, { entry: e, offset, length: Buffer.byteLength(line, "utf8") });
5717
+ offset += Buffer.byteLength(line, "utf8");
5718
+ }
4990
5719
  this.cache.set(input.sessionId, refreshed);
4991
5720
  this.diskCount.set(input.sessionId, keep.length);
4992
5721
  await this.writeAll(input.sessionId, keep, "compact");
@@ -5019,6 +5748,8 @@ var ReplayLogStore = class {
5019
5748
  const t0 = Date.now();
5020
5749
  try {
5021
5750
  const cache = await this.ensureCache(sessionId);
5751
+ const location = cache.get(hash);
5752
+ const entry = location ? await this.hydrateEntry(sessionId, hash, location) : null;
5022
5753
  this.events?.emit("storage.read", {
5023
5754
  sessionId,
5024
5755
  store: "replay",
@@ -5028,7 +5759,7 @@ var ReplayLogStore = class {
5028
5759
  durationMs: Date.now() - t0,
5029
5760
  ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
5030
5761
  });
5031
- return cache.get(hash) ?? null;
5762
+ return entry;
5032
5763
  } catch (err) {
5033
5764
  this.events?.emit("storage.read", {
5034
5765
  sessionId,
@@ -5049,6 +5780,10 @@ var ReplayLogStore = class {
5049
5780
  const t0 = Date.now();
5050
5781
  try {
5051
5782
  const cache = await this.ensureCache(sessionId);
5783
+ const entries = [];
5784
+ for (const [hash, location] of cache) {
5785
+ entries.push(await this.hydrateEntry(sessionId, hash, location));
5786
+ }
5052
5787
  const durationMs = Date.now() - t0;
5053
5788
  this.events?.emit("storage.read", {
5054
5789
  sessionId,
@@ -5059,7 +5794,7 @@ var ReplayLogStore = class {
5059
5794
  durationMs,
5060
5795
  ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
5061
5796
  });
5062
- return [...cache.values()];
5797
+ return entries;
5063
5798
  } catch (err) {
5064
5799
  const durationMs = Date.now() - t0;
5065
5800
  this.events?.emit("storage.read", {
@@ -5085,7 +5820,7 @@ var ReplayLogStore = class {
5085
5820
  const scan = async (dir, prefix, depth) => {
5086
5821
  let entries;
5087
5822
  try {
5088
- entries = await fsp.readdir(dir, { withFileTypes: true });
5823
+ entries = await fsp2.readdir(dir, { withFileTypes: true });
5089
5824
  } catch (err) {
5090
5825
  if (depth === 0 && err.code !== "ENOENT") {
5091
5826
  console.warn(JSON.stringify({
@@ -5123,7 +5858,7 @@ var ReplayLogStore = class {
5123
5858
  return sessionScopedPath(this.dir, sessionId, ".replay.jsonl");
5124
5859
  }
5125
5860
  async countEntries(filePath) {
5126
- const handle = await fsp.open(filePath, "r");
5861
+ const handle = await fsp2.open(filePath, "r");
5127
5862
  const buffer = Buffer.allocUnsafe(64 * 1024);
5128
5863
  let count = 0;
5129
5864
  let hasNonWhitespace = false;
@@ -5149,23 +5884,27 @@ var ReplayLogStore = class {
5149
5884
  if (hasNonWhitespace) count++;
5150
5885
  return count;
5151
5886
  }
5887
+ parseReplayLine(line) {
5888
+ try {
5889
+ const parsed = safeParse(line);
5890
+ if (!parsed.ok || !parsed.value) return null;
5891
+ if ("entry" in parsed.value && parsed.value.entry) {
5892
+ return parsed.value.entry;
5893
+ }
5894
+ return parsed.value;
5895
+ } catch {
5896
+ return null;
5897
+ }
5898
+ }
5152
5899
  async readAll(sessionId) {
5153
5900
  const fp = this.filePath(sessionId);
5154
5901
  try {
5155
- const raw = await fsp.readFile(fp, "utf8");
5902
+ const raw = await fsp2.readFile(fp, "utf8");
5156
5903
  const out = [];
5157
5904
  for (const line of raw.split("\n")) {
5158
5905
  if (!line.trim()) continue;
5159
- try {
5160
- const parsed = safeParse(line);
5161
- if (!parsed.ok || !parsed.value) continue;
5162
- if ("entry" in parsed.value && parsed.value.entry) {
5163
- out.push(parsed.value.entry);
5164
- } else {
5165
- out.push(parsed.value);
5166
- }
5167
- } catch {
5168
- }
5906
+ const parsed = this.parseReplayLine(line);
5907
+ if (parsed) out.push(parsed);
5169
5908
  }
5170
5909
  return out;
5171
5910
  } catch (err) {
@@ -5192,13 +5931,75 @@ var ReplayLogStore = class {
5192
5931
  async ensureCache(sessionId) {
5193
5932
  let cache = this.cache.get(sessionId);
5194
5933
  if (cache) return cache;
5195
- const all = await this.readAll(sessionId);
5934
+ const fp = this.filePath(sessionId);
5196
5935
  cache = /* @__PURE__ */ new Map();
5197
- for (const e of all) cache.set(e.hash, e);
5936
+ try {
5937
+ const handle = await fsp2.open(fp, "r");
5938
+ const CHUNK = 64 * 1024;
5939
+ const buffer = Buffer.alloc(CHUNK);
5940
+ let leftover = "";
5941
+ let leftoverOffset = 0;
5942
+ let fileOffset = 0;
5943
+ try {
5944
+ while (true) {
5945
+ const { bytesRead } = await handle.read(buffer, 0, CHUNK, fileOffset);
5946
+ if (bytesRead === 0) break;
5947
+ const chunk = buffer.subarray(0, bytesRead).toString("utf8");
5948
+ const text = leftover + chunk;
5949
+ let lineStart = leftoverOffset;
5950
+ let searchFrom = 0;
5951
+ while (true) {
5952
+ const newlineIndex = text.indexOf("\n", searchFrom);
5953
+ if (newlineIndex === -1) break;
5954
+ const line = text.slice(searchFrom, newlineIndex);
5955
+ const lineLength = Buffer.byteLength(text.slice(searchFrom, newlineIndex + 1), "utf8");
5956
+ if (line.trim()) {
5957
+ const entry = this.parseReplayLine(line);
5958
+ if (entry) {
5959
+ cache.set(entry.hash, { offset: lineStart, length: lineLength });
5960
+ }
5961
+ }
5962
+ lineStart += lineLength;
5963
+ searchFrom = newlineIndex + 1;
5964
+ }
5965
+ leftover = text.slice(searchFrom);
5966
+ leftoverOffset = lineStart;
5967
+ fileOffset += bytesRead;
5968
+ }
5969
+ if (leftover.trim()) {
5970
+ const entry = this.parseReplayLine(leftover);
5971
+ if (entry) {
5972
+ cache.set(entry.hash, { offset: leftoverOffset, length: Buffer.byteLength(leftover, "utf8") });
5973
+ }
5974
+ }
5975
+ } finally {
5976
+ await handle.close();
5977
+ }
5978
+ } catch (err) {
5979
+ if (err.code !== "ENOENT") throw err;
5980
+ }
5198
5981
  this.cache.set(sessionId, cache);
5199
- this.diskCount.set(sessionId, all.length);
5982
+ this.diskCount.set(sessionId, cache.size);
5200
5983
  return cache;
5201
5984
  }
5985
+ async hydrateEntry(sessionId, hash, location) {
5986
+ if (location.entry) return location.entry;
5987
+ const fp = this.filePath(sessionId);
5988
+ const handle = await fsp2.open(fp, "r");
5989
+ try {
5990
+ const buffer = Buffer.alloc(location.length);
5991
+ const { bytesRead } = await handle.read(buffer, 0, location.length, location.offset);
5992
+ const line = buffer.subarray(0, bytesRead).toString("utf8").trimEnd();
5993
+ const entry = this.parseReplayLine(line);
5994
+ if (!entry) {
5995
+ throw new Error(`Replay entry ${hash} is unreadable`);
5996
+ }
5997
+ location.entry = entry;
5998
+ return entry;
5999
+ } finally {
6000
+ await handle.close();
6001
+ }
6002
+ }
5202
6003
  enqueue(sessionId, fn) {
5203
6004
  const prev = this.writeChains.get(sessionId) ?? Promise.resolve();
5204
6005
  const next = prev.then(fn, fn);
@@ -5227,19 +6028,19 @@ var SessionRecovery = class {
5227
6028
  async detectStale(sessionId) {
5228
6029
  const fp = this.filePath(sessionId);
5229
6030
  const TAIL_SIZE = 8192;
5230
- let stat7;
6031
+ let stat10;
5231
6032
  try {
5232
- stat7 = await fsp.stat(fp);
6033
+ stat10 = await fsp2.stat(fp);
5233
6034
  } catch (err) {
5234
6035
  if (err.code === "ENOENT") return null;
5235
6036
  return null;
5236
6037
  }
5237
- if (stat7.size === 0) return null;
5238
- const position = Math.max(0, stat7.size - TAIL_SIZE);
6038
+ if (stat10.size === 0) return null;
6039
+ const position = Math.max(0, stat10.size - TAIL_SIZE);
5239
6040
  const buf = Buffer.alloc(TAIL_SIZE);
5240
6041
  let fh;
5241
6042
  try {
5242
- fh = await fsp.open(fp, "r");
6043
+ fh = await fsp2.open(fp, "r");
5243
6044
  const { bytesRead } = await fh.read(buf, 0, TAIL_SIZE, position);
5244
6045
  let eventCount = 0;
5245
6046
  const raw = buf.subarray(0, bytesRead).toString("utf8");
@@ -5284,7 +6085,7 @@ var SessionRecovery = class {
5284
6085
  const fp = this.filePath(sessionId);
5285
6086
  let raw;
5286
6087
  try {
5287
- raw = await fsp.readFile(fp, "utf8");
6088
+ raw = await fsp2.readFile(fp, "utf8");
5288
6089
  } catch (err) {
5289
6090
  if (err.code === "ENOENT") return null;
5290
6091
  return null;
@@ -5329,7 +6130,7 @@ var SessionRecovery = class {
5329
6130
  const collect = async (dir, prefix, depth) => {
5330
6131
  let entries;
5331
6132
  try {
5332
- entries = await fsp.readdir(dir, { withFileTypes: true });
6133
+ entries = await fsp2.readdir(dir, { withFileTypes: true });
5333
6134
  } catch {
5334
6135
  return;
5335
6136
  }
@@ -5430,9 +6231,9 @@ var ToolAuditLog = class {
5430
6231
  isError: input.isError,
5431
6232
  index
5432
6233
  };
5433
- await fsp.appendFile(fp, JSON.stringify(entry) + "\n", "utf8");
6234
+ await fsp2.appendFile(fp, JSON.stringify(entry) + "\n", "utf8");
5434
6235
  try {
5435
- const st = await fsp.stat(fp);
6236
+ const st = await fsp2.stat(fp);
5436
6237
  this.tailStat.set(input.sessionId, { mtimeMs: st.mtimeMs, size: st.size });
5437
6238
  } catch {
5438
6239
  }
@@ -5479,7 +6280,7 @@ var ToolAuditLog = class {
5479
6280
  const cachedStat = this.tailStat.get(sessionId);
5480
6281
  if (cachedHash !== void 0 && cachedIndex !== void 0 && cachedStat) {
5481
6282
  try {
5482
- const st = await fsp.stat(fp);
6283
+ const st = await fsp2.stat(fp);
5483
6284
  if (st.mtimeMs === cachedStat.mtimeMs && st.size === cachedStat.size) {
5484
6285
  return { prevHash: cachedHash, nextIndex: cachedIndex };
5485
6286
  }
@@ -5500,7 +6301,7 @@ var ToolAuditLog = class {
5500
6301
  this.tailHash.set(sessionId, prevHash);
5501
6302
  this.tailIndex.set(sessionId, nextIndex);
5502
6303
  try {
5503
- const st = await fsp.stat(fp);
6304
+ const st = await fsp2.stat(fp);
5504
6305
  this.tailStat.set(sessionId, { mtimeMs: st.mtimeMs, size: st.size });
5505
6306
  } catch {
5506
6307
  }
@@ -5621,7 +6422,7 @@ var ToolAuditLog = class {
5621
6422
  async readAll(sessionId) {
5622
6423
  const fp = this.filePath(sessionId);
5623
6424
  try {
5624
- const raw = await fsp.readFile(fp, "utf8");
6425
+ const raw = await fsp2.readFile(fp, "utf8");
5625
6426
  const out = [];
5626
6427
  for (const line of raw.split("\n")) {
5627
6428
  if (!line.trim()) continue;
@@ -5659,7 +6460,7 @@ var ToolAuditLog = class {
5659
6460
  }
5660
6461
  async sync(sessionId, fp) {
5661
6462
  try {
5662
- const fh = await fsp.open(fp, "r+");
6463
+ const fh = await fsp2.open(fp, "r+");
5663
6464
  try {
5664
6465
  await fh.sync();
5665
6466
  } finally {
@@ -5977,7 +6778,7 @@ var SessionRegistry = class {
5977
6778
  }
5978
6779
  async readAndPrune() {
5979
6780
  try {
5980
- const raw = await fsp.readFile(this.filePath, "utf8");
6781
+ const raw = await fsp2.readFile(this.filePath, "utf8");
5981
6782
  const registry = JSON.parse(raw);
5982
6783
  const now = Date.now();
5983
6784
  let pruned = false;
@@ -6011,11 +6812,11 @@ var SessionRegistry = class {
6011
6812
  const retryDelayMs = 20;
6012
6813
  for (let attempt = 0; attempt < maxRetries; attempt++) {
6013
6814
  try {
6014
- await fsp.mkdir(path2.dirname(this.filePath), { recursive: true });
6015
- let lockHandle = await fsp.open(lockPath, "wx").catch(() => null);
6815
+ await fsp2.mkdir(path2.dirname(this.filePath), { recursive: true });
6816
+ let lockHandle = await fsp2.open(lockPath, "wx").catch(() => null);
6016
6817
  if (!lockHandle) {
6017
6818
  if (await this.breakStaleLock(lockPath)) {
6018
- lockHandle = await fsp.open(lockPath, "wx").catch(() => null);
6819
+ lockHandle = await fsp2.open(lockPath, "wx").catch(() => null);
6019
6820
  }
6020
6821
  if (!lockHandle) {
6021
6822
  await new Promise((r) => setTimeout(r, retryDelayMs * (attempt + 1)));
@@ -6024,14 +6825,14 @@ var SessionRegistry = class {
6024
6825
  }
6025
6826
  try {
6026
6827
  await lockHandle.writeFile(String(process.pid)).catch(() => void 0);
6027
- const raw = await fsp.readFile(this.filePath, "utf8").catch(() => "{}");
6828
+ const raw = await fsp2.readFile(this.filePath, "utf8").catch(() => "{}");
6028
6829
  const registry = JSON.parse(raw);
6029
6830
  fn(registry);
6030
6831
  await this.writeAtomicLocked(registry);
6031
6832
  return;
6032
6833
  } finally {
6033
6834
  await lockHandle.close();
6034
- await fsp.unlink(lockPath).catch(() => void 0);
6835
+ await fsp2.unlink(lockPath).catch(() => void 0);
6035
6836
  }
6036
6837
  } catch {
6037
6838
  return;
@@ -6047,15 +6848,15 @@ var SessionRegistry = class {
6047
6848
  */
6048
6849
  async breakStaleLock(lockPath) {
6049
6850
  try {
6050
- const [stat7, content] = await Promise.all([
6051
- fsp.stat(lockPath),
6052
- fsp.readFile(lockPath, "utf8").catch(() => "")
6851
+ const [stat10, content] = await Promise.all([
6852
+ fsp2.stat(lockPath),
6853
+ fsp2.readFile(lockPath, "utf8").catch(() => "")
6053
6854
  ]);
6054
- const ageMs = Date.now() - stat7.mtimeMs;
6855
+ const ageMs = Date.now() - stat10.mtimeMs;
6055
6856
  const ownerPid = Number.parseInt(content.trim(), 10);
6056
6857
  const ownerDead = Number.isInteger(ownerPid) && ownerPid > 0 && ownerPid !== process.pid && !pidAlive(ownerPid);
6057
6858
  if (ownerDead || ageMs > STALE_LOCK_MS) {
6058
- await fsp.unlink(lockPath).catch(() => void 0);
6859
+ await fsp2.unlink(lockPath).catch(() => void 0);
6059
6860
  return true;
6060
6861
  }
6061
6862
  return false;
@@ -6078,10 +6879,10 @@ var SessionRegistry = class {
6078
6879
  `.${path2.basename(this.filePath)}.${randomUUID().slice(0, 8)}.tmp`
6079
6880
  );
6080
6881
  try {
6081
- await fsp.writeFile(tmp, JSON.stringify(registry, null, 2), "utf8");
6082
- await fsp.rename(tmp, this.filePath);
6882
+ await fsp2.writeFile(tmp, JSON.stringify(registry, null, 2), "utf8");
6883
+ await fsp2.rename(tmp, this.filePath);
6083
6884
  } catch (err) {
6084
- await fsp.unlink(tmp).catch(() => void 0);
6885
+ await fsp2.unlink(tmp).catch(() => void 0);
6085
6886
  throw err;
6086
6887
  }
6087
6888
  }
@@ -6091,17 +6892,17 @@ var SessionRegistry = class {
6091
6892
  const base = path2.basename(this.filePath);
6092
6893
  const now = Date.now();
6093
6894
  const stale = [];
6094
- for (const name of await fsp.readdir(dir)) {
6895
+ for (const name of await fsp2.readdir(dir)) {
6095
6896
  const isTemp = (name.startsWith(`${base}.`) || name.startsWith(`.${base}.`)) && name.endsWith(".tmp");
6096
6897
  if (!isTemp) continue;
6097
- const stat7 = await fsp.stat(path2.join(dir, name)).catch(() => null);
6098
- if (!stat7) continue;
6099
- if (now - stat7.mtimeMs > STALE_TMP_MS) stale.push({ name, mtimeMs: stat7.mtimeMs });
6898
+ const stat10 = await fsp2.stat(path2.join(dir, name)).catch(() => null);
6899
+ if (!stat10) continue;
6900
+ if (now - stat10.mtimeMs > STALE_TMP_MS) stale.push({ name, mtimeMs: stat10.mtimeMs });
6100
6901
  }
6101
6902
  stale.sort((a, b) => b.mtimeMs - a.mtimeMs);
6102
6903
  await Promise.all(
6103
6904
  stale.slice(MAX_STALE_TMP_FILES).map(async ({ name }) => {
6104
- await fsp.unlink(path2.join(dir, name)).catch(() => void 0);
6905
+ await fsp2.unlink(path2.join(dir, name)).catch(() => void 0);
6105
6906
  })
6106
6907
  );
6107
6908
  } catch {
@@ -6127,6 +6928,10 @@ var AGENT_REAP_MS = 3e4;
6127
6928
  var AGENT_SWEEP_INTERVAL_MS = 1e4;
6128
6929
  var PARTIAL_TEXT_CAP = 1200;
6129
6930
  var PARTIAL_FLUSH_THROTTLE_MS = 300;
6931
+ function clampPct(pct) {
6932
+ if (!Number.isFinite(pct)) return 0;
6933
+ return Math.max(0, Math.min(100, pct));
6934
+ }
6130
6935
  var AgentStatusTracker = class {
6131
6936
  events;
6132
6937
  registry;
@@ -6278,7 +7083,7 @@ var AgentStatusTracker = class {
6278
7083
  this.events.onPattern("ctx.pct", (_e, payload) => {
6279
7084
  const p = payload;
6280
7085
  if (typeof p?.load === "number" && Number.isFinite(p.load)) {
6281
- this.leaderCtxPct = Math.round(p.load * 100);
7086
+ this.leaderCtxPct = clampPct(Math.round(p.load * 100));
6282
7087
  this.flush();
6283
7088
  }
6284
7089
  })
@@ -6320,7 +7125,7 @@ var AgentStatusTracker = class {
6320
7125
  const p = payload;
6321
7126
  if (!p?.subagentId) return;
6322
7127
  const entry = touch(p.subagentId);
6323
- if (typeof p.load === "number") entry.ctxPct = Math.round(p.load * 100);
7128
+ if (typeof p.load === "number") entry.ctxPct = clampPct(Math.round(p.load * 100));
6324
7129
  this.flush();
6325
7130
  })
6326
7131
  );
@@ -6481,7 +7286,7 @@ var AgentStatusTracker = class {
6481
7286
  const providerMax = c.provider?.capabilities?.maxContext;
6482
7287
  const maxContext = typeof metaLimit === "number" && metaLimit > 0 ? metaLimit : typeof providerMax === "number" && providerMax > 0 ? providerMax : void 0;
6483
7288
  if (typeof c.lastRequestTokens === "number" && c.lastRequestTokens > 0 && maxContext !== void 0) {
6484
- this.leaderCtxPct = Math.round(c.lastRequestTokens / maxContext * 100);
7289
+ this.leaderCtxPct = clampPct(Math.round(c.lastRequestTokens / maxContext * 100));
6485
7290
  }
6486
7291
  }
6487
7292
  };
@@ -6546,7 +7351,7 @@ var FleetNotifier = class {
6546
7351
  }
6547
7352
  async discover() {
6548
7353
  try {
6549
- const raw = await fsp.readFile(path2.join(this.baseDir, INSTANCES_FILE), "utf8");
7354
+ const raw = await fsp2.readFile(path2.join(this.baseDir, INSTANCES_FILE), "utf8");
6550
7355
  const data = JSON.parse(raw);
6551
7356
  const list = Array.isArray(data?.instances) ? data.instances : [];
6552
7357
  return list.filter((i) => i && typeof i.httpPort === "number").filter((i) => i.pid !== this.selfPid).filter((i) => normRoot(i.projectRoot) === this.projectRoot).filter((i) => pidAlive2(i.pid)).map((i) => {
@@ -6573,7 +7378,7 @@ var DefaultSessionRewinder = class {
6573
7378
  projectRoot;
6574
7379
  async listCheckpoints(sessionId) {
6575
7380
  const file = path2.join(this.sessionsDir, `${sessionId}.jsonl`);
6576
- const raw = await fsp.readFile(file, "utf8");
7381
+ const raw = await fsp2.readFile(file, "utf8");
6577
7382
  const events = parseEvents(raw);
6578
7383
  const fileCountMap = /* @__PURE__ */ new Map();
6579
7384
  for (const event of events) {
@@ -6598,7 +7403,7 @@ var DefaultSessionRewinder = class {
6598
7403
  }
6599
7404
  async rewindToCheckpoint(sessionId, checkpointIndex) {
6600
7405
  const file = path2.join(this.sessionsDir, `${sessionId}.jsonl`);
6601
- const raw = await fsp.readFile(file, "utf8");
7406
+ const raw = await fsp2.readFile(file, "utf8");
6602
7407
  const events = parseEvents(raw);
6603
7408
  let targetIdx = -1;
6604
7409
  for (let i = 0; i < events.length; i++) {
@@ -6637,7 +7442,7 @@ var DefaultSessionRewinder = class {
6637
7442
  }
6638
7443
  async rewindLastN(sessionId, n) {
6639
7444
  const file = path2.join(this.sessionsDir, `${sessionId}.jsonl`);
6640
- const raw = await fsp.readFile(file, "utf8");
7445
+ const raw = await fsp2.readFile(file, "utf8");
6641
7446
  const events = parseEvents(raw);
6642
7447
  const checkpoints = [];
6643
7448
  for (const event of events) {
@@ -6666,7 +7471,7 @@ var DefaultSessionRewinder = class {
6666
7471
  }
6667
7472
  async rewindToStart(sessionId) {
6668
7473
  const file = path2.join(this.sessionsDir, `${sessionId}.jsonl`);
6669
- const raw = await fsp.readFile(file, "utf8");
7474
+ const raw = await fsp2.readFile(file, "utf8");
6670
7475
  const events = parseEvents(raw);
6671
7476
  const allSnapshots = [];
6672
7477
  for (const event of events) {
@@ -6714,7 +7519,7 @@ async function revertSnapshots(snapshots, projectRoot) {
6714
7519
  revertedFiles.push(file.path);
6715
7520
  }
6716
7521
  } else if (file.action === "created") {
6717
- await fsp.unlink(file.path);
7522
+ await fsp2.unlink(file.path);
6718
7523
  revertedFiles.push(file.path);
6719
7524
  } else if (file.action === "modified") {
6720
7525
  if (file.before !== null) {
@@ -6733,7 +7538,7 @@ async function loadTodosCheckpoint(filePath, events, traceId) {
6733
7538
  const t0 = Date.now();
6734
7539
  let raw;
6735
7540
  try {
6736
- raw = await fsp.readFile(filePath, "utf8");
7541
+ raw = await fsp2.readFile(filePath, "utf8");
6737
7542
  } catch (err) {
6738
7543
  events?.emit("storage.error", {
6739
7544
  sessionId: traceId ?? "~boot~",
@@ -6872,7 +7677,7 @@ async function loadPlan(filePath, events) {
6872
7677
  const t0 = Date.now();
6873
7678
  let raw;
6874
7679
  try {
6875
- raw = await fsp.readFile(filePath, "utf8");
7680
+ raw = await fsp2.readFile(filePath, "utf8");
6876
7681
  } catch (err) {
6877
7682
  events?.emit("storage.error", {
6878
7683
  sessionId: "~boot~",
@@ -7183,7 +7988,7 @@ async function loadTasks(filePath, events, traceId) {
7183
7988
  const t0 = Date.now();
7184
7989
  let raw;
7185
7990
  try {
7186
- raw = await fsp.readFile(filePath, "utf8");
7991
+ raw = await fsp2.readFile(filePath, "utf8");
7187
7992
  } catch (err) {
7188
7993
  events?.emit("storage.error", {
7189
7994
  sessionId: traceId ?? "~boot~",
@@ -7282,7 +8087,7 @@ async function mutateTasks(filePath, sessionId, fn, events, traceId) {
7282
8087
  async function loadDirectorState(filePath) {
7283
8088
  let raw;
7284
8089
  try {
7285
- raw = await fsp.readFile(filePath, "utf8");
8090
+ raw = await fsp2.readFile(filePath, "utf8");
7286
8091
  } catch {
7287
8092
  return null;
7288
8093
  }
@@ -7297,7 +8102,7 @@ async function loadDirectorState(filePath) {
7297
8102
  async function acquireDirectorStateLock(lockPath, processId = process.pid) {
7298
8103
  let existing;
7299
8104
  try {
7300
- existing = await fsp.readFile(lockPath, "utf8");
8105
+ existing = await fsp2.readFile(lockPath, "utf8");
7301
8106
  } catch {
7302
8107
  }
7303
8108
  if (existing) {
@@ -7321,7 +8126,7 @@ async function acquireDirectorStateLock(lockPath, processId = process.pid) {
7321
8126
  }
7322
8127
  async function releaseDirectorStateLock(lockPath) {
7323
8128
  try {
7324
- await fsp.unlink(lockPath);
8129
+ await fsp2.unlink(lockPath);
7325
8130
  } catch {
7326
8131
  }
7327
8132
  }
@@ -7465,7 +8270,7 @@ async function loadGoal(filePath, events) {
7465
8270
  const t0 = Date.now();
7466
8271
  let raw;
7467
8272
  try {
7468
- raw = await fsp.readFile(filePath, "utf8");
8273
+ raw = await fsp2.readFile(filePath, "utf8");
7469
8274
  } catch (err) {
7470
8275
  const code = err.code;
7471
8276
  if (code === "ENOENT") {
@@ -7718,12 +8523,12 @@ var DefaultPromptStore = class {
7718
8523
  await ensureDir(this.dir);
7719
8524
  const entries = [];
7720
8525
  try {
7721
- const files = await fsp.readdir(this.dir);
8526
+ const files = await fsp2.readdir(this.dir);
7722
8527
  for (const file of files) {
7723
8528
  if (!file.endsWith(".json")) continue;
7724
8529
  try {
7725
8530
  const raw = JSON.parse(
7726
- await fsp.readFile(path2.join(this.dir, file), "utf8")
8531
+ await fsp2.readFile(path2.join(this.dir, file), "utf8")
7727
8532
  );
7728
8533
  entries.push(raw.entry);
7729
8534
  } catch {
@@ -7738,7 +8543,7 @@ var DefaultPromptStore = class {
7738
8543
  async get(id) {
7739
8544
  const file = path2.join(this.dir, `${id}.json`);
7740
8545
  try {
7741
- const raw = JSON.parse(await fsp.readFile(file, "utf8"));
8546
+ const raw = JSON.parse(await fsp2.readFile(file, "utf8"));
7742
8547
  return raw.entry;
7743
8548
  } catch {
7744
8549
  return null;
@@ -7753,7 +8558,7 @@ var DefaultPromptStore = class {
7753
8558
  async delete(id) {
7754
8559
  const file = path2.join(this.dir, `${id}.json`);
7755
8560
  try {
7756
- await fsp.unlink(file);
8561
+ await fsp2.unlink(file);
7757
8562
  return true;
7758
8563
  } catch {
7759
8564
  return false;
@@ -7860,7 +8665,7 @@ var CloudSync = class {
7860
8665
  lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString(),
7861
8666
  localRev: rev
7862
8667
  };
7863
- await fsp.writeFile(this.statePath, JSON.stringify(syncState, null, 2), "utf8");
8668
+ await fsp2.writeFile(this.statePath, JSON.stringify(syncState, null, 2), "utf8");
7864
8669
  this.state = syncState;
7865
8670
  return {
7866
8671
  ok: true,
@@ -7892,8 +8697,8 @@ var CloudSync = class {
7892
8697
  const rel = segments.slice(2).join("/");
7893
8698
  const destPath = resolvePulledCategoryPath(cat, localPath, rel, entry.path);
7894
8699
  const blobData = await this.getBlob(token, owner, repoName, entry.sha);
7895
- await fsp.mkdir(path2.dirname(destPath), { recursive: true });
7896
- await fsp.writeFile(destPath, Buffer.from(blobData, "base64"));
8700
+ await fsp2.mkdir(path2.dirname(destPath), { recursive: true });
8701
+ await fsp2.writeFile(destPath, Buffer.from(blobData, "base64"));
7897
8702
  }
7898
8703
  const localRev = await this.hashLocalCategories(cfg.categories);
7899
8704
  const syncState = {
@@ -7902,7 +8707,7 @@ var CloudSync = class {
7902
8707
  lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString(),
7903
8708
  localRev
7904
8709
  };
7905
- await fsp.writeFile(this.statePath, JSON.stringify(syncState, null, 2), "utf8");
8710
+ await fsp2.writeFile(this.statePath, JSON.stringify(syncState, null, 2), "utf8");
7906
8711
  this.state = syncState;
7907
8712
  return {
7908
8713
  ok: true,
@@ -7921,7 +8726,7 @@ var CloudSync = class {
7921
8726
  }
7922
8727
  async loadState() {
7923
8728
  try {
7924
- const raw = await fsp.readFile(this.statePath, "utf8");
8729
+ const raw = await fsp2.readFile(this.statePath, "utf8");
7925
8730
  this.state = JSON.parse(raw);
7926
8731
  } catch {
7927
8732
  this.state = null;
@@ -7999,17 +8804,17 @@ var CloudSync = class {
7999
8804
  const localPath = this.categoryToPath(cat);
8000
8805
  if (!localPath) continue;
8001
8806
  try {
8002
- const stat7 = await fsp.stat(localPath);
8003
- if (stat7.isDirectory()) {
8807
+ const stat10 = await fsp2.stat(localPath);
8808
+ if (stat10.isDirectory()) {
8004
8809
  const files = await this.walkDir(localPath, localPath);
8005
8810
  for (const file of files) {
8006
- const content = await fsp.readFile(file, "utf8");
8811
+ const content = await fsp2.readFile(file, "utf8");
8007
8812
  const rel = path2.relative(localPath, file).replace(/\\/g, "/");
8008
8813
  entries.push({ path: `data/${cat}/${rel}`, content, mode: "100644" });
8009
8814
  hashes.push(content);
8010
8815
  }
8011
8816
  } else {
8012
- const content = await fsp.readFile(localPath, "utf8");
8817
+ const content = await fsp2.readFile(localPath, "utf8");
8013
8818
  entries.push({ path: `data/${cat}`, content, mode: "100644" });
8014
8819
  hashes.push(content);
8015
8820
  }
@@ -8025,15 +8830,15 @@ var CloudSync = class {
8025
8830
  const localPath = this.categoryToPath(cat);
8026
8831
  if (!localPath) continue;
8027
8832
  try {
8028
- const stat7 = await fsp.stat(localPath);
8029
- if (stat7.isDirectory()) {
8833
+ const stat10 = await fsp2.stat(localPath);
8834
+ if (stat10.isDirectory()) {
8030
8835
  const files = await this.walkDir(localPath, localPath);
8031
8836
  for (const file of files) {
8032
- const content = await fsp.readFile(file);
8837
+ const content = await fsp2.readFile(file);
8033
8838
  hashes.push(content.toString("base64") + file);
8034
8839
  }
8035
8840
  } else {
8036
- const content = await fsp.readFile(localPath);
8841
+ const content = await fsp2.readFile(localPath);
8037
8842
  hashes.push(content.toString("base64") + localPath);
8038
8843
  }
8039
8844
  } catch {
@@ -8060,7 +8865,7 @@ var CloudSync = class {
8060
8865
  }
8061
8866
  async walkDir(dir, base) {
8062
8867
  const results = [];
8063
- const entries = await fsp.readdir(dir, { withFileTypes: true });
8868
+ const entries = await fsp2.readdir(dir, { withFileTypes: true });
8064
8869
  for (const entry of entries) {
8065
8870
  const full = path2.join(dir, entry.name);
8066
8871
  if (entry.isDirectory()) {