@wrongstack/core 0.273.1 → 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 (63) hide show
  1. package/dist/{agent-bridge-DpKIxHhE.d.ts → agent-bridge-DFo21wmY.d.ts} +1 -1
  2. package/dist/{agent-subagent-runner-Dx7fZ1bE.d.ts → agent-subagent-runner-BwmkIDEd.d.ts} +7 -7
  3. package/dist/{brain-BDcQaku-.d.ts → brain-gfZX3the.d.ts} +1 -1
  4. package/dist/{provider-model-resolve-Cz6OlIOp.d.ts → codex-catalog-CooZ6mOl.d.ts} +47 -4
  5. package/dist/{compactor-BuSdj3fq.d.ts → compactor-riTOds0f.d.ts} +1 -1
  6. package/dist/{config-CR2yoG8c.d.ts → config-BxcrDzri.d.ts} +7 -1
  7. package/dist/{context-DulAr8Zo.d.ts → context-DERiLofu.d.ts} +36 -1
  8. package/dist/coordination/index.d.ts +20 -16
  9. package/dist/coordination/index.js +1622 -1479
  10. package/dist/coordination/index.js.map +1 -1
  11. package/dist/defaults/index.d.ts +26 -26
  12. package/dist/defaults/index.js +1832 -1541
  13. package/dist/defaults/index.js.map +1 -1
  14. package/dist/execution/index.d.ts +15 -15
  15. package/dist/execution/index.js +2 -8
  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-CwcubDkA.d.ts → global-mailbox-Cr8TW4t_.d.ts} +1 -1
  20. package/dist/{goal-preamble-Bu0a2uCG.d.ts → goal-preamble-CEhROp1e.d.ts} +9 -9
  21. package/dist/{goal-store-CTmFuZ8J.d.ts → goal-store-xNSVxmpV.d.ts} +1 -1
  22. package/dist/hq/index.d.ts +5 -5
  23. package/dist/{index-CTq5wU3m.d.ts → index-00KPKAlm.d.ts} +5 -5
  24. package/dist/{index-CxP-HBhX.d.ts → index-pDBSBE1r.d.ts} +2 -2
  25. package/dist/index.d.ts +49 -43
  26. package/dist/index.js +1973 -1444
  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/{mcp-servers-BQaOE71z.d.ts → mcp-servers-BIwRiOxe.d.ts} +3 -3
  31. package/dist/models/index.d.ts +5 -5
  32. package/dist/models/index.js +40 -2
  33. package/dist/models/index.js.map +1 -1
  34. package/dist/{models-registry-BEcny4kP.d.ts → models-registry-8OorW51H.d.ts} +1 -1
  35. package/dist/{multi-agent-coordinator-Bx8EFkv2.d.ts → multi-agent-coordinator-CNx48Zoz.d.ts} +1 -1
  36. package/dist/{null-fleet-bus-BC5ZXCQw.d.ts → null-fleet-bus-B4ZUJYL6.d.ts} +6 -6
  37. package/dist/observability/index.d.ts +2 -2
  38. package/dist/{parallel-eternal-engine-C345TI3n.d.ts → parallel-eternal-engine-rsIclDqO.d.ts} +9 -9
  39. package/dist/{path-resolver-C-W_wzkF.d.ts → path-resolver-BqU-fwzD.d.ts} +3 -3
  40. package/dist/{permission-CsBGZkxp.d.ts → permission-Dgs3v-Xq.d.ts} +1 -1
  41. package/dist/{permission-policy-g3Sg0GdZ.d.ts → permission-policy-Dtht2k0e.d.ts} +2 -2
  42. package/dist/{pipeline-xnw_24Z8.d.ts → pipeline-B-dpCFYS.d.ts} +2 -2
  43. package/dist/{plan-templates-DGaiYEcS.d.ts → plan-templates-B7MxyY2o.d.ts} +32 -5
  44. package/dist/{provider-runner-7J0HqF6B.d.ts → provider-runner-DrmpBE5l.d.ts} +3 -3
  45. package/dist/{retry-policy-kqXJOVkX.d.ts → retry-policy-hYxsm10a.d.ts} +1 -1
  46. package/dist/sdd/index.d.ts +12 -10
  47. package/dist/sdd/index.js +7 -0
  48. package/dist/sdd/index.js.map +1 -1
  49. package/dist/{secret-vault-CMQUr-eB.d.ts → secret-vault-DnqIFhPF.d.ts} +1 -1
  50. package/dist/security/index.d.ts +5 -5
  51. package/dist/{selector-B4r34PWR.d.ts → selector-D21RjDIg.d.ts} +1 -1
  52. package/dist/{session-event-bridge-BD3LoyLC.d.ts → session-event-bridge-Dt54CTvq.d.ts} +1 -1
  53. package/dist/{session-reader-DjrKGD9c.d.ts → session-reader-CceH13Kq.d.ts} +5 -4
  54. package/dist/storage/index.d.ts +46 -12
  55. package/dist/storage/index.js +1987 -1548
  56. package/dist/storage/index.js.map +1 -1
  57. package/dist/tools/index.d.ts +2 -2
  58. package/dist/types/index.d.ts +19 -19
  59. package/dist/types/index.js +134 -42
  60. package/dist/types/index.js.map +1 -1
  61. package/dist/utils/index.d.ts +2 -2
  62. package/dist/{worktree-manager-DHdrWQ_7.d.ts → worktree-manager-DUfBbKzk.d.ts} +1 -1
  63. 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,1541 +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
- /**
750
- * Streaming search over a session's JSONL. Walks the file once, parses
751
- * each event lazily, and yields only the events that match `predicate`.
752
- * Stops as soon as `opts.limit` matches are collected.
753
- *
754
- * Why this exists: `load()` parses the entire file into memory and
755
- * rebuilds `messages`/`toolCallEnds` for every caller. `search()` only
756
- * needs to know which events contain matching text — a per-line
757
- * predicate is enough. The full parse work (and the `_loadCache` poll)
758
- * is wasted in that case.
759
- *
760
- * Memory: O(hits) regardless of file size. Disk: one linear scan,
761
- * terminated at `limit` if the caller asked for one.
762
- *
763
- * Errors: missing file yields []. Corrupt lines are skipped (same
764
- * policy as `load()`). Aborting via `signal` rejects with `AbortError`.
765
- */
766
- async searchEvents(id, predicate, opts) {
767
- const file = this.sessionPath(id, ".jsonl");
768
- const limit = opts?.limit;
769
- const signal = opts?.signal;
770
- const out = [];
771
- let stat7;
772
- try {
773
- stat7 = await fsp.stat(file);
774
- } catch (err) {
775
- if (err.code === "ENOENT") return [];
776
- throw err;
777
- }
778
- if (stat7.size === 0) return [];
779
- let fh;
780
- try {
781
- fh = await fsp.open(file, "r");
782
- const CHUNK = 64 * 1024;
783
- const buf = Buffer.alloc(CHUNK);
784
- let leftover = "";
785
- let eventIndex = 0;
786
- for (let position = 0; ; position += buf.byteLength) {
787
- if (signal?.aborted) {
788
- const reason = signal.reason ?? new DOMException("Aborted", "AbortError");
789
- throw reason;
790
- }
791
- const { bytesRead } = await fh.read(buf, 0, CHUNK, position);
792
- if (bytesRead === 0) break;
793
- const text = leftover + buf.subarray(0, bytesRead).toString("utf8");
794
- const parts = text.split("\n");
795
- leftover = parts.pop() ?? "";
796
- for (const line of parts) {
797
- if (!line) continue;
798
- let ev;
799
- try {
800
- const parsed = JSON.parse(line);
801
- if (parsed === null || typeof parsed !== "object" || typeof parsed.type !== "string" || typeof parsed.ts !== "string") {
802
- continue;
803
- }
804
- ev = parsed;
805
- } catch {
806
- continue;
807
- }
808
- if (predicate(ev, eventIndex, ev.ts)) {
809
- out.push({ event: ev, eventIndex, ts: ev.ts });
810
- if (limit !== void 0 && out.length >= limit) {
811
- return out;
812
- }
813
- }
814
- eventIndex++;
815
- }
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);
816
690
  }
817
- if (leftover.trim()) {
818
- try {
819
- const parsed = JSON.parse(leftover);
820
- if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
821
- const ev = parsed;
822
- if (predicate(ev, eventIndex, ev.ts)) {
823
- out.push({ event: ev, eventIndex, ts: ev.ts });
824
- }
825
- }
826
- } catch {
827
- }
691
+ }
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";
828
702
  }
829
- return out;
830
- } finally {
831
- if (fh) await fh.close().catch(() => void 0);
703
+ } else if (event.type === "file_snapshot") {
704
+ this.fileChangeCount += event.files.length;
705
+ } else if (event.type === "compaction") {
706
+ this.compactionCount++;
707
+ }
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++;
832
722
  }
833
723
  }
834
- async list(limit = 20) {
835
- try {
836
- await ensureDir(this.dir);
837
- const indexed = await this.readIndex();
838
- if (indexed.length > 0) {
839
- indexed.sort((a, b) => {
840
- if (a.startedAt < b.startedAt) return 1;
841
- if (a.startedAt > b.startedAt) return -1;
842
- return a.id.localeCompare(b.id);
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 } : {}
843
767
  });
844
- return indexed.slice(0, limit);
845
768
  }
846
- return await this.listFromDirectoryScan(limit);
847
- } catch {
848
- return [];
849
769
  }
850
- }
851
- // ── Session index (_index.jsonl) ─────────────────────────────────────────
852
- //
853
- // One JSON line per closed session, appended atomically on close().
854
- // When a session is deleted, a tombstone {action:"delete",id:"..."} is
855
- // appended. On read, tombstones filter out matching session entries.
856
- // This keeps listing O(lines-in-index) instead of O(files-on-disk).
857
- //
858
- // The index auto-compacts every N appends to prevent unbounded growth
859
- // from tombstones and duplicate entries (resume cycles).
860
- indexAppendCount = 0;
861
- static COMPACT_EVERY = 30;
862
- /** Append a session summary to the index. */
863
- async appendToIndex(summary) {
770
+ const idxT0 = Date.now();
771
+ let idxOutcome = "success";
772
+ let idxError;
864
773
  try {
865
- await ensureDir(this.dir);
866
- const line = JSON.stringify(summary) + "\n";
867
- await fsp.appendFile(this.indexFile, line, "utf8");
868
- this._indexCache = null;
869
- this.indexAppendCount++;
870
- if (this.indexAppendCount >= _DefaultSessionStore.COMPACT_EVERY) {
871
- await this.compactIndex();
872
- this.indexAppendCount = 0;
873
- }
874
- } catch {
774
+ await this.onCloseCb?.(this.summary);
775
+ } catch (err) {
776
+ idxOutcome = "failure";
777
+ idxError = toErrorMessage(err);
778
+ } finally {
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
+ });
875
789
  }
876
- }
877
- /** Append a tombstone entry for a deleted session. */
878
- async writeTombstone(id) {
879
790
  try {
880
- await ensureDir(this.dir);
881
- const line = JSON.stringify({ action: "delete", id }) + "\n";
882
- await fsp.appendFile(this.indexFile, line, "utf8");
883
- this._indexCache = null;
884
- this.indexAppendCount++;
791
+ await this.handle.close();
885
792
  } catch {
886
793
  }
887
794
  }
888
- /**
889
- * Compact the index: read all entries, drop tombstones, deduplicate
890
- * (keep latest per session), and rewrite. Atomic via temp+rename.
891
- */
892
- async compactIndex() {
893
- const t0 = Date.now();
894
- let outcome = "success";
895
- let errorMsg;
896
- try {
897
- const entries = await this.readIndex();
898
- if (entries.length === 0) return;
899
- const tmp = `${this.indexFile}.compact.tmp`;
900
- const lines = entries.map((s) => JSON.stringify(s)).join("\n") + "\n";
901
- await fsp.writeFile(tmp, lines, "utf8");
902
- await fsp.rename(tmp, this.indexFile);
903
- this._indexCache = null;
904
- } catch (err) {
905
- outcome = "failure";
906
- errorMsg = toErrorMessage(err);
907
- } finally {
908
- this.emitWrite("~compact~", this.indexFile, "compact", outcome, Date.now() - t0, void 0, errorMsg);
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 = [];
909
800
  }
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
+ });
910
821
  }
911
822
  /**
912
- * Read the index file and return deduplicated session summaries.
913
- * Entries with a matching tombstone are filtered out.
914
- * Returns empty array when the index doesn't exist or is corrupt.
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.
915
827
  */
916
- async readIndex() {
917
- let stat7;
918
- try {
919
- const s = await fsp.stat(this.indexFile);
920
- stat7 = { mtimeMs: s.mtimeMs, size: s.size };
921
- } catch {
922
- this._indexCache = null;
923
- return [];
924
- }
925
- if (this._indexCache !== null && this._indexCache.mtimeMs === stat7.mtimeMs && this._indexCache.size === stat7.size) {
926
- return [...this._indexCache.summaries];
828
+ async truncateToCheckpoint(targetPromptIndex) {
829
+ if (!this.filePath) return 0;
830
+ if (this.flushTimer) {
831
+ clearTimeout(this.flushTimer);
832
+ this.flushTimer = null;
927
833
  }
928
- let raw;
929
- try {
930
- raw = await fsp.readFile(this.indexFile, "utf8");
931
- } catch {
932
- this._indexCache = null;
933
- return [];
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;
886
+ }
887
+ fileOffset += bytesRead;
888
+ if (chunkPos >= bytesRead) {
889
+ lineStartOffset = fileOffset;
890
+ }
891
+ }
892
+ } finally {
893
+ await fd?.close();
934
894
  }
935
- const deleted = /* @__PURE__ */ new Set();
936
- const seen = /* @__PURE__ */ new Map();
937
- for (const line of raw.split("\n")) {
938
- if (!line.trim()) continue;
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);
900
+ try {
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;
914
+ }
915
+ const writeFd = await fsp2.open(tmpPath, "w", 384);
939
916
  try {
940
- const entry = JSON.parse(line);
941
- if (entry.action === "delete" && entry.id) {
942
- deleted.add(entry.id);
943
- seen.delete(entry.id);
944
- continue;
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;
945
925
  }
946
- if (entry.id && !deleted.has(entry.id)) {
947
- seen.set(entry.id, entry);
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;
948
950
  }
949
- } catch {
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();
950
961
  }
962
+ await src.close();
963
+ await fsp2.rename(tmpPath, this.filePath);
964
+ this.handle = await fsp2.open(this.filePath, "a", 384);
965
+ } catch (err) {
966
+ await fsp2.unlink(tmpPath).catch(() => void 0);
967
+ this.handle = await fsp2.open(this.filePath, "a", 384).catch(() => this.handle);
968
+ throw err;
951
969
  }
952
- const summaries = Array.from(seen.values());
953
- this._indexCache = { ...stat7, summaries };
954
- return [...summaries];
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;
982
+ }
983
+ async clearSession() {
984
+ if (!this.filePath) return;
985
+ if (this.flushTimer) {
986
+ clearTimeout(this.flushTimer);
987
+ this.flushTimer = null;
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");
955
1000
  }
956
1001
  /**
957
- * Rebuild the index from disk by scanning all sessions and writing a
958
- * fresh _index.jsonl. Useful after manual cleanup or index corruption.
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.
959
1006
  */
960
- async rebuildIndex() {
961
- const ids = await this.collectSessionIds(this.dir);
962
- const summaries = await Promise.all(ids.map((id) => this.summaryFor(id).catch(() => null)));
963
- const valid = summaries.filter((s) => s !== null);
964
- const tmp = `${this.indexFile}.tmp`;
965
- const lines = valid.map((s) => JSON.stringify(s)).join("\n") + "\n";
966
- await fsp.writeFile(tmp, lines, "utf8");
967
- await fsp.rename(tmp, this.indexFile);
968
- this._indexCache = null;
969
- return valid.length;
1007
+ async writeInFlightMarker(context) {
1008
+ if (!context || context.length > 500) {
1009
+ throw new Error("In-flight context must be 1..500 chars");
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() });
970
1017
  }
971
- async listFromDirectoryScan(limit) {
972
- const refs = await this.collectSessionFiles(this.dir);
973
- const candidates = await mapWithConcurrency(
974
- refs,
975
- _DefaultSessionStore.LIST_SCAN_CONCURRENCY,
976
- async (ref) => {
977
- const manifest = await this.readSummaryManifest(ref.id);
978
- if (manifest) return { summary: manifest, needsBackfill: false };
979
- const summary = await this.summaryHeaderFor(ref);
980
- return summary ? { summary, needsBackfill: true } : null;
981
- }
982
- );
983
- const out = candidates.filter((s) => s !== null);
984
- out.sort((a, b) => compareSessionSummaries(a.summary, b.summary));
985
- const selected = out.slice(0, limit);
986
- const summaries = await mapWithConcurrency(
987
- selected,
988
- Math.min(_DefaultSessionStore.LIST_SCAN_CONCURRENCY, Math.max(1, limit)),
989
- async (candidate) => {
990
- if (!candidate.needsBackfill) return candidate.summary;
991
- return await this.summaryFor(candidate.summary.id).catch(() => candidate.summary);
992
- }
993
- );
994
- return summaries.filter((s) => s !== null);
1018
+ /**
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() });
995
1032
  }
996
- async collectSessionFiles(dir, prefix = "", depth = 0) {
997
- let entries;
998
- try {
999
- entries = await fsp.readdir(dir, { withFileTypes: true });
1000
- } catch {
1001
- return [];
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).
1056
+ *
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.
1059
+ */
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();
1002
1079
  }
1003
- const dirEntries = [];
1004
- const files = [];
1005
- for (const entry of entries) {
1006
- if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
1007
- if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
1008
- continue;
1009
- if (entry.isDirectory()) {
1010
- dirEntries.push(entry);
1011
- } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
1012
- if (entry.name === "_index.jsonl") continue;
1013
- const base = entry.name.replace(/\.jsonl$/, "");
1014
- const id = prefix ? `${prefix}/${base}` : base;
1015
- files.push({ id, filePath: path2.join(dir, entry.name) });
1016
- }
1017
- }
1018
- const childFileArrays = await Promise.all(
1019
- dirEntries.map((entry) => {
1020
- const childPrefix = depth === 0 ? entry.name : `${prefix}/${entry.name}`;
1021
- return this.collectSessionFiles(path2.join(dir, entry.name), childPrefix, depth + 1);
1022
- })
1023
- );
1024
- return [...childFileArrays.flat(), ...files];
1025
1080
  }
1026
- /** Recursively collect session IDs from date-shard subdirectories.
1027
- * IDs include the date-prefix path (e.g. "2026-06-06/17-46-57Z_…").
1028
- * Skips `.jsonl`/`.summary.json` root files, dot-files, and
1029
- * sub-directories that belong to fleet/subagent sessions. */
1030
- async collectSessionIds(dir, prefix = "", depth = 0) {
1031
- let entries;
1032
- try {
1033
- entries = await fsp.readdir(dir, { withFileTypes: true });
1034
- } catch {
1035
- return [];
1036
- }
1037
- const dirEntries = [];
1038
- const fileIds = [];
1039
- for (const entry of entries) {
1040
- if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
1041
- if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
1042
- continue;
1043
- if (entry.isDirectory()) {
1044
- dirEntries.push(entry);
1045
- } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
1046
- if (entry.name === "_index.jsonl") continue;
1047
- const base = entry.name.replace(/\.jsonl$/, "");
1048
- fileIds.push(prefix ? `${prefix}/${base}` : base);
1049
- }
1050
- }
1051
- const childIdArrays = await Promise.all(
1052
- dirEntries.map((entry) => {
1053
- const childPrefix = depth === 0 ? entry.name : `${prefix}/${entry.name}`;
1054
- return this.collectSessionIds(path2.join(dir, entry.name), childPrefix, depth + 1);
1055
- })
1056
- );
1057
- return [...childIdArrays.flat(), ...fileIds];
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
+ });
1058
1092
  }
1059
- async summaryFor(id) {
1060
- const manifest = this.sessionPath(id, ".summary.json");
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`);
1061
1148
  const t0 = Date.now();
1062
- let outcome = "success";
1063
- let errorMsg;
1064
- const fromManifest = await this.readSummaryManifest(id, t0);
1065
- if (fromManifest) return fromManifest;
1149
+ let handle;
1066
1150
  try {
1067
- const full = this.sessionPath(id, ".jsonl");
1068
- const stat7 = await fsp.stat(full);
1069
- const summary = await this.summarize(id, stat7.mtime.toISOString());
1070
- await atomicWrite(manifest, JSON.stringify(summary), { mode: 384 }).catch((err) => {
1071
- const msg = toErrorMessage(err);
1072
- this.emitError(id, manifest, "summary_fallback", msg, true);
1073
- console.warn(JSON.stringify({
1074
- level: "warn",
1075
- event: "session_store.manifest_write_failed",
1076
- sessionId: id,
1077
- message: msg,
1078
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1079
- }));
1080
- });
1081
- outcome = "failure";
1082
- errorMsg = "summary fallback \xE2\u20AC\u201D manifest rebuilt";
1083
- this.emitRead(id, manifest, "summary", outcome, Date.now() - t0, errorMsg);
1084
- return summary;
1151
+ handle = await fsp2.open(file, "a", 384);
1085
1152
  } catch (err) {
1086
- outcome = "failure";
1087
- errorMsg = toErrorMessage(err);
1088
- this.emitRead(id, manifest, "summary", outcome, Date.now() - t0, errorMsg);
1089
- return {
1090
- id,
1091
- title: "(damaged)",
1092
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1093
- model: "unknown",
1094
- provider: "unknown",
1095
- tokenTotal: 0
1096
- };
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
+ );
1097
1158
  }
1098
- }
1099
- async readSummaryManifest(id, startTime = Date.now()) {
1100
- const manifest = this.sessionPath(id, ".summary.json");
1101
1159
  try {
1102
- const raw = await fsp.readFile(manifest, "utf8");
1103
- this.emitRead(id, manifest, "summary", "success", Date.now() - startTime);
1104
- return JSON.parse(raw);
1105
- } catch {
1106
- return null;
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({
1170
+ level: "warn",
1171
+ event: "session_store.handle_close_failed",
1172
+ message: e instanceof Error ? e.message : String(e),
1173
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1174
+ })));
1175
+ this.emitError(id, file, "create", toErrorMessage(err), true);
1176
+ throw err;
1107
1177
  }
1108
1178
  }
1109
- async summaryHeaderFor(ref) {
1110
- let mtime = (/* @__PURE__ */ new Date(0)).toISOString();
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;
1111
1184
  try {
1112
- const stat7 = await fsp.stat(ref.filePath);
1113
- if (!stat7.isFile()) {
1114
- return {
1115
- id: ref.id,
1116
- title: "(damaged)",
1117
- startedAt: stat7.mtime.toISOString(),
1118
- model: "unknown",
1119
- provider: "unknown",
1120
- tokenTotal: 0
1121
- };
1122
- }
1123
- mtime = stat7.mtime.toISOString();
1124
- } catch {
1125
- return null;
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
+ );
1126
1192
  }
1127
1193
  try {
1128
- for await (const event of this.iterSessionEvents(ref.filePath)) {
1129
- if (event.type === "session_start") {
1130
- return {
1131
- id: ref.id,
1132
- title: "(empty session)",
1133
- startedAt: event.ts,
1134
- model: event.model ?? "unknown",
1135
- provider: event.provider ?? "unknown",
1136
- tokenTotal: 0
1137
- };
1138
- }
1139
- }
1140
- return {
1141
- id: ref.id,
1142
- title: "(empty session)",
1143
- startedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
1144
- model: "unknown",
1145
- provider: "unknown",
1146
- tokenTotal: 0
1147
- };
1148
- } catch {
1149
- return {
1150
- id: ref.id,
1151
- title: "(damaged)",
1152
- startedAt: mtime,
1153
- model: "unknown",
1154
- provider: "unknown",
1155
- tokenTotal: 0
1156
- };
1157
- }
1158
- }
1159
- /**
1160
- * Delete a session and all associated files: JSONL, summary, plan/todos
1161
- * sidecars, and the session directory (fleet.json, shared/, subagents/).
1162
- *
1163
- * Individual file deletions are best-effort (logged as structured warnings),
1164
- * but a tombstone is always written so readIndex() filters this session out.
1165
- * If the session directory itself can't be removed, the error is surfaced
1166
- * to the caller so prune() can report it.
1167
- */
1168
- async deleteSession(id) {
1169
- const jsonlPath = this.sessionPath(id, ".jsonl");
1170
- const summaryPath = this.sessionPath(id, ".summary.json");
1171
- const shardDir = path2.dirname(path2.join(this.dir, id));
1172
- const base = path2.basename(id);
1173
- const sessDir = path2.join(shardDir, base);
1174
- const deletions = [
1175
- fsp.unlink(jsonlPath),
1176
- fsp.unlink(summaryPath),
1177
- fsp.unlink(path2.join(shardDir, `${base}.plan.json`)),
1178
- fsp.unlink(path2.join(shardDir, `${base}.todos.json`))
1179
- ];
1180
- const results = await Promise.allSettled(deletions);
1181
- for (const r of results) {
1182
- if (r.status === "rejected") {
1183
- const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
1184
- if (r.reason?.code !== "ENOENT") {
1185
- console.warn(JSON.stringify({
1186
- level: "warn",
1187
- event: "session_store.delete_failed",
1188
- sessionId: id,
1189
- message: msg,
1190
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1191
- }));
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)
1192
1213
  }
1193
- }
1194
- }
1195
- await fsp.rm(sessDir, { recursive: true, force: true }).catch((err) => {
1196
- console.warn(JSON.stringify({
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({
1197
1219
  level: "warn",
1198
- event: "session_store.rmdir_failed",
1199
- sessionId: id,
1200
- message: toErrorMessage(err),
1220
+ event: "session_store.handle_close_failed",
1221
+ message: e instanceof Error ? e.message : String(e),
1201
1222
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1202
- }));
1203
- });
1204
- await this.writeTombstone(id);
1205
- }
1206
- async delete(id) {
1207
- await this.deleteSession(id);
1208
- }
1209
- async prune(maxAgeDays = 30) {
1210
- const cutoff = Date.now() - maxAgeDays * 864e5;
1211
- let deleted = 0;
1212
- let activeSessionId = null;
1213
- try {
1214
- const raw = await fsp.readFile(path2.join(this.dir, "active.json"), "utf8");
1215
- const active = JSON.parse(raw);
1216
- activeSessionId = active.sessionId ?? null;
1217
- } catch {
1218
- }
1219
- const isPrunableJsonl = (name) => name.endsWith(".jsonl") && name !== "_index.jsonl" && name !== "_mailbox.jsonl" && !name.endsWith(".replay.jsonl") && !name.endsWith(".audit.jsonl");
1220
- const pruneFile = async (dir, name, prefix) => {
1221
- const jsonlPath = path2.join(dir, name);
1222
- try {
1223
- const stat7 = await fsp.stat(jsonlPath);
1224
- if (stat7.mtimeMs >= cutoff) return;
1225
- } catch {
1226
- return;
1227
- }
1228
- const base = name.replace(/\.jsonl$/, "");
1229
- const id = prefix ? `${prefix}/${base}` : base;
1230
- if (activeSessionId && id === activeSessionId) return;
1231
- await this.deleteSession(id);
1232
- deleted++;
1233
- };
1234
- const entries = await fsp.readdir(this.dir, { withFileTypes: true }).catch(() => []);
1235
- for (const entry of entries) {
1236
- if (entry.isFile()) {
1237
- if (isPrunableJsonl(entry.name)) await pruneFile(this.dir, entry.name, "");
1238
- continue;
1239
- }
1240
- if (!entry.isDirectory()) continue;
1241
- const dateDir = path2.join(this.dir, entry.name);
1242
- const files = await fsp.readdir(dateDir, { withFileTypes: true }).catch(() => []);
1243
- for (const file of files) {
1244
- if (!file.isFile() || !isPrunableJsonl(file.name)) continue;
1245
- await pruneFile(dateDir, file.name, entry.name);
1246
- }
1247
- }
1248
- if (deleted > 0) {
1249
- await this.compactIndex().catch(() => void 0);
1250
- }
1251
- for (const entry of entries) {
1252
- if (!entry.isDirectory()) continue;
1253
- const dateDir = path2.join(this.dir, entry.name);
1254
- try {
1255
- const remaining = await fsp.readdir(dateDir);
1256
- if (remaining.length === 0) {
1257
- await fsp.rmdir(dateDir).catch(() => void 0);
1258
- }
1259
- } catch {
1260
- }
1223
+ })));
1224
+ this.emitError(id, file, "resume", toErrorMessage(err), true);
1225
+ throw err;
1261
1226
  }
1262
- return deleted;
1263
1227
  }
1264
- async clearHistory(id) {
1265
- await this.ensureShardDir(id);
1228
+ async load(id) {
1266
1229
  const file = this.sessionPath(id, ".jsonl");
1267
- const meta = this.sessionPath(id, ".summary.json");
1268
- const record = `${JSON.stringify({
1269
- type: "session_start",
1270
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1271
- id,
1272
- model: "unknown",
1273
- provider: "unknown"
1274
- })}
1275
- `;
1276
- await fsp.writeFile(file, record, "utf8");
1277
- await fsp.unlink(meta).catch(() => void 0);
1278
- }
1279
- async summarize(id, mtime) {
1230
+ const t0 = Date.now();
1231
+ let outcome = "success";
1232
+ let errorMsg;
1233
+ let cacheHit = false;
1280
1234
  try {
1281
- const file = this.sessionPath(id, ".jsonl");
1282
- let title = "(empty session)";
1283
- let startedAt = (/* @__PURE__ */ new Date(0)).toISOString();
1284
- let endedAt;
1285
- let model = "unknown";
1286
- let provider = "unknown";
1287
- let tokenIn = 0;
1288
- let tokenOut = 0;
1289
- let iterationCount = 0;
1290
- let toolCallCount = 0;
1291
- let toolErrorCount = 0;
1292
- let fileChangeCount = 0;
1293
- const toolBreakdown = {};
1294
- let outcome;
1295
- let lastEventType;
1296
- let hasError = false;
1297
- let sawStart = false;
1298
- for await (const e of this.iterSessionEvents(file)) {
1299
- lastEventType = e.type;
1300
- if (e.type === "session_start") {
1301
- if (!sawStart) {
1302
- sawStart = true;
1303
- startedAt = e.ts;
1304
- model = e.model ?? "unknown";
1305
- provider = e.provider ?? "unknown";
1306
- }
1307
- } else if (e.type === "session_end") {
1308
- endedAt = e.ts;
1309
- } else if (e.type === "user_input") {
1310
- if (title === "(empty session)") title = userInputTitle(e.content);
1311
- } else if (e.type === "llm_response") {
1312
- tokenIn += e.usage.input ?? 0;
1313
- tokenOut += e.usage.output ?? 0;
1314
- } else if (e.type === "in_flight_start") iterationCount++;
1315
- else if (e.type === "tool_call_start") {
1316
- toolCallCount++;
1317
- toolBreakdown[e.name] = (toolBreakdown[e.name] ?? 0) + 1;
1318
- } else if (e.type === "tool_result" && e.isError) toolErrorCount++;
1319
- else if (e.type === "file_snapshot") fileChangeCount += e.files.length;
1320
- else if (e.type === "error" || e.type === "provider_error") hasError = true;
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
+ }
1307
+ }
1308
+ } catch {
1309
+ }
1321
1310
  }
1322
- if (lastEventType === "session_end") {
1323
- outcome = "completed";
1324
- } else if (lastEventType === "in_flight_start") {
1325
- outcome = "aborted";
1326
- } else if (hasError) {
1327
- 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
+ });
1328
1316
  }
1329
- return {
1330
- id,
1331
- title,
1332
- startedAt,
1333
- endedAt,
1334
- model,
1335
- provider,
1336
- tokenTotal: tokenIn + tokenOut,
1337
- iterationCount: iterationCount > 0 ? iterationCount : void 0,
1338
- toolCallCount: toolCallCount > 0 ? toolCallCount : void 0,
1339
- toolErrorCount: toolErrorCount > 0 ? toolErrorCount : void 0,
1340
- fileChangeCount: fileChangeCount > 0 ? fileChangeCount : void 0,
1341
- toolBreakdown: Object.keys(toolBreakdown).length > 0 ? toolBreakdown : {},
1342
- outcome
1343
- };
1344
- } catch {
1345
- 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 = {
1346
1325
  id,
1347
- title: "(damaged)",
1348
- startedAt: mtime,
1349
- model: "unknown",
1350
- provider: "unknown",
1351
- tokenTotal: 0
1326
+ startedAt: sessionStartEvent?.ts ?? (/* @__PURE__ */ new Date(0)).toISOString(),
1327
+ endedAt: sessionEndEvent?.ts,
1328
+ model: sessionModel,
1329
+ provider: sessionProvider,
1330
+ pendingToolUses: sessionPendingToolUses
1352
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
+ }
1353
1357
  }
1354
1358
  }
1355
- async *iterSessionEvents(file) {
1356
- const stream = createReadStream(file, { encoding: "utf8" });
1357
- 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;
1358
1382
  try {
1359
- for await (const line of lines) {
1360
- 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()) {
1361
1428
  try {
1362
- const parsed = JSON.parse(line);
1429
+ const parsed = JSON.parse(leftover);
1363
1430
  if (parsed !== null && typeof parsed === "object" && typeof parsed.type === "string" && typeof parsed.ts === "string") {
1364
- yield parsed;
1431
+ const ev = parsed;
1432
+ if (predicate(ev, eventIndex, ev.ts)) {
1433
+ out.push({ event: ev, eventIndex, ts: ev.ts });
1434
+ }
1365
1435
  }
1366
1436
  } catch {
1367
1437
  }
1368
1438
  }
1439
+ return out;
1369
1440
  } finally {
1370
- lines.close();
1371
- stream.destroy();
1441
+ if (fh) await fh.close().catch(() => void 0);
1372
1442
  }
1373
1443
  }
1374
- };
1375
- function extractToolCallEnds(events) {
1376
- const result = [];
1377
- for (const e of events) {
1378
- if (e.type === "tool_call_end") {
1379
- result.push({
1380
- name: e.name,
1381
- id: e.id,
1382
- durationMs: e.durationMs,
1383
- ok: e.ok ?? false,
1384
- outputBytes: e.outputBytes,
1385
- outputTokens: e.outputTokens,
1386
- outputLines: e.outputLines
1387
- });
1444
+ async list(limit = 20) {
1445
+ try {
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);
1455
+ }
1456
+ return await this.listFromDirectoryScan(limit);
1457
+ } catch {
1458
+ return [];
1388
1459
  }
1389
1460
  }
1390
- return result;
1391
- }
1392
- var FileSessionWriter = class _FileSessionWriter {
1393
- constructor(id, handle, startedAt, meta, events, opts = {}, traceId) {
1394
- this.id = id;
1395
- this.handle = handle;
1396
- this.startedAt = startedAt;
1397
- this.meta = meta;
1398
- this.events = events;
1399
- this.resumed = opts.resumed ?? false;
1400
- this.manifestFile = opts.dir ? path2.join(opts.dir, `${path2.basename(id)}.summary.json`) : "";
1401
- this.filePath = opts.filePath ?? "";
1402
- this.secretScrubber = opts.secretScrubber;
1403
- this.onCloseCb = opts.onClose;
1404
- this.summary = {
1405
- id,
1406
- title: "(empty session)",
1407
- startedAt,
1408
- model: meta.model ?? "unknown",
1409
- provider: meta.provider ?? "unknown",
1410
- tokenTotal: 0
1411
- };
1412
- this.traceId = traceId;
1461
+ /**
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.
1465
+ *
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.
1470
+ */
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 [];
1489
+ }
1413
1490
  }
1414
- id;
1415
- handle;
1416
- startedAt;
1417
- meta;
1418
- events;
1419
- closed = false;
1420
- closePromise = null;
1421
- manifestFile;
1422
- summary;
1423
- tokenIn = 0;
1424
- tokenOut = 0;
1425
- filePath;
1426
- get transcriptPath() {
1427
- return this.filePath || void 0;
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
+ }
1428
1529
  }
1429
1530
  /**
1430
- * Lazy session_start/session_resumed init, shared by all appenders.
1431
- * A single promise (not a boolean) so a second append racing the first
1432
- * can't push its event into the buffer BEFORE the first append's event —
1433
- * every appender awaits the same init and resumes in FIFO call order.
1531
+ * Compact the index: read all entries, drop tombstones, deduplicate
1532
+ * (keep latest per session), and rewrite. Atomic via temp+rename.
1434
1533
  */
1435
- initPromise = null;
1436
- ensureInit() {
1437
- if (!this.initPromise) this.initPromise = this.writeSessionStartLazy();
1438
- return this.initPromise;
1439
- }
1440
- resumed;
1441
- appendFailCount = 0;
1442
- lastAppendWarnAt = 0;
1443
- secretScrubber;
1444
- onCloseCb;
1445
- /** Implements SessionWriter.traceId — propagated from ContextInit.traceId. */
1446
- traceId;
1447
- // ── Write buffer — batches events to reduce per-event disk I/O ─────────
1448
- //
1449
- // Every append() pushes the scrubbed event into an in-memory buffer instead
1450
- // of calling handle.appendFile() synchronously. The buffer flushes to disk
1451
- // when it reaches FLUSH_SIZE events OR after FLUSH_INTERVAL_MS of inactivity.
1452
- // This cuts the number of disk writes by ~95% without changing the on-disk
1453
- // format — the JSONL is still one JSON object per line.
1454
- writeBuffer = [];
1455
- flushTimer = null;
1456
- static FLUSH_INTERVAL_MS = 500;
1457
- static FLUSH_SIZE = 50;
1458
- // ── Write serialization ─────────────────────────────────────────────────
1459
- //
1460
- // All disk writes are funneled through a FIFO promise chain. Without it,
1461
- // a timer-driven flush racing an explicit flush()/close() issues two
1462
- // concurrent appendFile() calls on the shared O_APPEND handle — the kernel
1463
- // may complete them out of order (chronology breaks) or, for large
1464
- // batches, interleave partial writes (torn JSONL lines). The chain keeps
1465
- // exactly one write in flight; failures don't break the chain.
1466
- writeChain = Promise.resolve();
1467
- /** Enqueue a write on the FIFO chain. Resolves/rejects with that write. */
1468
- enqueueWrite(data) {
1469
- const write = this.writeChain.then(() => this.handle.appendFile(data, "utf8"));
1470
- this.writeChain = write.then(
1471
- () => void 0,
1472
- () => void 0
1473
- );
1474
- return write;
1534
+ async compactIndex() {
1535
+ const t0 = Date.now();
1536
+ let outcome = "success";
1537
+ let errorMsg;
1538
+ try {
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;
1546
+ } catch (err) {
1547
+ outcome = "failure";
1548
+ errorMsg = toErrorMessage(err);
1549
+ } finally {
1550
+ this.emitWrite("~compact~", this.indexFile, "compact", outcome, Date.now() - t0, void 0, errorMsg);
1551
+ }
1475
1552
  }
1476
- // ── Enriched summary tracking ──────────────────────────────────────────
1477
- iterationCount = 0;
1478
- toolCallCount = 0;
1479
- toolErrorCount = 0;
1480
- toolBreakdown = {};
1481
- fileChangeCount = 0;
1482
- compactionCount = 0;
1483
- outcome = void 0;
1484
1553
  /**
1485
- * Scrub secrets out of conversation-turn events before they are observed
1486
- * for the summary, written to the JSONL log, or surfaced on resume. Only
1487
- * `user_input` / `llm_response` carry free-form user/model text; other event
1488
- * types either have no secret-bearing content or are already scrubbed
1489
- * upstream (tool results). Returns the event unchanged when no scrubber is
1490
- * configured.
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.
1491
1557
  */
1492
- scrubEvent(event) {
1493
- const s = this.secretScrubber;
1494
- if (!s) return event;
1495
- if (event.type === "user_input") {
1496
- return {
1497
- ...event,
1498
- content: typeof event.content === "string" ? s.scrub(event.content) : s.scrubObject(event.content)
1499
- };
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 [];
1500
1566
  }
1501
- if (event.type === "llm_response") {
1502
- return { ...event, content: s.scrubObject(event.content) };
1567
+ if (this._indexCache !== null && this._indexCache.mtimeMs === stat10.mtimeMs && this._indexCache.size === stat10.size) {
1568
+ return [...this._indexCache.summaries];
1503
1569
  }
1504
- return event;
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 {
1592
+ }
1593
+ }
1594
+ const summaries = Array.from(seen.values());
1595
+ this._indexCache = { ...stat10, summaries };
1596
+ return [...summaries];
1505
1597
  }
1506
- pendingFileSnapshots = [];
1507
- /** Tracks open tool_use IDs during the current run to serialize on close for resume. */
1508
- openToolUses = /* @__PURE__ */ new Set();
1509
- recordFileChange(input) {
1510
- this.pendingFileSnapshots.push(input);
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;
1511
1612
  }
1512
- get pendingToolUses() {
1513
- return Array.from(this.openToolUses);
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 });
1624
+ }
1625
+ }
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);
1514
1634
  }
1515
- async writeSessionStartLazy() {
1516
- const record = `${JSON.stringify({
1517
- type: this.resumed ? "session_resumed" : "session_start",
1518
- ts: this.startedAt,
1519
- id: this.id,
1520
- model: this.meta.model ?? "unknown",
1521
- provider: this.meta.provider ?? "unknown"
1522
- })}
1523
- `;
1635
+ async collectShardKeys() {
1636
+ let entries;
1524
1637
  try {
1525
- await this.enqueueWrite(record);
1638
+ entries = await fsp2.readdir(this.dir, { withFileTypes: true });
1526
1639
  } catch {
1640
+ return [""];
1527
1641
  }
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);
1647
+ }
1648
+ return shardKeys;
1528
1649
  }
1529
- async append(event) {
1530
- if (this.closed) return;
1531
- await this.ensureInit();
1532
- const scrubbed = this.scrubEvent(event);
1533
- this.observeForSummary(scrubbed);
1534
- this.writeBuffer.push(scrubbed);
1535
- if (this.writeBuffer.length >= _FileSessionWriter.FLUSH_SIZE) {
1536
- if (this.flushTimer) {
1537
- clearTimeout(this.flushTimer);
1538
- this.flushTimer = null;
1539
- }
1540
- await this.flushBuffer();
1541
- } else {
1542
- this.scheduleFlush();
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 {
1543
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("/"));
1544
1689
  }
1545
- async appendBatch(events) {
1546
- if (this.closed || events.length === 0) return;
1547
- await this.ensureInit();
1548
- for (const event of events) {
1549
- const scrubbed = this.scrubEvent(event);
1550
- this.observeForSummary(scrubbed);
1551
- this.writeBuffer.push(scrubbed);
1690
+ async collectSessionFiles(dir, prefix = "", depth = 0) {
1691
+ let entries;
1692
+ try {
1693
+ entries = await fsp2.readdir(dir, { withFileTypes: true });
1694
+ } catch {
1695
+ return [];
1552
1696
  }
1553
- if (this.writeBuffer.length >= _FileSessionWriter.FLUSH_SIZE) {
1554
- if (this.flushTimer) {
1555
- clearTimeout(this.flushTimer);
1556
- this.flushTimer = null;
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) });
1557
1710
  }
1558
- await this.flushBuffer();
1559
- } else {
1560
- this.scheduleFlush();
1561
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];
1562
1719
  }
1563
- /**
1564
- * Flush buffered events to disk immediately. Critical events
1565
- * (user_input, llm_response) call this so they survive SIGKILL/crash
1566
- * instead of sitting in the in-memory buffer for up to 500ms.
1567
- *
1568
- * Idempotent — cancels any pending timer and writes whatever has
1569
- * accumulated in the buffer. Safe to call even when the buffer
1570
- * is empty (no-op).
1571
- */
1572
- async flush() {
1573
- if (this.flushTimer) {
1574
- clearTimeout(this.flushTimer);
1575
- 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 [];
1576
1730
  }
1577
- await this.flushBuffer();
1578
- }
1579
- /** Schedule a deferred flush. No-op if a timer is already pending. */
1580
- scheduleFlush() {
1581
- if (this.flushTimer) return;
1582
- this.flushTimer = setTimeout(() => {
1583
- this.flushTimer = null;
1584
- this.flushBuffer().catch(() => {
1585
- });
1586
- }, _FileSessionWriter.FLUSH_INTERVAL_MS);
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);
1743
+ }
1744
+ }
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];
1587
1752
  }
1588
- /**
1589
- * Flush all buffered events to disk as a single appendFile call.
1590
- * Errors use the same throttled-warning pattern the old per-event
1591
- * append path used — one warning every 5s with a suppressed count.
1592
- * On failure the buffer is cleared (events are best-effort, same as
1593
- * the old per-event path where a failed write was silently dropped).
1594
- */
1595
- async flushBuffer() {
1596
- if (this.writeBuffer.length === 0) return;
1597
- const eventCount = this.writeBuffer.length;
1598
- const batch = this.writeBuffer.map((e) => JSON.stringify(e)).join("\n") + "\n";
1599
- this.writeBuffer = [];
1753
+ async summaryFor(id) {
1754
+ const manifest = this.sessionPath(id, ".summary.json");
1600
1755
  const t0 = Date.now();
1601
1756
  let outcome = "success";
1602
1757
  let errorMsg;
1758
+ const fromManifest = await this.readSummaryManifest(id, t0);
1759
+ if (fromManifest) return fromManifest;
1603
1760
  try {
1604
- await this.enqueueWrite(batch);
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
+ }));
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;
1605
1779
  } catch (err) {
1606
1780
  outcome = "failure";
1607
1781
  errorMsg = toErrorMessage(err);
1608
- this.appendFailCount += eventCount;
1609
- const now = Date.now();
1610
- if (now - this.lastAppendWarnAt > 5e3) {
1611
- const suppressed = this.appendFailCount - 1;
1612
- const tail = suppressed > 0 ? ` (+${suppressed} suppressed)` : "";
1613
- console.warn(
1614
- "[session] flush failed:",
1615
- toErrorMessage(err),
1616
- tail
1617
- );
1618
- this.lastAppendWarnAt = now;
1619
- this.appendFailCount = 0;
1620
- }
1621
- } finally {
1622
- this.events?.emit("storage.write", {
1623
- sessionId: this.id,
1624
- store: "session",
1625
- filePath: this.filePath,
1626
- operation: "flush",
1627
- outcome,
1628
- durationMs: Date.now() - t0,
1629
- ...errorMsg !== void 0 ? { error: errorMsg } : {},
1630
- ...eventCount !== void 0 ? { eventCount } : {},
1631
- ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
1632
- });
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
+ };
1633
1791
  }
1634
1792
  }
1635
- observeForSummary(event) {
1636
- if (event.type === "llm_response") {
1637
- for (const block of event.content) {
1638
- if (block.type === "tool_use") this.openToolUses.add(block.id);
1639
- }
1793
+ async readSummaryManifest(id, startTime = Date.now()) {
1794
+ const manifest = this.sessionPath(id, ".summary.json");
1795
+ try {
1796
+ const raw = await fsp2.readFile(manifest, "utf8");
1797
+ this.emitRead(id, manifest, "summary", "success", Date.now() - startTime);
1798
+ return JSON.parse(raw);
1799
+ } catch {
1800
+ return null;
1640
1801
  }
1641
- if (event.type === "tool_use") {
1642
- this.openToolUses.add(event.id);
1643
- } else if (event.type === "tool_call_start") {
1644
- this.toolCallCount++;
1645
- this.toolBreakdown[event.name] = (this.toolBreakdown[event.name] ?? 0) + 1;
1646
- } else if (event.type === "tool_result") {
1647
- this.openToolUses.delete(event.id);
1648
- if (event.isError) {
1649
- this.toolErrorCount++;
1650
- this.outcome = "error";
1802
+ }
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
+ };
1651
1816
  }
1652
- } else if (event.type === "file_snapshot") {
1653
- this.fileChangeCount += event.files.length;
1654
- } else if (event.type === "compaction") {
1655
- this.compactionCount++;
1656
- }
1657
- if (event.type === "error" || event.type === "provider_error") {
1658
- this.outcome = "error";
1817
+ mtime = stat10.mtime.toISOString();
1818
+ } catch {
1819
+ return null;
1659
1820
  }
1660
- if (event.type === "user_input" && this.summary.title === "(empty session)") {
1661
- this.summary = { ...this.summary, title: userInputTitle(event.content) };
1662
- } else if (event.type === "llm_response") {
1663
- this.tokenIn += event.usage.input;
1664
- this.tokenOut += event.usage.output;
1665
- this.summary = { ...this.summary, tokenTotal: this.tokenIn + this.tokenOut };
1666
- } else if (event.type === "session_end") {
1667
- const total = event.usage.input + event.usage.output;
1668
- if (total > 0) this.summary = { ...this.summary, tokenTotal: total };
1669
- } else if (event.type === "in_flight_start") {
1670
- this.iterationCount++;
1821
+ try {
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
+ };
1832
+ }
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
+ };
1671
1851
  }
1672
1852
  }
1673
- async close() {
1674
- if (this.closePromise) return this.closePromise;
1675
- this.closePromise = this.doClose();
1676
- return this.closePromise;
1677
- }
1678
- async doClose() {
1679
- this.closed = true;
1680
- if (this.flushTimer) {
1681
- clearTimeout(this.flushTimer);
1682
- this.flushTimer = null;
1683
- }
1684
- await this.flushBuffer();
1685
- await this.writeChain;
1686
- this.summary = {
1687
- ...this.summary,
1688
- endedAt: (/* @__PURE__ */ new Date()).toISOString(),
1689
- iterationCount: this.iterationCount,
1690
- toolCallCount: this.toolCallCount,
1691
- toolErrorCount: this.toolErrorCount,
1692
- fileChangeCount: this.fileChangeCount,
1693
- compactionCount: this.compactionCount > 0 ? this.compactionCount : void 0,
1694
- toolBreakdown: { ...this.toolBreakdown },
1695
- outcome: this.outcome ?? "completed"
1696
- };
1697
- if (this.manifestFile) {
1698
- const t0 = Date.now();
1699
- let outcome = "success";
1700
- let errorMsg;
1701
- try {
1702
- await atomicWrite(this.manifestFile, JSON.stringify(this.summary), { mode: 384 });
1703
- } catch (err) {
1704
- outcome = "failure";
1705
- errorMsg = toErrorMessage(err);
1706
- } finally {
1707
- this.events?.emit("storage.write", {
1708
- sessionId: this.id,
1709
- store: "session",
1710
- filePath: this.manifestFile,
1711
- operation: "close",
1712
- outcome,
1713
- durationMs: Date.now() - t0,
1714
- ...errorMsg !== void 0 ? { error: errorMsg } : {},
1715
- ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
1716
- });
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
+ }));
1886
+ }
1717
1887
  }
1718
1888
  }
1719
- const idxT0 = Date.now();
1720
- let idxOutcome = "success";
1721
- let idxError;
1722
- try {
1723
- await this.onCloseCb?.(this.summary);
1724
- } catch (err) {
1725
- idxOutcome = "failure";
1726
- idxError = toErrorMessage(err);
1727
- } finally {
1728
- this.events?.emit("storage.write", {
1729
- sessionId: this.summary.id,
1730
- store: "session",
1731
- filePath: this.filePath,
1732
- operation: "index_append",
1733
- outcome: idxOutcome,
1734
- durationMs: Date.now() - idxT0,
1735
- ...idxError !== void 0 ? { error: idxError } : {},
1736
- ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
1737
- });
1738
- }
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;
1739
1907
  try {
1740
- await this.handle.close();
1908
+ const raw = await fsp2.readFile(path2.join(this.dir, "active.json"), "utf8");
1909
+ const active = JSON.parse(raw);
1910
+ activeSessionId = active.sessionId ?? null;
1741
1911
  } catch {
1742
1912
  }
1743
- }
1744
- async writeCheckpoint(promptIndex, promptPreview) {
1745
- const fileCount = this.pendingFileSnapshots.length;
1746
- if (fileCount > 0) {
1747
- await this.writeFileSnapshot(promptIndex, [...this.pendingFileSnapshots]);
1748
- this.pendingFileSnapshots = [];
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);
1940
+ }
1749
1941
  }
1750
- await this.append({
1751
- type: "checkpoint",
1752
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1753
- promptIndex,
1754
- promptPreview
1755
- });
1756
- this.events?.emit("checkpoint.written", {
1757
- promptIndex,
1758
- promptPreview,
1759
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1760
- fileCount
1761
- });
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);
1948
+ try {
1949
+ const remaining = await fsp2.readdir(dateDir);
1950
+ if (remaining.length === 0) {
1951
+ await fsp2.rmdir(dateDir).catch(() => void 0);
1952
+ }
1953
+ } catch {
1954
+ }
1955
+ }
1956
+ return deleted;
1762
1957
  }
1763
- async writeFileSnapshot(promptIndex, files) {
1764
- await this.append({
1765
- type: "file_snapshot",
1958
+ async clearHistory(id) {
1959
+ await this.ensureShardDir(id);
1960
+ const file = this.sessionPath(id, ".jsonl");
1961
+ const meta = this.sessionPath(id, ".summary.json");
1962
+ const record = `${JSON.stringify({
1963
+ type: "session_start",
1766
1964
  ts: (/* @__PURE__ */ new Date()).toISOString(),
1767
- promptIndex,
1768
- files
1769
- });
1965
+ id,
1966
+ model: "unknown",
1967
+ provider: "unknown"
1968
+ })}
1969
+ `;
1970
+ await fsp2.writeFile(file, record, "utf8");
1971
+ await fsp2.unlink(meta).catch(() => void 0);
1770
1972
  }
1771
- /**
1772
- * Truncate the session file to the checkpoint with the given promptIndex,
1773
- * removing all events that follow it. Uses a single-pass byte-offset scan
1774
- * so post-checkpoint content is never read or parsed — O(1) memory instead
1775
- * of O(N) JSON.parse calls over the full file.
1776
- */
1777
- async truncateToCheckpoint(targetPromptIndex) {
1778
- if (!this.filePath) return 0;
1779
- if (this.flushTimer) {
1780
- clearTimeout(this.flushTimer);
1781
- this.flushTimer = null;
1782
- }
1783
- await this.flushBuffer();
1784
- await this.writeChain;
1785
- const CHUNK_SIZE = 65536;
1786
- let fd;
1787
- let fileOffset = 0;
1788
- let lineStartOffset = 0;
1789
- let checkpointByteOffset = -1;
1790
- let removedCount = 0;
1791
- let targetCheckpointSeen = false;
1973
+ async summarize(id, mtime) {
1792
1974
  try {
1793
- fd = await fsp.open(this.filePath, "r", 384);
1794
- while (true) {
1795
- const buf = Buffer.alloc(CHUNK_SIZE);
1796
- const { bytesRead } = await fd.read(buf, 0, CHUNK_SIZE, fileOffset);
1797
- if (bytesRead === 0) break;
1798
- let chunkPos = 0;
1799
- while (chunkPos < bytesRead) {
1800
- const idx = buf.indexOf("\n", chunkPos);
1801
- if (idx === -1) {
1802
- lineStartOffset = fileOffset + chunkPos;
1803
- break;
1804
- }
1805
- if (checkpointByteOffset !== -1) {
1806
- removedCount++;
1807
- } else {
1808
- const lineBytes = buf.subarray(chunkPos, idx);
1809
- const line = new TextDecoder("utf-8", { fatal: false }).decode(lineBytes);
1810
- if (line.trim()) {
1811
- try {
1812
- const event = JSON.parse(line);
1813
- if (event.type === "checkpoint") {
1814
- if (event.promptIndex === targetPromptIndex) {
1815
- checkpointByteOffset = lineStartOffset;
1816
- targetCheckpointSeen = true;
1817
- } else if (event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
1818
- checkpointByteOffset = lineStartOffset;
1819
- }
1820
- } else if (targetCheckpointSeen && event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
1821
- removedCount++;
1822
- } else if (targetCheckpointSeen && event.promptIndex === void 0) {
1823
- removedCount++;
1824
- } else if (!targetCheckpointSeen && event.promptIndex === void 0) {
1825
- removedCount++;
1826
- } else if (!targetCheckpointSeen && event.promptIndex !== void 0 && event.promptIndex > targetPromptIndex) {
1827
- removedCount++;
1828
- }
1829
- } catch {
1830
- }
1831
- }
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";
1832
2000
  }
1833
- chunkPos = idx + 1;
1834
- lineStartOffset = fileOffset + chunkPos;
1835
- }
1836
- fileOffset += bytesRead;
1837
- if (chunkPos >= bytesRead) {
1838
- lineStartOffset = fileOffset;
1839
- }
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;
1840
2015
  }
1841
- } finally {
1842
- await fd?.close();
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
+ };
1843
2047
  }
1844
- if (checkpointByteOffset === -1) return 0;
1845
- await this.writeChain;
1846
- await this.handle.close();
1847
- const tmpPath = `${this.filePath}.rewind.tmp`;
1848
- const src = await fsp.open(this.filePath, "r", 384);
2048
+ }
2049
+ async *iterSessionEvents(file) {
2050
+ const stream = createReadStream(file, { encoding: "utf8" });
2051
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
1849
2052
  try {
1850
- const statResult = await src.stat();
1851
- const totalSize = statResult.size;
1852
- const prefixBytes = checkpointByteOffset;
1853
- let newlineAfterCheckpoint = prefixBytes;
1854
- if (prefixBytes < totalSize) {
1855
- const probeBuf = Buffer.alloc(Math.min(CHUNK_SIZE, totalSize - prefixBytes));
1856
- const { bytesRead: probeRead } = await src.read(probeBuf, 0, probeBuf.length, prefixBytes);
1857
- if (probeRead > 0) {
1858
- const nl = probeBuf.indexOf("\n");
1859
- newlineAfterCheckpoint = nl !== -1 ? prefixBytes + nl + 1 : totalSize;
1860
- }
1861
- } else {
1862
- newlineAfterCheckpoint = totalSize;
1863
- }
1864
- const writeFd = await fsp.open(tmpPath, "w", 384);
1865
- try {
1866
- let readOffset = 0;
1867
- while (readOffset < newlineAfterCheckpoint) {
1868
- const toCopy = Math.min(CHUNK_SIZE, newlineAfterCheckpoint - readOffset);
1869
- const copyBuf = Buffer.alloc(toCopy);
1870
- const { bytesRead: r } = await src.read(copyBuf, 0, toCopy, readOffset);
1871
- if (r === 0) break;
1872
- await writeFd.write(copyBuf, 0, r);
1873
- readOffset += r;
1874
- }
1875
- const raw = await fsp.readFile(this.filePath);
1876
- const tail = raw.subarray(newlineAfterCheckpoint).toString("utf8");
1877
- for (const line of tail.split("\n")) {
1878
- if (!line.trim()) continue;
1879
- try {
1880
- JSON.parse(line);
1881
- } catch {
1882
- await writeFd.write(`${line}
1883
- `, void 0, "utf8");
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;
1884
2059
  }
2060
+ } catch {
1885
2061
  }
1886
- } finally {
1887
- await writeFd.close();
1888
2062
  }
1889
- await src.close();
1890
- await fsp.rename(tmpPath, this.filePath);
1891
- this.handle = await fsp.open(this.filePath, "a", 384);
1892
- } catch (err) {
1893
- await fsp.unlink(tmpPath).catch(() => void 0);
1894
- this.handle = await fsp.open(this.filePath, "a", 384).catch(() => this.handle);
1895
- throw err;
1896
- }
1897
- await this.append({
1898
- type: "rewound",
1899
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1900
- toPromptIndex: targetPromptIndex,
1901
- revertedFiles: []
1902
- });
1903
- this.events?.emit("session.rewound", {
1904
- toPromptIndex: targetPromptIndex,
1905
- revertedFiles: [],
1906
- removedEvents: removedCount
1907
- });
1908
- return removedCount;
1909
- }
1910
- async clearSession() {
1911
- if (!this.filePath) return;
1912
- if (this.flushTimer) {
1913
- clearTimeout(this.flushTimer);
1914
- this.flushTimer = null;
2063
+ } finally {
2064
+ lines.close();
2065
+ stream.destroy();
1915
2066
  }
1916
- this.writeBuffer = [];
1917
- await this.writeChain;
1918
- const record = `${JSON.stringify({
1919
- type: "session_start",
1920
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1921
- id: this.id,
1922
- model: this.meta.model ?? "unknown",
1923
- provider: this.meta.provider ?? "unknown"
1924
- })}
1925
- `;
1926
- await fsp.writeFile(this.filePath, record, "utf8");
1927
2067
  }
1928
- /**
1929
- * Idea #1 — write an in-flight marker. The agent loop should call
1930
- * this at the start of each long-running operation; a matching
1931
- * `clearInFlightMarker` follows on clean exit. A stale marker
1932
- * (no end) is what `SessionRecovery.detectStale` looks for.
1933
- */
1934
- async writeInFlightMarker(context) {
1935
- if (!context || context.length > 500) {
1936
- throw new Error("In-flight context must be 1..500 chars");
2068
+ };
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
+ });
1937
2082
  }
1938
- await this.append({
1939
- type: "in_flight_start",
1940
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1941
- context
1942
- });
1943
- this.events?.emit("in_flight.started", { context, ts: (/* @__PURE__ */ new Date()).toISOString() });
1944
- }
1945
- /**
1946
- * Idea #1 — close the in-flight marker. Idempotent in spirit
1947
- * (you can call it after a successful iteration even if you
1948
- * didn't open one this round) — but the session log records
1949
- * every call so postmortem tooling can see "the agent finished
1950
- * cleanly X times, then died without finishing Y".
1951
- */
1952
- async clearInFlightMarker(reason) {
1953
- await this.append({
1954
- type: "in_flight_end",
1955
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1956
- reason
1957
- });
1958
- this.events?.emit("in_flight.ended", { reason, ts: (/* @__PURE__ */ new Date()).toISOString() });
1959
2083
  }
1960
- };
1961
- function userInputTitle(content) {
1962
- const text = typeof content === "string" ? content : content.filter((b) => b.type === "text").map((b) => b.text).join(" ");
1963
- return (text || "(non-text input)").slice(0, 60);
2084
+ return result;
1964
2085
  }
1965
2086
  function compareSessionSummaries(a, b) {
1966
2087
  if (a.startedAt < b.startedAt) return 1;
1967
2088
  if (a.startedAt > b.startedAt) return -1;
1968
2089
  return a.id.localeCompare(b.id);
1969
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
+ }
1970
2103
  async function mapWithConcurrency(items, concurrency, fn) {
1971
2104
  if (items.length === 0) return [];
1972
2105
  const out = new Array(items.length);
@@ -2035,7 +2168,7 @@ var QueueStore = class {
2035
2168
  const t0 = Date.now();
2036
2169
  let raw;
2037
2170
  try {
2038
- raw = await fsp.readFile(this.file, "utf8");
2171
+ raw = await fsp2.readFile(this.file, "utf8");
2039
2172
  } catch (err) {
2040
2173
  const code = err.code;
2041
2174
  if (code === "ENOENT") {
@@ -2116,7 +2249,7 @@ var QueueStore = class {
2116
2249
  async clear() {
2117
2250
  const t0 = Date.now();
2118
2251
  try {
2119
- await fsp.unlink(this.file);
2252
+ await fsp2.unlink(this.file);
2120
2253
  this.events?.emit("storage.write", {
2121
2254
  sessionId: this.traceId ?? "~boot~",
2122
2255
  store: "queue",
@@ -2173,7 +2306,7 @@ var DefaultAttachmentStore = class {
2173
2306
  let spooledPath;
2174
2307
  let data = input.data;
2175
2308
  if (this.spoolDir && bytes >= this.spoolThreshold) {
2176
- await fsp.mkdir(this.spoolDir, { recursive: true });
2309
+ await fsp2.mkdir(this.spoolDir, { recursive: true });
2177
2310
  spooledPath = path2.join(this.spoolDir, `${id}.bin`);
2178
2311
  await atomicWrite(spooledPath, input.data, {
2179
2312
  encoding: input.kind === "image" ? "base64" : "utf8"
@@ -2236,7 +2369,7 @@ var DefaultAttachmentStore = class {
2236
2369
  for (const att of this.items.values()) {
2237
2370
  if (att.path) toDelete.push(att.path);
2238
2371
  }
2239
- 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)));
2240
2373
  }
2241
2374
  this.items.clear();
2242
2375
  this.refs.length = 0;
@@ -2244,7 +2377,7 @@ var DefaultAttachmentStore = class {
2244
2377
  }
2245
2378
  async toBlock(att) {
2246
2379
  if (att.kind === "image") {
2247
- 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" }) : "");
2248
2381
  return {
2249
2382
  type: "image",
2250
2383
  source: {
@@ -2254,7 +2387,7 @@ var DefaultAttachmentStore = class {
2254
2387
  }
2255
2388
  };
2256
2389
  }
2257
- 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") : "");
2258
2391
  const label = att.meta.filename ? `<file path="${att.meta.filename}">` : "<pasted>";
2259
2392
  const close = att.meta.filename ? "</file>" : "</pasted>";
2260
2393
  return { type: "text", text: `${label}
@@ -2389,7 +2522,7 @@ var FileMemoryBackend = class {
2389
2522
  await ensureDir(path2.dirname(file));
2390
2523
  let existing = "";
2391
2524
  try {
2392
- existing = await fsp.readFile(file, "utf8");
2525
+ existing = await fsp2.readFile(file, "utf8");
2393
2526
  } catch {
2394
2527
  }
2395
2528
  const id = `mem_${Date.now()}_${randomUUID().slice(0, 8)}`;
@@ -2406,7 +2539,7 @@ ${line}`;
2406
2539
  return withFileLock(file, async () => {
2407
2540
  let existing;
2408
2541
  try {
2409
- existing = await fsp.readFile(file, "utf8");
2542
+ existing = await fsp2.readFile(file, "utf8");
2410
2543
  } catch {
2411
2544
  return 0;
2412
2545
  }
@@ -2442,7 +2575,7 @@ ${line}`;
2442
2575
  async readAll(scope, filePath) {
2443
2576
  const file = this.resolveFile(filePath, scope);
2444
2577
  try {
2445
- return await fsp.readFile(file, "utf8");
2578
+ return await fsp2.readFile(file, "utf8");
2446
2579
  } catch {
2447
2580
  return "";
2448
2581
  }
@@ -2477,7 +2610,7 @@ ${line}`;
2477
2610
  const file = this.resolveFile(filePath, scope);
2478
2611
  let existing;
2479
2612
  try {
2480
- existing = await fsp.readFile(file, "utf8");
2613
+ existing = await fsp2.readFile(file, "utf8");
2481
2614
  } catch {
2482
2615
  return 0;
2483
2616
  }
@@ -2497,7 +2630,7 @@ ${line}`;
2497
2630
  const next = lines.join("\n");
2498
2631
  const backup = `${file}.bak.${Date.now()}`;
2499
2632
  try {
2500
- await fsp.copyFile(file, backup);
2633
+ await fsp2.copyFile(file, backup);
2501
2634
  await pruneConsolidateBackups(file);
2502
2635
  } catch {
2503
2636
  }
@@ -2513,11 +2646,11 @@ async function pruneConsolidateBackups(file) {
2513
2646
  const dir = path2.dirname(file);
2514
2647
  const base = path2.basename(file);
2515
2648
  const prefix = `${base}.bak.`;
2516
- 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();
2517
2650
  await Promise.all(
2518
2651
  backups.slice(MAX_MEMORY_CONSOLIDATE_BACKUPS).map(async (name) => {
2519
2652
  try {
2520
- await fsp.unlink(path2.join(dir, name));
2653
+ await fsp2.unlink(path2.join(dir, name));
2521
2654
  } catch {
2522
2655
  }
2523
2656
  })
@@ -3072,9 +3205,9 @@ ${body.trim()}`);
3072
3205
  if (!this.persistBackup || scope === "project-agents") return;
3073
3206
  try {
3074
3207
  const content = await this.backend.readAll(scope, this.files[scope]);
3075
- const { writeFile: writeFile7, mkdir: mkdir7 } = await import('fs/promises');
3208
+ const { writeFile: writeFile8, mkdir: mkdir7 } = await import('fs/promises');
3076
3209
  await mkdir7(this.backupDir, { recursive: true });
3077
- await writeFile7(`${this.backupDir}/${scope}.md`, content, "utf8");
3210
+ await writeFile8(`${this.backupDir}/${scope}.md`, content, "utf8");
3078
3211
  } catch {
3079
3212
  }
3080
3213
  }
@@ -3089,7 +3222,7 @@ function labelOf(scope) {
3089
3222
  return "User memory";
3090
3223
  }
3091
3224
  }
3092
- var GraphMemoryBackend = class {
3225
+ var GraphMemoryBackend = class _GraphMemoryBackend {
3093
3226
  kind = "graph";
3094
3227
  file;
3095
3228
  graphFile;
@@ -3098,6 +3231,14 @@ var GraphMemoryBackend = class {
3098
3231
  edges = [];
3099
3232
  loadedScope = null;
3100
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;
3101
3242
  /**
3102
3243
  * Promise that resolves when the current in-flight _saveGraph completes.
3103
3244
  * Tests call flush() to await this before deleting the backend or its temp dir.
@@ -3120,6 +3261,7 @@ var GraphMemoryBackend = class {
3120
3261
  existing.type = entry.type;
3121
3262
  existing.tags = entry.tags;
3122
3263
  existing.priority = entry.priority;
3264
+ this.updateNodeIndex(nodeId, entry);
3123
3265
  } else {
3124
3266
  this.nodes.set(nodeId, {
3125
3267
  id: nodeId,
@@ -3130,6 +3272,7 @@ var GraphMemoryBackend = class {
3130
3272
  tags: entry.tags,
3131
3273
  priority: entry.priority
3132
3274
  });
3275
+ this.indexNode(nodeId, entry);
3133
3276
  const recentNodes = [...this.nodes.values()].slice(-100);
3134
3277
  for (const other of recentNodes) {
3135
3278
  if (other.id === nodeId) continue;
@@ -3161,7 +3304,10 @@ var GraphMemoryBackend = class {
3161
3304
  toRemove.push(id);
3162
3305
  }
3163
3306
  }
3164
- 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
+ }
3165
3311
  this.edges = this.edges.filter((e) => !toRemove.includes(e.from) && !toRemove.includes(e.to));
3166
3312
  this._saveDone = this._saveGraph(scope);
3167
3313
  await this._saveDone;
@@ -3199,20 +3345,36 @@ var GraphMemoryBackend = class {
3199
3345
  }
3200
3346
  async search(scope, query, _filePath, limit) {
3201
3347
  await this.loadGraph(scope);
3202
- const needle = query.toLowerCase().split(/\s+/);
3203
- const scored = [];
3204
- 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) {
3205
3360
  if (node.entry.scope !== scope) continue;
3206
- 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);
3207
3369
  let score = 0;
3208
- for (const n of needle) {
3209
- if (words.some((w) => w.includes(n))) score += 1;
3210
- 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;
3211
3373
  }
3212
3374
  if (node.priority === "critical") score += 3;
3213
3375
  else if (node.priority === "high") score += 2;
3214
3376
  score += node.count * 0.5;
3215
- if (score > 0) scored.push({ entry: node.entry, score });
3377
+ scored.push({ entry: node.entry, score });
3216
3378
  }
3217
3379
  scored.sort((a, b) => b.score - a.score);
3218
3380
  const matched = scored.map((s) => s.entry);
@@ -3222,10 +3384,11 @@ var GraphMemoryBackend = class {
3222
3384
  await this.file.clear(scope, filePath);
3223
3385
  this.nodes.clear();
3224
3386
  this.edges = [];
3387
+ this.invertedIndex = /* @__PURE__ */ new Map();
3225
3388
  this.loadedScope = scope;
3226
3389
  this.loaded = true;
3227
3390
  try {
3228
- await fsp.unlink(this.graphFile);
3391
+ await fsp2.unlink(this.graphFile);
3229
3392
  } catch {
3230
3393
  }
3231
3394
  }
@@ -3265,7 +3428,7 @@ var GraphMemoryBackend = class {
3265
3428
  async loadGraph(scope) {
3266
3429
  if (this.loaded && this.loadedScope === scope) return;
3267
3430
  try {
3268
- const raw = await fsp.readFile(this.graphFile, "utf8");
3431
+ const raw = await fsp2.readFile(this.graphFile, "utf8");
3269
3432
  const data = JSON.parse(raw);
3270
3433
  this.nodes = new Map(data.nodes);
3271
3434
  this.edges = data.edges;
@@ -3273,6 +3436,7 @@ var GraphMemoryBackend = class {
3273
3436
  this.nodes = /* @__PURE__ */ new Map();
3274
3437
  this.edges = [];
3275
3438
  }
3439
+ this.buildInvertedIndex();
3276
3440
  this.loadedScope = scope;
3277
3441
  this.loaded = true;
3278
3442
  }
@@ -3286,10 +3450,10 @@ var GraphMemoryBackend = class {
3286
3450
  edges: this.edges
3287
3451
  };
3288
3452
  const dir = this.graphFile.substring(0, this.graphFile.lastIndexOf("/"));
3289
- await fsp.mkdir(dir, { recursive: true });
3453
+ await fsp2.mkdir(dir, { recursive: true });
3290
3454
  const tmp = `${this.graphFile}.tmp`;
3291
- await fsp.writeFile(tmp, JSON.stringify(data));
3292
- await fsp.rename(tmp, this.graphFile);
3455
+ await fsp2.writeFile(tmp, JSON.stringify(data));
3456
+ await fsp2.rename(tmp, this.graphFile);
3293
3457
  } catch {
3294
3458
  }
3295
3459
  }
@@ -3300,6 +3464,86 @@ var GraphMemoryBackend = class {
3300
3464
  async flush() {
3301
3465
  await this._saveDone;
3302
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
+ }
3303
3547
  };
3304
3548
  function wordOverlap(a, b) {
3305
3549
  const wordsA = new Set(a.toLowerCase().split(/\s+/).filter((w) => w.length > 2));
@@ -3853,7 +4097,12 @@ var BEHAVIOR_DEFAULTS = {
3853
4097
  },
3854
4098
  circuitBreaker: { ...DEFAULT_CIRCUIT_BREAKER_CONFIG },
3855
4099
  modelRuntime: {
3856
- reasoning: { mode: "auto", effort: "high", preserve: false },
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" },
3857
4106
  cache: {}
3858
4107
  }
3859
4108
  };
@@ -4109,6 +4358,7 @@ var DefaultConfigLoader = class {
4109
4358
  extraSources;
4110
4359
  events;
4111
4360
  traceId;
4361
+ jsonCache = /* @__PURE__ */ new Map();
4112
4362
  constructor(opts) {
4113
4363
  this.paths = opts.paths;
4114
4364
  this.strict = opts.strict ?? false;
@@ -4191,7 +4441,7 @@ var DefaultConfigLoader = class {
4191
4441
  await withFileLock(fp, async () => {
4192
4442
  let parsed;
4193
4443
  try {
4194
- const raw = await fsp.readFile(fp, "utf8");
4444
+ const raw = await fsp2.readFile(fp, "utf8");
4195
4445
  const result = safeParse(raw);
4196
4446
  if (!result.ok || !isPlainRecord(result.value)) {
4197
4447
  return;
@@ -4306,7 +4556,7 @@ var DefaultConfigLoader = class {
4306
4556
  const fp = this.paths.syncConfig;
4307
4557
  const t0 = Date.now();
4308
4558
  try {
4309
- const raw = await fsp.readFile(fp, "utf8");
4559
+ const raw = await fsp2.readFile(fp, "utf8");
4310
4560
  const parsed = safeParse(raw);
4311
4561
  if (!parsed.ok || !parsed.value) {
4312
4562
  this.events?.emit("storage.read", {
@@ -4367,10 +4617,42 @@ var DefaultConfigLoader = class {
4367
4617
  }
4368
4618
  }
4369
4619
  async readJson(file) {
4370
- let raw;
4371
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;
4372
4654
  try {
4373
- raw = await fsp.readFile(file, "utf8");
4655
+ raw = await fsp2.readFile(file, "utf8");
4374
4656
  } catch (err) {
4375
4657
  if (err.code !== "ENOENT") {
4376
4658
  this.events?.emit("storage.read", {
@@ -4391,6 +4673,7 @@ var DefaultConfigLoader = class {
4391
4673
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
4392
4674
  }));
4393
4675
  }
4676
+ this.jsonCache.set(file, { mtimeMs: null, value: {} });
4394
4677
  return {};
4395
4678
  }
4396
4679
  const parsed = safeParse(raw);
@@ -4414,6 +4697,7 @@ var DefaultConfigLoader = class {
4414
4697
  }));
4415
4698
  return {};
4416
4699
  }
4700
+ this.jsonCache.set(file, { mtimeMs, value: structuredClone(parsed.value) });
4417
4701
  return parsed.value;
4418
4702
  }
4419
4703
  validateBehavior(cfg) {
@@ -4608,7 +4892,7 @@ var RecoveryLock = class {
4608
4892
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
4609
4893
  };
4610
4894
  try {
4611
- 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 });
4612
4896
  } catch (err) {
4613
4897
  const code = err.code;
4614
4898
  if (code === "EEXIST") {
@@ -4624,7 +4908,7 @@ var RecoveryLock = class {
4624
4908
  */
4625
4909
  async clear() {
4626
4910
  try {
4627
- await fsp.unlink(this.file);
4911
+ await fsp2.unlink(this.file);
4628
4912
  } catch (err) {
4629
4913
  const code = err.code;
4630
4914
  if (code === "ENOENT") return;
@@ -4634,7 +4918,7 @@ var RecoveryLock = class {
4634
4918
  async readLock() {
4635
4919
  let raw;
4636
4920
  try {
4637
- raw = await fsp.readFile(this.file, "utf8");
4921
+ raw = await fsp2.readFile(this.file, "utf8");
4638
4922
  } catch (err) {
4639
4923
  const code = err.code;
4640
4924
  if (code === "ENOENT") return null;
@@ -4665,26 +4949,82 @@ function defaultIsPidAlive(pid) {
4665
4949
  return false;
4666
4950
  }
4667
4951
  }
4668
-
4669
- // src/storage/session-reader.ts
4670
- var DefaultSessionReader = class {
4952
+ var DefaultSessionReader = class _DefaultSessionReader {
4671
4953
  store;
4954
+ eventCache = /* @__PURE__ */ new Map();
4955
+ eventCacheMtimes = /* @__PURE__ */ new Map();
4956
+ static EVENT_CACHE_MAX_ENTRIES = 32;
4672
4957
  constructor(opts) {
4673
4958
  this.store = opts.store;
4674
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
+ }
4675
5001
  async query(q = {}) {
4676
- const raw = await this.store.list(q.limit ? Math.max(q.limit, 100) : 1e3);
4677
- const titleNeedle = q.titleContains?.toLowerCase();
4678
- const filtered = raw.filter((s) => {
4679
- if (q.since && s.startedAt < q.since) return false;
4680
- if (q.until && s.startedAt > q.until) return false;
4681
- if (q.provider && s.provider !== q.provider) return false;
4682
- if (q.model && s.model !== q.model) return false;
4683
- if (q.minTokens !== void 0 && s.tokenTotal < q.minTokens) return false;
4684
- if (titleNeedle && !s.title.toLowerCase().includes(titleNeedle)) return false;
4685
- return true;
4686
- });
4687
- 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) => ({
4688
5028
  id: s.id,
4689
5029
  title: s.title,
4690
5030
  startedAt: s.startedAt,
@@ -4695,7 +5035,7 @@ var DefaultSessionReader = class {
4695
5035
  return q.limit ? out.slice(0, q.limit) : out;
4696
5036
  }
4697
5037
  async *replay(sessionId) {
4698
- const data = await this.store.load(sessionId);
5038
+ const data = await this.loadCachedSessionData(sessionId);
4699
5039
  for (const e of data.events) yield e;
4700
5040
  }
4701
5041
  async search(q, sessionId, sessionQuery) {
@@ -4706,18 +5046,32 @@ var DefaultSessionReader = class {
4706
5046
  if (sessionId) {
4707
5047
  ids = [sessionId];
4708
5048
  } else {
4709
- const sessions = await this.store.list(1e3);
4710
- const titleNeedle = sessionQuery?.titleContains?.toLowerCase();
4711
- const filtered = sessions.filter((s) => {
4712
- if (sessionQuery?.since && s.startedAt < sessionQuery.since) return false;
4713
- if (sessionQuery?.until && s.startedAt > sessionQuery.until) return false;
4714
- if (sessionQuery?.provider && s.provider !== sessionQuery.provider) return false;
4715
- if (sessionQuery?.model && s.model !== sessionQuery.model) return false;
4716
- if (sessionQuery?.minTokens !== void 0 && s.tokenTotal < sessionQuery.minTokens) return false;
4717
- if (titleNeedle && !s.title.toLowerCase().includes(titleNeedle)) return false;
4718
- return true;
4719
- });
4720
- 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);
4721
5075
  }
4722
5076
  const hits = [];
4723
5077
  const streaming = this.store.searchEvents?.bind(this.store);
@@ -4751,7 +5105,7 @@ var DefaultSessionReader = class {
4751
5105
  for (const id of ids) {
4752
5106
  let data;
4753
5107
  try {
4754
- data = await this.store.load(id);
5108
+ data = await this.loadCachedSessionData(id);
4755
5109
  } catch {
4756
5110
  continue;
4757
5111
  }
@@ -4775,7 +5129,7 @@ var DefaultSessionReader = class {
4775
5129
  return hits;
4776
5130
  }
4777
5131
  async export(sessionId, opts) {
4778
- const data = await this.store.load(sessionId);
5132
+ const data = await this.loadCachedSessionData(sessionId);
4779
5133
  const includeTools = opts.includeTools ?? true;
4780
5134
  const includeDiagnostics = opts.includeDiagnostics ?? true;
4781
5135
  const filtered = data.events.filter((e) => {
@@ -4796,7 +5150,7 @@ var DefaultSessionReader = class {
4796
5150
  return renderMarkdown(data.metadata, filtered);
4797
5151
  }
4798
5152
  async metadata(sessionId) {
4799
- const data = await this.store.load(sessionId);
5153
+ const data = await this.loadCachedSessionData(sessionId);
4800
5154
  return data.metadata;
4801
5155
  }
4802
5156
  };
@@ -5214,7 +5568,7 @@ var AnnotationsStore = class {
5214
5568
  const fp = this.filePath(sessionId);
5215
5569
  let raw;
5216
5570
  try {
5217
- raw = await fsp.readFile(fp, "utf8");
5571
+ raw = await fsp2.readFile(fp, "utf8");
5218
5572
  } catch (err) {
5219
5573
  if (err.code === "ENOENT") return null;
5220
5574
  throw err;
@@ -5295,7 +5649,7 @@ var ReplayLogStore = class {
5295
5649
  events;
5296
5650
  traceId;
5297
5651
  writeChains = /* @__PURE__ */ new Map();
5298
- /** Per-session hash → entry index, kept in memory after the first load. */
5652
+ /** Per-session hash → on-disk location, with lazy entry hydration. */
5299
5653
  cache = /* @__PURE__ */ new Map();
5300
5654
  /** Per-session entry count on disk, to detect when compaction is needed. */
5301
5655
  diskCount = /* @__PURE__ */ new Map();
@@ -5330,8 +5684,16 @@ var ReplayLogStore = class {
5330
5684
  const currentCount = this.diskCount.get(input.sessionId) ?? 0;
5331
5685
  const willEvict = currentCount + 1 > this.maxEntries;
5332
5686
  if (!willEvict) {
5333
- await fsp.appendFile(fp, JSON.stringify(entry) + "\n", "utf8");
5334
- 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") });
5335
5697
  this.diskCount.set(input.sessionId, currentCount + 1);
5336
5698
  this.events?.emit("storage.write", {
5337
5699
  sessionId: input.sessionId,
@@ -5348,7 +5710,12 @@ var ReplayLogStore = class {
5348
5710
  all.push(entry);
5349
5711
  const keep = all.slice(-this.maxEntries);
5350
5712
  const refreshed = /* @__PURE__ */ new Map();
5351
- 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
+ }
5352
5719
  this.cache.set(input.sessionId, refreshed);
5353
5720
  this.diskCount.set(input.sessionId, keep.length);
5354
5721
  await this.writeAll(input.sessionId, keep, "compact");
@@ -5381,6 +5748,8 @@ var ReplayLogStore = class {
5381
5748
  const t0 = Date.now();
5382
5749
  try {
5383
5750
  const cache = await this.ensureCache(sessionId);
5751
+ const location = cache.get(hash);
5752
+ const entry = location ? await this.hydrateEntry(sessionId, hash, location) : null;
5384
5753
  this.events?.emit("storage.read", {
5385
5754
  sessionId,
5386
5755
  store: "replay",
@@ -5390,7 +5759,7 @@ var ReplayLogStore = class {
5390
5759
  durationMs: Date.now() - t0,
5391
5760
  ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
5392
5761
  });
5393
- return cache.get(hash) ?? null;
5762
+ return entry;
5394
5763
  } catch (err) {
5395
5764
  this.events?.emit("storage.read", {
5396
5765
  sessionId,
@@ -5411,6 +5780,10 @@ var ReplayLogStore = class {
5411
5780
  const t0 = Date.now();
5412
5781
  try {
5413
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
+ }
5414
5787
  const durationMs = Date.now() - t0;
5415
5788
  this.events?.emit("storage.read", {
5416
5789
  sessionId,
@@ -5421,7 +5794,7 @@ var ReplayLogStore = class {
5421
5794
  durationMs,
5422
5795
  ...this.traceId !== void 0 ? { traceId: this.traceId } : {}
5423
5796
  });
5424
- return [...cache.values()];
5797
+ return entries;
5425
5798
  } catch (err) {
5426
5799
  const durationMs = Date.now() - t0;
5427
5800
  this.events?.emit("storage.read", {
@@ -5447,7 +5820,7 @@ var ReplayLogStore = class {
5447
5820
  const scan = async (dir, prefix, depth) => {
5448
5821
  let entries;
5449
5822
  try {
5450
- entries = await fsp.readdir(dir, { withFileTypes: true });
5823
+ entries = await fsp2.readdir(dir, { withFileTypes: true });
5451
5824
  } catch (err) {
5452
5825
  if (depth === 0 && err.code !== "ENOENT") {
5453
5826
  console.warn(JSON.stringify({
@@ -5485,7 +5858,7 @@ var ReplayLogStore = class {
5485
5858
  return sessionScopedPath(this.dir, sessionId, ".replay.jsonl");
5486
5859
  }
5487
5860
  async countEntries(filePath) {
5488
- const handle = await fsp.open(filePath, "r");
5861
+ const handle = await fsp2.open(filePath, "r");
5489
5862
  const buffer = Buffer.allocUnsafe(64 * 1024);
5490
5863
  let count = 0;
5491
5864
  let hasNonWhitespace = false;
@@ -5511,23 +5884,27 @@ var ReplayLogStore = class {
5511
5884
  if (hasNonWhitespace) count++;
5512
5885
  return count;
5513
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
+ }
5514
5899
  async readAll(sessionId) {
5515
5900
  const fp = this.filePath(sessionId);
5516
5901
  try {
5517
- const raw = await fsp.readFile(fp, "utf8");
5902
+ const raw = await fsp2.readFile(fp, "utf8");
5518
5903
  const out = [];
5519
5904
  for (const line of raw.split("\n")) {
5520
5905
  if (!line.trim()) continue;
5521
- try {
5522
- const parsed = safeParse(line);
5523
- if (!parsed.ok || !parsed.value) continue;
5524
- if ("entry" in parsed.value && parsed.value.entry) {
5525
- out.push(parsed.value.entry);
5526
- } else {
5527
- out.push(parsed.value);
5528
- }
5529
- } catch {
5530
- }
5906
+ const parsed = this.parseReplayLine(line);
5907
+ if (parsed) out.push(parsed);
5531
5908
  }
5532
5909
  return out;
5533
5910
  } catch (err) {
@@ -5554,13 +5931,75 @@ var ReplayLogStore = class {
5554
5931
  async ensureCache(sessionId) {
5555
5932
  let cache = this.cache.get(sessionId);
5556
5933
  if (cache) return cache;
5557
- const all = await this.readAll(sessionId);
5934
+ const fp = this.filePath(sessionId);
5558
5935
  cache = /* @__PURE__ */ new Map();
5559
- 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
+ }
5560
5981
  this.cache.set(sessionId, cache);
5561
- this.diskCount.set(sessionId, all.length);
5982
+ this.diskCount.set(sessionId, cache.size);
5562
5983
  return cache;
5563
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
+ }
5564
6003
  enqueue(sessionId, fn) {
5565
6004
  const prev = this.writeChains.get(sessionId) ?? Promise.resolve();
5566
6005
  const next = prev.then(fn, fn);
@@ -5589,19 +6028,19 @@ var SessionRecovery = class {
5589
6028
  async detectStale(sessionId) {
5590
6029
  const fp = this.filePath(sessionId);
5591
6030
  const TAIL_SIZE = 8192;
5592
- let stat7;
6031
+ let stat10;
5593
6032
  try {
5594
- stat7 = await fsp.stat(fp);
6033
+ stat10 = await fsp2.stat(fp);
5595
6034
  } catch (err) {
5596
6035
  if (err.code === "ENOENT") return null;
5597
6036
  return null;
5598
6037
  }
5599
- if (stat7.size === 0) return null;
5600
- 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);
5601
6040
  const buf = Buffer.alloc(TAIL_SIZE);
5602
6041
  let fh;
5603
6042
  try {
5604
- fh = await fsp.open(fp, "r");
6043
+ fh = await fsp2.open(fp, "r");
5605
6044
  const { bytesRead } = await fh.read(buf, 0, TAIL_SIZE, position);
5606
6045
  let eventCount = 0;
5607
6046
  const raw = buf.subarray(0, bytesRead).toString("utf8");
@@ -5646,7 +6085,7 @@ var SessionRecovery = class {
5646
6085
  const fp = this.filePath(sessionId);
5647
6086
  let raw;
5648
6087
  try {
5649
- raw = await fsp.readFile(fp, "utf8");
6088
+ raw = await fsp2.readFile(fp, "utf8");
5650
6089
  } catch (err) {
5651
6090
  if (err.code === "ENOENT") return null;
5652
6091
  return null;
@@ -5691,7 +6130,7 @@ var SessionRecovery = class {
5691
6130
  const collect = async (dir, prefix, depth) => {
5692
6131
  let entries;
5693
6132
  try {
5694
- entries = await fsp.readdir(dir, { withFileTypes: true });
6133
+ entries = await fsp2.readdir(dir, { withFileTypes: true });
5695
6134
  } catch {
5696
6135
  return;
5697
6136
  }
@@ -5792,9 +6231,9 @@ var ToolAuditLog = class {
5792
6231
  isError: input.isError,
5793
6232
  index
5794
6233
  };
5795
- await fsp.appendFile(fp, JSON.stringify(entry) + "\n", "utf8");
6234
+ await fsp2.appendFile(fp, JSON.stringify(entry) + "\n", "utf8");
5796
6235
  try {
5797
- const st = await fsp.stat(fp);
6236
+ const st = await fsp2.stat(fp);
5798
6237
  this.tailStat.set(input.sessionId, { mtimeMs: st.mtimeMs, size: st.size });
5799
6238
  } catch {
5800
6239
  }
@@ -5841,7 +6280,7 @@ var ToolAuditLog = class {
5841
6280
  const cachedStat = this.tailStat.get(sessionId);
5842
6281
  if (cachedHash !== void 0 && cachedIndex !== void 0 && cachedStat) {
5843
6282
  try {
5844
- const st = await fsp.stat(fp);
6283
+ const st = await fsp2.stat(fp);
5845
6284
  if (st.mtimeMs === cachedStat.mtimeMs && st.size === cachedStat.size) {
5846
6285
  return { prevHash: cachedHash, nextIndex: cachedIndex };
5847
6286
  }
@@ -5862,7 +6301,7 @@ var ToolAuditLog = class {
5862
6301
  this.tailHash.set(sessionId, prevHash);
5863
6302
  this.tailIndex.set(sessionId, nextIndex);
5864
6303
  try {
5865
- const st = await fsp.stat(fp);
6304
+ const st = await fsp2.stat(fp);
5866
6305
  this.tailStat.set(sessionId, { mtimeMs: st.mtimeMs, size: st.size });
5867
6306
  } catch {
5868
6307
  }
@@ -5983,7 +6422,7 @@ var ToolAuditLog = class {
5983
6422
  async readAll(sessionId) {
5984
6423
  const fp = this.filePath(sessionId);
5985
6424
  try {
5986
- const raw = await fsp.readFile(fp, "utf8");
6425
+ const raw = await fsp2.readFile(fp, "utf8");
5987
6426
  const out = [];
5988
6427
  for (const line of raw.split("\n")) {
5989
6428
  if (!line.trim()) continue;
@@ -6021,7 +6460,7 @@ var ToolAuditLog = class {
6021
6460
  }
6022
6461
  async sync(sessionId, fp) {
6023
6462
  try {
6024
- const fh = await fsp.open(fp, "r+");
6463
+ const fh = await fsp2.open(fp, "r+");
6025
6464
  try {
6026
6465
  await fh.sync();
6027
6466
  } finally {
@@ -6339,7 +6778,7 @@ var SessionRegistry = class {
6339
6778
  }
6340
6779
  async readAndPrune() {
6341
6780
  try {
6342
- const raw = await fsp.readFile(this.filePath, "utf8");
6781
+ const raw = await fsp2.readFile(this.filePath, "utf8");
6343
6782
  const registry = JSON.parse(raw);
6344
6783
  const now = Date.now();
6345
6784
  let pruned = false;
@@ -6373,11 +6812,11 @@ var SessionRegistry = class {
6373
6812
  const retryDelayMs = 20;
6374
6813
  for (let attempt = 0; attempt < maxRetries; attempt++) {
6375
6814
  try {
6376
- await fsp.mkdir(path2.dirname(this.filePath), { recursive: true });
6377
- 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);
6378
6817
  if (!lockHandle) {
6379
6818
  if (await this.breakStaleLock(lockPath)) {
6380
- lockHandle = await fsp.open(lockPath, "wx").catch(() => null);
6819
+ lockHandle = await fsp2.open(lockPath, "wx").catch(() => null);
6381
6820
  }
6382
6821
  if (!lockHandle) {
6383
6822
  await new Promise((r) => setTimeout(r, retryDelayMs * (attempt + 1)));
@@ -6386,14 +6825,14 @@ var SessionRegistry = class {
6386
6825
  }
6387
6826
  try {
6388
6827
  await lockHandle.writeFile(String(process.pid)).catch(() => void 0);
6389
- const raw = await fsp.readFile(this.filePath, "utf8").catch(() => "{}");
6828
+ const raw = await fsp2.readFile(this.filePath, "utf8").catch(() => "{}");
6390
6829
  const registry = JSON.parse(raw);
6391
6830
  fn(registry);
6392
6831
  await this.writeAtomicLocked(registry);
6393
6832
  return;
6394
6833
  } finally {
6395
6834
  await lockHandle.close();
6396
- await fsp.unlink(lockPath).catch(() => void 0);
6835
+ await fsp2.unlink(lockPath).catch(() => void 0);
6397
6836
  }
6398
6837
  } catch {
6399
6838
  return;
@@ -6409,15 +6848,15 @@ var SessionRegistry = class {
6409
6848
  */
6410
6849
  async breakStaleLock(lockPath) {
6411
6850
  try {
6412
- const [stat7, content] = await Promise.all([
6413
- fsp.stat(lockPath),
6414
- fsp.readFile(lockPath, "utf8").catch(() => "")
6851
+ const [stat10, content] = await Promise.all([
6852
+ fsp2.stat(lockPath),
6853
+ fsp2.readFile(lockPath, "utf8").catch(() => "")
6415
6854
  ]);
6416
- const ageMs = Date.now() - stat7.mtimeMs;
6855
+ const ageMs = Date.now() - stat10.mtimeMs;
6417
6856
  const ownerPid = Number.parseInt(content.trim(), 10);
6418
6857
  const ownerDead = Number.isInteger(ownerPid) && ownerPid > 0 && ownerPid !== process.pid && !pidAlive(ownerPid);
6419
6858
  if (ownerDead || ageMs > STALE_LOCK_MS) {
6420
- await fsp.unlink(lockPath).catch(() => void 0);
6859
+ await fsp2.unlink(lockPath).catch(() => void 0);
6421
6860
  return true;
6422
6861
  }
6423
6862
  return false;
@@ -6440,10 +6879,10 @@ var SessionRegistry = class {
6440
6879
  `.${path2.basename(this.filePath)}.${randomUUID().slice(0, 8)}.tmp`
6441
6880
  );
6442
6881
  try {
6443
- await fsp.writeFile(tmp, JSON.stringify(registry, null, 2), "utf8");
6444
- await fsp.rename(tmp, this.filePath);
6882
+ await fsp2.writeFile(tmp, JSON.stringify(registry, null, 2), "utf8");
6883
+ await fsp2.rename(tmp, this.filePath);
6445
6884
  } catch (err) {
6446
- await fsp.unlink(tmp).catch(() => void 0);
6885
+ await fsp2.unlink(tmp).catch(() => void 0);
6447
6886
  throw err;
6448
6887
  }
6449
6888
  }
@@ -6453,17 +6892,17 @@ var SessionRegistry = class {
6453
6892
  const base = path2.basename(this.filePath);
6454
6893
  const now = Date.now();
6455
6894
  const stale = [];
6456
- for (const name of await fsp.readdir(dir)) {
6895
+ for (const name of await fsp2.readdir(dir)) {
6457
6896
  const isTemp = (name.startsWith(`${base}.`) || name.startsWith(`.${base}.`)) && name.endsWith(".tmp");
6458
6897
  if (!isTemp) continue;
6459
- const stat7 = await fsp.stat(path2.join(dir, name)).catch(() => null);
6460
- if (!stat7) continue;
6461
- 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 });
6462
6901
  }
6463
6902
  stale.sort((a, b) => b.mtimeMs - a.mtimeMs);
6464
6903
  await Promise.all(
6465
6904
  stale.slice(MAX_STALE_TMP_FILES).map(async ({ name }) => {
6466
- await fsp.unlink(path2.join(dir, name)).catch(() => void 0);
6905
+ await fsp2.unlink(path2.join(dir, name)).catch(() => void 0);
6467
6906
  })
6468
6907
  );
6469
6908
  } catch {
@@ -6912,7 +7351,7 @@ var FleetNotifier = class {
6912
7351
  }
6913
7352
  async discover() {
6914
7353
  try {
6915
- 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");
6916
7355
  const data = JSON.parse(raw);
6917
7356
  const list = Array.isArray(data?.instances) ? data.instances : [];
6918
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) => {
@@ -6939,7 +7378,7 @@ var DefaultSessionRewinder = class {
6939
7378
  projectRoot;
6940
7379
  async listCheckpoints(sessionId) {
6941
7380
  const file = path2.join(this.sessionsDir, `${sessionId}.jsonl`);
6942
- const raw = await fsp.readFile(file, "utf8");
7381
+ const raw = await fsp2.readFile(file, "utf8");
6943
7382
  const events = parseEvents(raw);
6944
7383
  const fileCountMap = /* @__PURE__ */ new Map();
6945
7384
  for (const event of events) {
@@ -6964,7 +7403,7 @@ var DefaultSessionRewinder = class {
6964
7403
  }
6965
7404
  async rewindToCheckpoint(sessionId, checkpointIndex) {
6966
7405
  const file = path2.join(this.sessionsDir, `${sessionId}.jsonl`);
6967
- const raw = await fsp.readFile(file, "utf8");
7406
+ const raw = await fsp2.readFile(file, "utf8");
6968
7407
  const events = parseEvents(raw);
6969
7408
  let targetIdx = -1;
6970
7409
  for (let i = 0; i < events.length; i++) {
@@ -7003,7 +7442,7 @@ var DefaultSessionRewinder = class {
7003
7442
  }
7004
7443
  async rewindLastN(sessionId, n) {
7005
7444
  const file = path2.join(this.sessionsDir, `${sessionId}.jsonl`);
7006
- const raw = await fsp.readFile(file, "utf8");
7445
+ const raw = await fsp2.readFile(file, "utf8");
7007
7446
  const events = parseEvents(raw);
7008
7447
  const checkpoints = [];
7009
7448
  for (const event of events) {
@@ -7032,7 +7471,7 @@ var DefaultSessionRewinder = class {
7032
7471
  }
7033
7472
  async rewindToStart(sessionId) {
7034
7473
  const file = path2.join(this.sessionsDir, `${sessionId}.jsonl`);
7035
- const raw = await fsp.readFile(file, "utf8");
7474
+ const raw = await fsp2.readFile(file, "utf8");
7036
7475
  const events = parseEvents(raw);
7037
7476
  const allSnapshots = [];
7038
7477
  for (const event of events) {
@@ -7080,7 +7519,7 @@ async function revertSnapshots(snapshots, projectRoot) {
7080
7519
  revertedFiles.push(file.path);
7081
7520
  }
7082
7521
  } else if (file.action === "created") {
7083
- await fsp.unlink(file.path);
7522
+ await fsp2.unlink(file.path);
7084
7523
  revertedFiles.push(file.path);
7085
7524
  } else if (file.action === "modified") {
7086
7525
  if (file.before !== null) {
@@ -7099,7 +7538,7 @@ async function loadTodosCheckpoint(filePath, events, traceId) {
7099
7538
  const t0 = Date.now();
7100
7539
  let raw;
7101
7540
  try {
7102
- raw = await fsp.readFile(filePath, "utf8");
7541
+ raw = await fsp2.readFile(filePath, "utf8");
7103
7542
  } catch (err) {
7104
7543
  events?.emit("storage.error", {
7105
7544
  sessionId: traceId ?? "~boot~",
@@ -7238,7 +7677,7 @@ async function loadPlan(filePath, events) {
7238
7677
  const t0 = Date.now();
7239
7678
  let raw;
7240
7679
  try {
7241
- raw = await fsp.readFile(filePath, "utf8");
7680
+ raw = await fsp2.readFile(filePath, "utf8");
7242
7681
  } catch (err) {
7243
7682
  events?.emit("storage.error", {
7244
7683
  sessionId: "~boot~",
@@ -7549,7 +7988,7 @@ async function loadTasks(filePath, events, traceId) {
7549
7988
  const t0 = Date.now();
7550
7989
  let raw;
7551
7990
  try {
7552
- raw = await fsp.readFile(filePath, "utf8");
7991
+ raw = await fsp2.readFile(filePath, "utf8");
7553
7992
  } catch (err) {
7554
7993
  events?.emit("storage.error", {
7555
7994
  sessionId: traceId ?? "~boot~",
@@ -7648,7 +8087,7 @@ async function mutateTasks(filePath, sessionId, fn, events, traceId) {
7648
8087
  async function loadDirectorState(filePath) {
7649
8088
  let raw;
7650
8089
  try {
7651
- raw = await fsp.readFile(filePath, "utf8");
8090
+ raw = await fsp2.readFile(filePath, "utf8");
7652
8091
  } catch {
7653
8092
  return null;
7654
8093
  }
@@ -7663,7 +8102,7 @@ async function loadDirectorState(filePath) {
7663
8102
  async function acquireDirectorStateLock(lockPath, processId = process.pid) {
7664
8103
  let existing;
7665
8104
  try {
7666
- existing = await fsp.readFile(lockPath, "utf8");
8105
+ existing = await fsp2.readFile(lockPath, "utf8");
7667
8106
  } catch {
7668
8107
  }
7669
8108
  if (existing) {
@@ -7687,7 +8126,7 @@ async function acquireDirectorStateLock(lockPath, processId = process.pid) {
7687
8126
  }
7688
8127
  async function releaseDirectorStateLock(lockPath) {
7689
8128
  try {
7690
- await fsp.unlink(lockPath);
8129
+ await fsp2.unlink(lockPath);
7691
8130
  } catch {
7692
8131
  }
7693
8132
  }
@@ -7831,7 +8270,7 @@ async function loadGoal(filePath, events) {
7831
8270
  const t0 = Date.now();
7832
8271
  let raw;
7833
8272
  try {
7834
- raw = await fsp.readFile(filePath, "utf8");
8273
+ raw = await fsp2.readFile(filePath, "utf8");
7835
8274
  } catch (err) {
7836
8275
  const code = err.code;
7837
8276
  if (code === "ENOENT") {
@@ -8084,12 +8523,12 @@ var DefaultPromptStore = class {
8084
8523
  await ensureDir(this.dir);
8085
8524
  const entries = [];
8086
8525
  try {
8087
- const files = await fsp.readdir(this.dir);
8526
+ const files = await fsp2.readdir(this.dir);
8088
8527
  for (const file of files) {
8089
8528
  if (!file.endsWith(".json")) continue;
8090
8529
  try {
8091
8530
  const raw = JSON.parse(
8092
- await fsp.readFile(path2.join(this.dir, file), "utf8")
8531
+ await fsp2.readFile(path2.join(this.dir, file), "utf8")
8093
8532
  );
8094
8533
  entries.push(raw.entry);
8095
8534
  } catch {
@@ -8104,7 +8543,7 @@ var DefaultPromptStore = class {
8104
8543
  async get(id) {
8105
8544
  const file = path2.join(this.dir, `${id}.json`);
8106
8545
  try {
8107
- const raw = JSON.parse(await fsp.readFile(file, "utf8"));
8546
+ const raw = JSON.parse(await fsp2.readFile(file, "utf8"));
8108
8547
  return raw.entry;
8109
8548
  } catch {
8110
8549
  return null;
@@ -8119,7 +8558,7 @@ var DefaultPromptStore = class {
8119
8558
  async delete(id) {
8120
8559
  const file = path2.join(this.dir, `${id}.json`);
8121
8560
  try {
8122
- await fsp.unlink(file);
8561
+ await fsp2.unlink(file);
8123
8562
  return true;
8124
8563
  } catch {
8125
8564
  return false;
@@ -8226,7 +8665,7 @@ var CloudSync = class {
8226
8665
  lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString(),
8227
8666
  localRev: rev
8228
8667
  };
8229
- await fsp.writeFile(this.statePath, JSON.stringify(syncState, null, 2), "utf8");
8668
+ await fsp2.writeFile(this.statePath, JSON.stringify(syncState, null, 2), "utf8");
8230
8669
  this.state = syncState;
8231
8670
  return {
8232
8671
  ok: true,
@@ -8258,8 +8697,8 @@ var CloudSync = class {
8258
8697
  const rel = segments.slice(2).join("/");
8259
8698
  const destPath = resolvePulledCategoryPath(cat, localPath, rel, entry.path);
8260
8699
  const blobData = await this.getBlob(token, owner, repoName, entry.sha);
8261
- await fsp.mkdir(path2.dirname(destPath), { recursive: true });
8262
- 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"));
8263
8702
  }
8264
8703
  const localRev = await this.hashLocalCategories(cfg.categories);
8265
8704
  const syncState = {
@@ -8268,7 +8707,7 @@ var CloudSync = class {
8268
8707
  lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString(),
8269
8708
  localRev
8270
8709
  };
8271
- await fsp.writeFile(this.statePath, JSON.stringify(syncState, null, 2), "utf8");
8710
+ await fsp2.writeFile(this.statePath, JSON.stringify(syncState, null, 2), "utf8");
8272
8711
  this.state = syncState;
8273
8712
  return {
8274
8713
  ok: true,
@@ -8287,7 +8726,7 @@ var CloudSync = class {
8287
8726
  }
8288
8727
  async loadState() {
8289
8728
  try {
8290
- const raw = await fsp.readFile(this.statePath, "utf8");
8729
+ const raw = await fsp2.readFile(this.statePath, "utf8");
8291
8730
  this.state = JSON.parse(raw);
8292
8731
  } catch {
8293
8732
  this.state = null;
@@ -8365,17 +8804,17 @@ var CloudSync = class {
8365
8804
  const localPath = this.categoryToPath(cat);
8366
8805
  if (!localPath) continue;
8367
8806
  try {
8368
- const stat7 = await fsp.stat(localPath);
8369
- if (stat7.isDirectory()) {
8807
+ const stat10 = await fsp2.stat(localPath);
8808
+ if (stat10.isDirectory()) {
8370
8809
  const files = await this.walkDir(localPath, localPath);
8371
8810
  for (const file of files) {
8372
- const content = await fsp.readFile(file, "utf8");
8811
+ const content = await fsp2.readFile(file, "utf8");
8373
8812
  const rel = path2.relative(localPath, file).replace(/\\/g, "/");
8374
8813
  entries.push({ path: `data/${cat}/${rel}`, content, mode: "100644" });
8375
8814
  hashes.push(content);
8376
8815
  }
8377
8816
  } else {
8378
- const content = await fsp.readFile(localPath, "utf8");
8817
+ const content = await fsp2.readFile(localPath, "utf8");
8379
8818
  entries.push({ path: `data/${cat}`, content, mode: "100644" });
8380
8819
  hashes.push(content);
8381
8820
  }
@@ -8391,15 +8830,15 @@ var CloudSync = class {
8391
8830
  const localPath = this.categoryToPath(cat);
8392
8831
  if (!localPath) continue;
8393
8832
  try {
8394
- const stat7 = await fsp.stat(localPath);
8395
- if (stat7.isDirectory()) {
8833
+ const stat10 = await fsp2.stat(localPath);
8834
+ if (stat10.isDirectory()) {
8396
8835
  const files = await this.walkDir(localPath, localPath);
8397
8836
  for (const file of files) {
8398
- const content = await fsp.readFile(file);
8837
+ const content = await fsp2.readFile(file);
8399
8838
  hashes.push(content.toString("base64") + file);
8400
8839
  }
8401
8840
  } else {
8402
- const content = await fsp.readFile(localPath);
8841
+ const content = await fsp2.readFile(localPath);
8403
8842
  hashes.push(content.toString("base64") + localPath);
8404
8843
  }
8405
8844
  } catch {
@@ -8426,7 +8865,7 @@ var CloudSync = class {
8426
8865
  }
8427
8866
  async walkDir(dir, base) {
8428
8867
  const results = [];
8429
- const entries = await fsp.readdir(dir, { withFileTypes: true });
8868
+ const entries = await fsp2.readdir(dir, { withFileTypes: true });
8430
8869
  for (const entry of entries) {
8431
8870
  const full = path2.join(dir, entry.name);
8432
8871
  if (entry.isDirectory()) {