codemem 0.43.1 → 0.44.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -91,7 +91,7 @@ function emitJsonError(errorCode, message, exitCode = 1) {
91
91
  * and empty) are treated as toggles, not paths, so the default is used.
92
92
  * - All I/O is best-effort: failures are swallowed.
93
93
  */
94
- var BOOLEAN_TOGGLE_VALUES = new Set([
94
+ var BOOLEAN_TOGGLE_VALUES = /* @__PURE__ */ new Set([
95
95
  "",
96
96
  "0",
97
97
  "false",
@@ -102,7 +102,7 @@ var BOOLEAN_TOGGLE_VALUES = new Set([
102
102
  "on",
103
103
  "no"
104
104
  ]);
105
- function expandHome$4(value) {
105
+ function expandHome$3(value) {
106
106
  const home = process.env.HOME?.trim() || homedir();
107
107
  if (value === "~") return home;
108
108
  if (value.startsWith("~/")) return join(home, value.slice(2));
@@ -111,8 +111,8 @@ function expandHome$4(value) {
111
111
  function pluginLogPath() {
112
112
  const raw = process.env.CODEMEM_PLUGIN_LOG_PATH ?? process.env.CODEMEM_PLUGIN_LOG ?? "";
113
113
  const normalized = raw.trim().toLowerCase();
114
- if (BOOLEAN_TOGGLE_VALUES.has(normalized)) return expandHome$4("~/.codemem/plugin.log");
115
- return expandHome$4(raw.trim());
114
+ if (BOOLEAN_TOGGLE_VALUES.has(normalized)) return expandHome$3("~/.codemem/plugin.log");
115
+ return expandHome$3(raw.trim());
116
116
  }
117
117
  /**
118
118
  * Append a single timestamped line to the plugin log. Best-effort: any
@@ -131,7 +131,7 @@ function logHookEvent(message) {
131
131
  var FILE_GATE_MIN_BYTES = 1500;
132
132
  var FETCH_LIMIT = 40;
133
133
  var DISPLAY_LIMIT = 15;
134
- var MTIME_FRESH_TOLERANCE_MS = 300 * 1e3;
134
+ var MTIME_FRESH_TOLERANCE_MS = 3e5;
135
135
  var SMALL_FILE_BYPASS_PATTERNS = [
136
136
  /\.(json|jsonc|toml|ya?ml)$/i,
137
137
  /\.env(\.|$)/i,
@@ -164,7 +164,7 @@ function envTruthy$3(value) {
164
164
  const normalized = String(value ?? "").trim().toLowerCase();
165
165
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
166
166
  }
167
- function expandHome$3(value) {
167
+ function expandHome$2(value) {
168
168
  if (value === "~") return homedir();
169
169
  if (value.startsWith("~/")) return resolve(homedir(), value.slice(2));
170
170
  return value;
@@ -286,7 +286,8 @@ function queryByFile(dbPath, relativePath, project, limit) {
286
286
  }
287
287
  }
288
288
  function resolveProject$1(payload) {
289
- return resolveHookProject(typeof payload.cwd === "string" ? payload.cwd : null, payload.project);
289
+ const cwd = typeof payload.cwd === "string" ? payload.cwd : null;
290
+ return resolveHookProject(cwd, payload.project);
290
291
  }
291
292
  function sourceSessionId(payload) {
292
293
  const value = payload.session_id;
@@ -360,7 +361,7 @@ async function buildClaudeFileContext(payload, opts, deps = {}) {
360
361
  return finish(continueResult$2());
361
362
  }
362
363
  const cwd = typeof payload.cwd === "string" && payload.cwd.trim() ? payload.cwd : process.cwd();
363
- const expandedPath = expandHome$3(filePath);
364
+ const expandedPath = expandHome$2(filePath);
364
365
  const absolutePath = isAbsolute(expandedPath) ? expandedPath : resolve(cwd, expandedPath);
365
366
  const relativePath = relative(cwd, absolutePath).split(sep).join("/");
366
367
  const escapesCwd = relativePath === ".." || relativePath.startsWith("../") || isAbsolute(relativePath);
@@ -519,34 +520,22 @@ var claudeHookFileContextCommand = claudeHookFileContextCmd.action(async (opts)
519
520
  emitJson$2(await buildClaudeFileContext(payload, opts));
520
521
  });
521
522
  //#endregion
522
- //#region src/commands/claude-hook-ingest-spool.ts
523
+ //#region src/commands/hook-ingest-spool.ts
523
524
  /**
524
- * Durability layer for `claude-hook-ingest`: file-based mutex to
525
- * serialize concurrent invocations, on-disk spool that captures
526
- * payloads when both HTTP and direct ingestion paths fail, and a
527
- * recovery routine that promotes stale temp files back into the queue.
525
+ * Shared durability layer for the hook ingest commands (claude, codex):
526
+ * a file-based mutex that serializes concurrent invocations, an on-disk
527
+ * spool that captures payloads when both HTTP and direct ingestion fail,
528
+ * and a recovery routine that promotes stale temp files back into the
529
+ * queue. Each client instantiates its own copy via `createHookIngestSpool`
530
+ * with its own lock/spool dirs, TTL, retry budget, log prefix, and
531
+ * client-specific `LockBusyError` name/message. Per-client flush
532
+ * predicates stay in the client files.
533
+ */
534
+ var LOCK_ACQUIRE_BACKOFF_MS = 50;
535
+ /**
536
+ * Boolean-shaped env toggle used by per-client flush predicates
537
+ * (claude's `shouldForceBoundaryFlush`).
528
538
  */
529
- var DEFAULT_LOCK_TTL_S$1 = 300;
530
- var DEFAULT_LOCK_GRACE_S$1 = 2;
531
- var LOCK_ACQUIRE_ATTEMPTS$1 = 100;
532
- var LOCK_ACQUIRE_BACKOFF_MS$1 = 50;
533
- var LockBusyError = class extends Error {
534
- constructor() {
535
- super("claude-hook-ingest lock busy");
536
- this.name = "LockBusyError";
537
- }
538
- };
539
- function expandHome$2(value) {
540
- if (value === "~") return homedir();
541
- if (value.startsWith("~/")) return join(homedir(), value.slice(2));
542
- return value;
543
- }
544
- function envInt$1(name, fallback) {
545
- const raw = process.env[name];
546
- if (raw === void 0) return fallback;
547
- const parsed = Number.parseInt(raw, 10);
548
- return Number.isFinite(parsed) ? parsed : fallback;
549
- }
550
539
  function envTruthy$2(name, fallback) {
551
540
  const raw = process.env[name];
552
541
  if (raw === void 0) return fallback;
@@ -565,336 +554,404 @@ function envTruthy$2(name, fallback) {
565
554
  ].includes(normalized)) return false;
566
555
  return fallback;
567
556
  }
568
- function lockConfig$1() {
569
- return {
570
- lockDir: expandHome$2(process.env.CODEMEM_CLAUDE_HOOK_LOCK_DIR?.trim() || "~/.codemem/claude-hook-ingest.lock"),
571
- ttlSeconds: Math.max(1, envInt$1("CODEMEM_CLAUDE_HOOK_LOCK_TTL_S", DEFAULT_LOCK_TTL_S$1)),
572
- graceSeconds: Math.max(1, envInt$1("CODEMEM_CLAUDE_HOOK_LOCK_GRACE_S", DEFAULT_LOCK_GRACE_S$1))
573
- };
557
+ function expandHome$1(value) {
558
+ if (value === "~") return homedir();
559
+ if (value.startsWith("~/")) return join(homedir(), value.slice(2));
560
+ return value;
574
561
  }
575
- function spoolDir() {
576
- return expandHome$2(process.env.CODEMEM_CLAUDE_HOOK_SPOOL_DIR?.trim() || "~/.codemem/claude-hook-spool");
562
+ function envInt(name, fallback) {
563
+ const raw = process.env[name];
564
+ if (raw === void 0) return fallback;
565
+ const parsed = Number.parseInt(raw, 10);
566
+ return Number.isFinite(parsed) ? parsed : fallback;
577
567
  }
578
- /**
579
- * Cheap pre-check used by the unlocked HTTP-success path to decide
580
- * whether it needs to acquire the ingest lock and drain queued
581
- * payloads. Returns true when the spool directory contains at least
582
- * one active entry (a `*.json` file that is neither an in-flight
583
- * `.hook-tmp-*` nor a quarantined `.bad-*` file). Any I/O failure
584
- * is treated as "no entries" so callers stay on the fast path.
585
- */
586
- function hasSpooledEntries() {
587
- const dir = spoolDir();
588
- let entries;
589
- try {
590
- entries = readdirSync(dir);
591
- } catch {
568
+ function createHookIngestSpool(cfg) {
569
+ class LockBusyError extends Error {
570
+ constructor() {
571
+ super(cfg.lockBusyErrorMessage);
572
+ this.name = cfg.lockBusyErrorName;
573
+ }
574
+ }
575
+ function lockConfig() {
576
+ return {
577
+ lockDir: expandHome$1(process.env[cfg.lockDirEnv]?.trim() || cfg.lockDirDefault),
578
+ ttlSeconds: Math.max(1, envInt(cfg.lockTtlEnv, cfg.lockTtlDefault)),
579
+ graceSeconds: Math.max(1, envInt(cfg.lockGraceEnv, cfg.lockGraceDefault))
580
+ };
581
+ }
582
+ function spoolDir() {
583
+ return expandHome$1(process.env[cfg.spoolDirEnv]?.trim() || cfg.spoolDirDefault);
584
+ }
585
+ /**
586
+ * Returns the configured lock TTL so callers (hook-ingest commands)
587
+ * can pass the same value to `recoverStaleTmpSpool` without re-reading
588
+ * the env.
589
+ */
590
+ function lockTtlSeconds() {
591
+ return lockConfig().ttlSeconds;
592
+ }
593
+ /**
594
+ * Cheap pre-check used by the unlocked HTTP-success path to decide
595
+ * whether it needs to acquire the ingest lock and drain queued
596
+ * payloads. Returns true when the spool directory contains at least
597
+ * one active entry (a `*.json` file that is neither an in-flight
598
+ * `.hook-tmp-*` nor a quarantined `.bad-*` file). Any I/O failure
599
+ * is treated as "no entries" so callers stay on the fast path.
600
+ */
601
+ function hasSpooledEntries() {
602
+ let entries;
603
+ try {
604
+ entries = readdirSync(spoolDir());
605
+ } catch {
606
+ return false;
607
+ }
608
+ for (const name of entries) {
609
+ if (!name.endsWith(".json")) continue;
610
+ if (name.startsWith(".hook-tmp-") || name.startsWith(".bad-")) continue;
611
+ return true;
612
+ }
592
613
  return false;
593
614
  }
594
- for (const name of entries) {
595
- if (!name.endsWith(".json")) continue;
596
- if (name.startsWith(".hook-tmp-") || name.startsWith(".bad-")) continue;
597
- return true;
615
+ function readFileTrimmedOrEmpty(path) {
616
+ try {
617
+ return readFileSync(path, "utf8").trim();
618
+ } catch {
619
+ return "";
620
+ }
598
621
  }
599
- return false;
600
- }
601
- function readFileTrimmedOrEmpty(path) {
602
- try {
603
- return readFileSync(path, "utf8").trim();
604
- } catch {
605
- return "";
622
+ function readLockMetadata(lockDir) {
623
+ const pid = readFileTrimmedOrEmpty(join(lockDir, "pid"));
624
+ const owner = readFileTrimmedOrEmpty(join(lockDir, "owner"));
625
+ const tsRaw = readFileTrimmedOrEmpty(join(lockDir, "ts"));
626
+ const ts = tsRaw === "" ? null : Number.parseInt(tsRaw, 10);
627
+ return {
628
+ pid,
629
+ ts: ts === null || !Number.isFinite(ts) ? null : ts,
630
+ owner
631
+ };
606
632
  }
607
- }
608
- function readLockMetadata$1(lockDir) {
609
- const pid = readFileTrimmedOrEmpty(join(lockDir, "pid"));
610
- const owner = readFileTrimmedOrEmpty(join(lockDir, "owner"));
611
- const tsRaw = readFileTrimmedOrEmpty(join(lockDir, "ts"));
612
- const ts = tsRaw === "" ? null : Number.parseInt(tsRaw, 10);
613
- return {
614
- pid,
615
- ts: ts === null || !Number.isFinite(ts) ? null : ts,
616
- owner
617
- };
618
- }
619
- function isPidAlive$1(pidText) {
620
- const pid = Number.parseInt(pidText, 10);
621
- if (!Number.isFinite(pid) || pid <= 0) return false;
622
- try {
623
- process.kill(pid, 0);
624
- return true;
625
- } catch {
626
- return false;
633
+ function isPidAlive(pidText) {
634
+ const pid = Number.parseInt(pidText, 10);
635
+ if (!Number.isFinite(pid) || pid <= 0) return false;
636
+ try {
637
+ process.kill(pid, 0);
638
+ return true;
639
+ } catch {
640
+ return false;
641
+ }
627
642
  }
628
- }
629
- function lockIsStale$1(cfg) {
630
- const snapshot = readLockMetadata$1(cfg.lockDir);
631
- const nowS = Math.floor(Date.now() / 1e3);
632
- if (snapshot.pid) {
633
- if (isPidAlive$1(snapshot.pid)) {
634
- if (snapshot.ts === null) return {
635
- stale: false,
636
- snapshot
637
- };
643
+ function lockIsStale(cfg) {
644
+ const snapshot = readLockMetadata(cfg.lockDir);
645
+ const nowS = Math.floor(Date.now() / 1e3);
646
+ if (snapshot.pid) {
647
+ if (isPidAlive(snapshot.pid)) {
648
+ if (snapshot.ts === null) return {
649
+ stale: false,
650
+ snapshot
651
+ };
652
+ return {
653
+ stale: nowS - snapshot.ts > cfg.ttlSeconds,
654
+ snapshot
655
+ };
656
+ }
638
657
  return {
639
- stale: nowS - snapshot.ts > cfg.ttlSeconds,
658
+ stale: true,
640
659
  snapshot
641
660
  };
642
661
  }
643
- return {
644
- stale: true,
662
+ if (snapshot.ts !== null) return {
663
+ stale: nowS - snapshot.ts > cfg.graceSeconds,
645
664
  snapshot
646
665
  };
647
- }
648
- if (snapshot.ts !== null) return {
649
- stale: nowS - snapshot.ts > cfg.graceSeconds,
650
- snapshot
651
- };
652
- let mtimeS;
653
- try {
654
- mtimeS = Math.floor(statSync(cfg.lockDir).mtimeMs / 1e3);
655
- } catch {
666
+ let mtimeS;
667
+ try {
668
+ mtimeS = Math.floor(statSync(cfg.lockDir).mtimeMs / 1e3);
669
+ } catch {
670
+ return {
671
+ stale: true,
672
+ snapshot
673
+ };
674
+ }
656
675
  return {
657
- stale: true,
676
+ stale: nowS - mtimeS > cfg.graceSeconds,
658
677
  snapshot
659
678
  };
660
679
  }
661
- return {
662
- stale: nowS - mtimeS > cfg.graceSeconds,
663
- snapshot
664
- };
665
- }
666
- function cleanupLockDir$1(lockDir) {
667
- for (const name of [
668
- "pid",
669
- "ts",
670
- "owner"
671
- ]) try {
672
- unlinkSync(join(lockDir, name));
673
- } catch {}
674
- try {
675
- rmdirSync(lockDir);
676
- } catch {}
677
- }
678
- function snapshotsEqual(a, b) {
679
- return a.pid === b.pid && a.ts === b.ts && a.owner === b.owner;
680
- }
681
- function cleanupLockDirIfUnchanged$1(lockDir, snapshot) {
682
- if (snapshotsEqual(readLockMetadata$1(lockDir), snapshot)) cleanupLockDir$1(lockDir);
683
- }
684
- function isErrnoException$1(err) {
685
- return typeof err === "object" && err !== null && "code" in err;
686
- }
687
- async function sleep$1(ms) {
688
- return new Promise((resolve) => setTimeout(resolve, ms));
689
- }
690
- /**
691
- * Run `fn` while holding the claude-hook-ingest lock. Throws
692
- * `LockBusyError` when the lock cannot be acquired within
693
- * `LOCK_ACQUIRE_ATTEMPTS` attempts.
694
- *
695
- * The lock is a directory at `lockDir`, with three sentinel files
696
- * (`pid`, `ts`, `owner`) recording who currently holds it. Stale locks
697
- * are detected via PID liveness, TTL, and a grace window for the
698
- * race between mkdir and writing pid/ts.
699
- */
700
- async function withClaudeHookIngestLock(fn) {
701
- const cfg = lockConfig$1();
702
- mkdirSync(dirname(cfg.lockDir), { recursive: true });
703
- const ownerToken = `${process.pid}-${Math.floor(Date.now() / 1e3)}-${randomInt(1e3, 1e4)}`;
704
- let acquired = false;
705
- for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS$1; attempt++) {
680
+ function cleanupLockDir(lockDir) {
681
+ for (const name of [
682
+ "pid",
683
+ "ts",
684
+ "owner"
685
+ ]) try {
686
+ unlinkSync(join(lockDir, name));
687
+ } catch {}
706
688
  try {
707
- mkdirSync(cfg.lockDir);
708
- } catch (err) {
709
- if (isErrnoException$1(err) && err.code === "EEXIST") {
710
- const { stale, snapshot } = lockIsStale$1(cfg);
711
- if (stale) cleanupLockDirIfUnchanged$1(cfg.lockDir, snapshot);
712
- await sleep$1(LOCK_ACQUIRE_BACKOFF_MS$1);
689
+ rmdirSync(lockDir);
690
+ } catch {}
691
+ }
692
+ function snapshotsEqual(a, b) {
693
+ return a.pid === b.pid && a.ts === b.ts && a.owner === b.owner;
694
+ }
695
+ function cleanupLockDirIfUnchanged(lockDir, snapshot) {
696
+ if (snapshotsEqual(readLockMetadata(lockDir), snapshot)) cleanupLockDir(lockDir);
697
+ }
698
+ function isErrnoException(err) {
699
+ return typeof err === "object" && err !== null && "code" in err;
700
+ }
701
+ function sleep(ms) {
702
+ return new Promise((resolve) => setTimeout(resolve, ms));
703
+ }
704
+ /**
705
+ * Run `fn` while holding the client's ingest lock. Throws the
706
+ * client-specific `LockBusyError` when the lock cannot be acquired
707
+ * within `cfg.lockAcquireAttempts` attempts.
708
+ *
709
+ * The lock is a directory at `lockDir`, with three sentinel files
710
+ * (`pid`, `ts`, `owner`) recording who currently holds it. Stale locks
711
+ * are detected via PID liveness, TTL, and a grace window for the
712
+ * race between mkdir and writing pid/ts.
713
+ */
714
+ async function withLock(fn) {
715
+ const lock = lockConfig();
716
+ mkdirSync(dirname(lock.lockDir), { recursive: true });
717
+ const ownerToken = `${process.pid}-${Math.floor(Date.now() / 1e3)}-${randomInt(1e3, 1e4)}`;
718
+ let acquired = false;
719
+ for (let attempt = 0; attempt < cfg.lockAcquireAttempts; attempt++) {
720
+ try {
721
+ mkdirSync(lock.lockDir);
722
+ } catch (err) {
723
+ if (isErrnoException(err) && err.code === "EEXIST") {
724
+ const { stale, snapshot } = lockIsStale(lock);
725
+ if (stale) cleanupLockDirIfUnchanged(lock.lockDir, snapshot);
726
+ }
727
+ await sleep(LOCK_ACQUIRE_BACKOFF_MS);
713
728
  continue;
714
729
  }
715
- await sleep$1(LOCK_ACQUIRE_BACKOFF_MS$1);
716
- continue;
730
+ try {
731
+ writeFileSync(join(lock.lockDir, "ts"), String(Math.floor(Date.now() / 1e3)), { encoding: "utf8" });
732
+ writeFileSync(join(lock.lockDir, "pid"), String(process.pid), { encoding: "utf8" });
733
+ writeFileSync(join(lock.lockDir, "owner"), ownerToken, { encoding: "utf8" });
734
+ acquired = true;
735
+ break;
736
+ } catch {
737
+ cleanupLockDir(lock.lockDir);
738
+ await sleep(LOCK_ACQUIRE_BACKOFF_MS);
739
+ }
717
740
  }
741
+ if (!acquired) throw new LockBusyError();
718
742
  try {
719
- writeFileSync(join(cfg.lockDir, "ts"), String(Math.floor(Date.now() / 1e3)), { encoding: "utf8" });
720
- writeFileSync(join(cfg.lockDir, "pid"), String(process.pid), { encoding: "utf8" });
721
- writeFileSync(join(cfg.lockDir, "owner"), ownerToken, { encoding: "utf8" });
722
- acquired = true;
723
- break;
724
- } catch {
725
- cleanupLockDir$1(cfg.lockDir);
726
- await sleep$1(LOCK_ACQUIRE_BACKOFF_MS$1);
743
+ return await fn();
744
+ } finally {
745
+ if (readFileTrimmedOrEmpty(join(lock.lockDir, "owner")) === ownerToken) cleanupLockDir(lock.lockDir);
727
746
  }
728
747
  }
729
- if (!acquired) throw new LockBusyError();
730
- try {
731
- return await fn();
732
- } finally {
733
- if (readFileTrimmedOrEmpty(join(cfg.lockDir, "owner")) === ownerToken) cleanupLockDir$1(cfg.lockDir);
734
- }
735
- }
736
- /**
737
- * Persist a payload to the spool directory using a tmp+rename so that
738
- * a partially-written file is never visible to the drainer. Returns
739
- * true on success, false on any I/O failure.
740
- */
741
- function spoolPayload(payload) {
742
- const dir = spoolDir();
743
- try {
744
- mkdirSync(dir, { recursive: true });
745
- } catch {
746
- logHookEvent("codemem claude-hook-ingest failed to create spool dir");
747
- return false;
748
- }
749
- const payloadText = JSON.stringify(payload);
750
- const tmpPath = join(dir, `.hook-tmp-${process.pid}-${Date.now()}-${randomInt(1e3, 1e4)}.json`);
751
- try {
752
- writeFileSync(tmpPath, payloadText, { encoding: "utf8" });
753
- } catch {
754
- logHookEvent("codemem claude-hook-ingest failed to allocate spool temp file");
755
- return false;
756
- }
757
- const finalPath = join(dir, `hook-${Math.floor(Date.now() / 1e3)}-${process.pid}-${randomInt(1e3, 1e4)}.json`);
758
- try {
759
- renameSync(tmpPath, finalPath);
760
- } catch {
748
+ /**
749
+ * Persist a payload to the spool directory using a tmp+rename so that
750
+ * a partially-written file is never visible to the drainer. Returns
751
+ * true on success, false on any I/O failure.
752
+ */
753
+ function spoolPayload(payload) {
754
+ const dir = spoolDir();
761
755
  try {
762
- unlinkSync(tmpPath);
763
- } catch {}
764
- logHookEvent("codemem claude-hook-ingest failed to spool payload");
765
- return false;
766
- }
767
- logHookEvent(`codemem claude-hook-ingest spooled payload: ${finalPath}`);
768
- return true;
769
- }
770
- /**
771
- * Promote any `.hook-tmp-*.json` files older than `ttlSeconds` to a
772
- * recovered name so they are picked up by the next drain. Caller is
773
- * responsible for passing the same TTL used by lock acquisition so
774
- * that an in-flight write inside an active locked region is never
775
- * mistaken for a crashed-writer leftover.
776
- */
777
- function recoverStaleTmpSpool(ttlSeconds) {
778
- const dir = spoolDir();
779
- try {
780
- mkdirSync(dir, { recursive: true });
781
- } catch {
782
- return;
783
- }
784
- let entries;
785
- try {
786
- entries = readdirSync(dir);
787
- } catch {
788
- return;
789
- }
790
- const nowS = Date.now() / 1e3;
791
- for (const name of entries) {
792
- if (!name.startsWith(".hook-tmp-") || !name.endsWith(".json")) continue;
793
- const tmpPath = join(dir, name);
794
- let mtimeS;
756
+ mkdirSync(dir, { recursive: true });
757
+ } catch {
758
+ logHookEvent(`${cfg.logPrefix} failed to create spool dir`);
759
+ return false;
760
+ }
761
+ const payloadText = JSON.stringify(payload);
762
+ const tmpName = `.hook-tmp-${process.pid}-${Date.now()}-${randomInt(1e3, 1e4)}.json`;
763
+ const tmpPath = join(dir, tmpName);
795
764
  try {
796
- mtimeS = statSync(tmpPath).mtimeMs / 1e3;
765
+ writeFileSync(tmpPath, payloadText, { encoding: "utf8" });
797
766
  } catch {
798
- continue;
767
+ logHookEvent(`${cfg.logPrefix} failed to allocate spool temp file`);
768
+ return false;
799
769
  }
800
- if (nowS - mtimeS <= ttlSeconds) continue;
801
- const recoveredPath = join(dir, `hook-recovered-${Math.floor(nowS)}-${process.pid}-${randomInt(1e3, 1e4)}.json`);
770
+ const finalName = `hook-${Math.floor(Date.now() / 1e3)}-${process.pid}-${randomInt(1e3, 1e4)}.json`;
771
+ const finalPath = join(dir, finalName);
802
772
  try {
803
- renameSync(tmpPath, recoveredPath);
804
- logHookEvent(`codemem claude-hook-ingest recovered stale temp spool payload: ${recoveredPath}`);
805
- } catch {}
773
+ renameSync(tmpPath, finalPath);
774
+ } catch {
775
+ try {
776
+ unlinkSync(tmpPath);
777
+ } catch {}
778
+ logHookEvent(`${cfg.logPrefix} failed to spool payload`);
779
+ return false;
780
+ }
781
+ logHookEvent(`${cfg.logPrefix} spooled payload: ${finalPath}`);
782
+ return true;
806
783
  }
807
- }
808
- /**
809
- * Move a permanently-broken spool entry out of the queue so that it
810
- * stops being picked up by future drains. The entry is renamed in
811
- * place with a `.bad-<reason>-` prefix so an operator can inspect or
812
- * delete it manually.
813
- */
814
- function quarantineSpoolEntry$1(dir, name, reason) {
815
- const sourcePath = join(dir, name);
816
- const quarantineName = `.bad-${reason}-${Date.now()}-${randomInt(1e3, 1e4)}-${name}`;
817
- try {
818
- renameSync(sourcePath, join(dir, quarantineName));
819
- logHookEvent(`codemem claude-hook-ingest quarantined corrupt spool payload (${reason}): ${quarantineName}`);
820
- } catch {
784
+ /**
785
+ * Promote any `.hook-tmp-*.json` files older than `ttlSeconds` to a
786
+ * recovered name so they are picked up by the next drain. Caller is
787
+ * responsible for passing the same TTL used by lock acquisition so
788
+ * that an in-flight write inside an active locked region is never
789
+ * mistaken for a crashed-writer leftover.
790
+ */
791
+ function recoverStaleTmpSpool(ttlSeconds) {
792
+ const dir = spoolDir();
821
793
  try {
822
- unlinkSync(sourcePath);
823
- logHookEvent(`codemem claude-hook-ingest dropped corrupt spool payload (${reason}): ${name}`);
824
- } catch {}
825
- }
826
- }
827
- /**
828
- * Process every queued payload in the spool directory in lexicographic
829
- * order (which approximates oldest-first because filenames embed the
830
- * second-precision creation timestamp). The handler returns true to
831
- * indicate the payload has been durably accepted; only then is the
832
- * spool entry deleted. Failed entries are left on disk for the next
833
- * drain attempt.
834
- */
835
- async function drainSpool(handler) {
836
- const dir = spoolDir();
837
- try {
838
- mkdirSync(dir, { recursive: true });
839
- } catch {
840
- return {
841
- processed: 0,
842
- failed: 0
843
- };
844
- }
845
- let entries;
846
- try {
847
- entries = readdirSync(dir);
848
- } catch {
849
- return {
850
- processed: 0,
851
- failed: 0
852
- };
853
- }
854
- const queued = entries.filter((name) => name.endsWith(".json") && !name.startsWith(".hook-tmp-") && !name.startsWith(".bad-")).sort();
855
- const result = {
856
- processed: 0,
857
- failed: 0
858
- };
859
- for (const name of queued) {
860
- const path = join(dir, name);
861
- let raw;
794
+ mkdirSync(dir, { recursive: true });
795
+ } catch {
796
+ return;
797
+ }
798
+ let entries;
862
799
  try {
863
- raw = readFileSync(path, "utf8");
800
+ entries = readdirSync(dir);
864
801
  } catch {
865
- logHookEvent(`codemem claude-hook-ingest failed to read spooled payload: ${path}`);
866
- result.failed++;
867
- continue;
802
+ return;
803
+ }
804
+ const nowS = Date.now() / 1e3;
805
+ for (const name of entries) {
806
+ if (!name.startsWith(".hook-tmp-") || !name.endsWith(".json")) continue;
807
+ const tmpPath = join(dir, name);
808
+ let mtimeS;
809
+ try {
810
+ mtimeS = statSync(tmpPath).mtimeMs / 1e3;
811
+ } catch {
812
+ continue;
813
+ }
814
+ if (nowS - mtimeS <= ttlSeconds) continue;
815
+ const recoveredName = `hook-recovered-${Math.floor(nowS)}-${process.pid}-${randomInt(1e3, 1e4)}.json`;
816
+ const recoveredPath = join(dir, recoveredName);
817
+ try {
818
+ renameSync(tmpPath, recoveredPath);
819
+ logHookEvent(`${cfg.logPrefix} recovered stale temp spool payload: ${recoveredPath}`);
820
+ } catch {}
868
821
  }
869
- let parsed;
822
+ }
823
+ /**
824
+ * Move a permanently-broken spool entry out of the queue so that it
825
+ * stops being picked up by future drains. The entry is renamed in
826
+ * place with a `.bad-<reason>-` prefix so an operator can inspect or
827
+ * delete it manually.
828
+ */
829
+ function quarantineSpoolEntry(dir, name, reason) {
830
+ const sourcePath = join(dir, name);
831
+ const quarantineName = `.bad-${reason}-${Date.now()}-${randomInt(1e3, 1e4)}-${name}`;
870
832
  try {
871
- parsed = JSON.parse(raw);
833
+ renameSync(sourcePath, join(dir, quarantineName));
834
+ logHookEvent(`${cfg.logPrefix} quarantined corrupt spool payload (${reason}): ${quarantineName}`);
872
835
  } catch {
873
- quarantineSpoolEntry$1(dir, name, "parse-error");
874
- result.failed++;
875
- continue;
836
+ try {
837
+ unlinkSync(sourcePath);
838
+ logHookEvent(`${cfg.logPrefix} dropped corrupt spool payload (${reason}): ${name}`);
839
+ } catch {}
876
840
  }
877
- if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
878
- quarantineSpoolEntry$1(dir, name, "wrong-shape");
879
- continue;
841
+ }
842
+ /**
843
+ * Process every queued payload in the spool directory in lexicographic
844
+ * order (which approximates oldest-first because filenames embed the
845
+ * second-precision creation timestamp). The handler returns true to
846
+ * indicate the payload has been durably accepted; only then is the
847
+ * spool entry deleted. Failed entries are left on disk for the next
848
+ * drain attempt.
849
+ */
850
+ async function drainSpool(handler) {
851
+ const dir = spoolDir();
852
+ try {
853
+ mkdirSync(dir, { recursive: true });
854
+ } catch {
855
+ return {
856
+ processed: 0,
857
+ failed: 0
858
+ };
880
859
  }
881
- let ok = false;
860
+ let entries;
882
861
  try {
883
- ok = await handler(parsed);
862
+ entries = readdirSync(dir);
884
863
  } catch {
885
- ok = false;
864
+ return {
865
+ processed: 0,
866
+ failed: 0
867
+ };
886
868
  }
887
- if (ok) try {
888
- unlinkSync(path);
889
- result.processed++;
890
- } catch {}
891
- else {
892
- logHookEvent(`codemem claude-hook-ingest failed processing spooled payload: ${path}`);
893
- result.failed++;
869
+ const queued = entries.filter((name) => name.endsWith(".json") && !name.startsWith(".hook-tmp-") && !name.startsWith(".bad-")).sort();
870
+ const result = {
871
+ processed: 0,
872
+ failed: 0
873
+ };
874
+ for (const name of queued) {
875
+ const path = join(dir, name);
876
+ let raw;
877
+ try {
878
+ raw = readFileSync(path, "utf8");
879
+ } catch {
880
+ logHookEvent(`${cfg.logPrefix} failed to read spooled payload: ${path}`);
881
+ result.failed++;
882
+ continue;
883
+ }
884
+ let parsed;
885
+ try {
886
+ parsed = JSON.parse(raw);
887
+ } catch {
888
+ quarantineSpoolEntry(dir, name, "parse-error");
889
+ result.failed++;
890
+ continue;
891
+ }
892
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
893
+ quarantineSpoolEntry(dir, name, "wrong-shape");
894
+ result.failed++;
895
+ continue;
896
+ }
897
+ let ok = false;
898
+ try {
899
+ ok = await handler(parsed);
900
+ } catch {
901
+ ok = false;
902
+ }
903
+ if (ok) try {
904
+ unlinkSync(path);
905
+ result.processed++;
906
+ } catch {}
907
+ else {
908
+ logHookEvent(`${cfg.logPrefix} failed processing spooled payload: ${path}`);
909
+ result.failed++;
910
+ }
894
911
  }
912
+ return result;
895
913
  }
896
- return result;
914
+ return {
915
+ LockBusyError,
916
+ spoolDir,
917
+ lockTtlSeconds,
918
+ hasSpooledEntries,
919
+ withLock,
920
+ spoolPayload,
921
+ recoverStaleTmpSpool,
922
+ drainSpool
923
+ };
897
924
  }
925
+ //#endregion
926
+ //#region src/commands/claude-hook-ingest-spool.ts
927
+ /**
928
+ * Durability layer for `claude-hook-ingest`. The lock/spool/drain/
929
+ * quarantine machinery is shared in `hook-ingest-spool.ts`; this file
930
+ * wires the claude-specific config (dirs, TTL 300s, 100 acquire
931
+ * attempts, error name/message) and keeps the claude flush predicate.
932
+ */
933
+ var spool$1 = createHookIngestSpool({
934
+ logPrefix: "codemem claude-hook-ingest",
935
+ lockDirEnv: "CODEMEM_CLAUDE_HOOK_LOCK_DIR",
936
+ lockDirDefault: "~/.codemem/claude-hook-ingest.lock",
937
+ lockTtlEnv: "CODEMEM_CLAUDE_HOOK_LOCK_TTL_S",
938
+ lockTtlDefault: 300,
939
+ lockGraceEnv: "CODEMEM_CLAUDE_HOOK_LOCK_GRACE_S",
940
+ lockGraceDefault: 2,
941
+ lockAcquireAttempts: 100,
942
+ spoolDirEnv: "CODEMEM_CLAUDE_HOOK_SPOOL_DIR",
943
+ spoolDirDefault: "~/.codemem/claude-hook-spool",
944
+ lockBusyErrorName: "LockBusyError",
945
+ lockBusyErrorMessage: "claude-hook-ingest lock busy"
946
+ });
947
+ var LockBusyError = spool$1.LockBusyError;
948
+ var withClaudeHookIngestLock = spool$1.withLock;
949
+ var spoolPayload = spool$1.spoolPayload;
950
+ var drainSpool = spool$1.drainSpool;
951
+ var hasSpooledEntries = spool$1.hasSpooledEntries;
952
+ var recoverStaleTmpSpool = spool$1.recoverStaleTmpSpool;
953
+ var lockTtlSeconds = spool$1.lockTtlSeconds;
954
+ spool$1.spoolDir;
898
955
  /**
899
956
  * Whether the boundary-flush write-through should run for this hook
900
957
  * payload. SessionEnd defaults to forcing a flush; Stop only flushes
@@ -908,14 +965,6 @@ function shouldForceBoundaryFlush(payload) {
908
965
  if (!envTruthy$2("CODEMEM_CLAUDE_HOOK_FLUSH", false)) return false;
909
966
  return envTruthy$2("CODEMEM_CLAUDE_HOOK_FLUSH_ON_STOP", false);
910
967
  }
911
- /**
912
- * Returns the configured lock TTL so callers (`claude-hook-ingest`)
913
- * can pass the same value to `recoverStaleTmpSpool` without re-reading
914
- * the env.
915
- */
916
- function lockTtlSeconds() {
917
- return lockConfig$1().ttlSeconds;
918
- }
919
968
  //#endregion
920
969
  //#region src/commands/claude-hook-session-state.ts
921
970
  /**
@@ -945,14 +994,14 @@ function defaultSessionState() {
945
994
  updated_at: ""
946
995
  };
947
996
  }
948
- function expandHome$1(value) {
997
+ function expandHome(value) {
949
998
  if (value === "~") return homedir();
950
999
  if (value.startsWith("~/")) return join(homedir(), value.slice(2));
951
1000
  return value;
952
1001
  }
953
1002
  function contextDir() {
954
1003
  const override = process.env.CODEMEM_CLAUDE_HOOK_CONTEXT_DIR;
955
- return expandHome$1(override?.trim() ? override : "~/.codemem/claude-hook-context");
1004
+ return expandHome(override?.trim() ? override : "~/.codemem/claude-hook-context");
956
1005
  }
957
1006
  function sessionFileStem(sessionId) {
958
1007
  const trimmed = sessionId.trim();
@@ -1004,7 +1053,8 @@ function nowIso() {
1004
1053
  return (/* @__PURE__ */ new Date()).toISOString();
1005
1054
  }
1006
1055
  function saveSessionState(sessionId, state) {
1007
- mkdirSync(contextDir(), { recursive: true });
1056
+ const dir = contextDir();
1057
+ mkdirSync(dir, { recursive: true });
1008
1058
  const path = statePathForSession(sessionId);
1009
1059
  const tmpPath = `${path}.tmp`;
1010
1060
  const payload = {
@@ -1381,8 +1431,8 @@ async function ingestClaudeHookPayload(payload, opts, deps = {}) {
1381
1431
  };
1382
1432
  const httpResult = await httpIngest(httpPayload(payload), opts.host, port);
1383
1433
  if (httpResult.ok) {
1384
- await flushOnBoundaryIfRequested();
1385
1434
  await drainBacklogIfPresent();
1435
+ await flushOnBoundaryIfRequested();
1386
1436
  return {
1387
1437
  inserted: httpResult.inserted,
1388
1438
  skipped: httpResult.skipped,
@@ -1526,7 +1576,8 @@ function extractInjectContext(payload) {
1526
1576
  return normalizePromptText(payload.prompt) || null;
1527
1577
  }
1528
1578
  function resolveInjectProject$1(payload) {
1529
- return resolveHookProject(typeof payload.cwd === "string" ? payload.cwd : null, payload.project);
1579
+ const cwd = typeof payload.cwd === "string" ? payload.cwd : null;
1580
+ return resolveHookProject(cwd, payload.project);
1530
1581
  }
1531
1582
  async function buildLocalPack$1(context, project, dbPath, workingSetPaths = []) {
1532
1583
  const store = new MemoryStore(dbPath);
@@ -1622,273 +1673,34 @@ var claudeHookInjectCommand = claudeHookInjectCmd.action(async (opts) => {
1622
1673
  });
1623
1674
  //#endregion
1624
1675
  //#region src/commands/codex-hook-ingest-spool.ts
1625
- var DEFAULT_LOCK_TTL_S = 120;
1626
- var DEFAULT_LOCK_GRACE_S = 2;
1627
- var LOCK_ACQUIRE_ATTEMPTS = 20;
1628
- var LOCK_ACQUIRE_BACKOFF_MS = 50;
1629
- var CodexHookLockBusyError = class extends Error {
1630
- constructor() {
1631
- super("codex-hook-ingest lock busy");
1632
- this.name = "CodexHookLockBusyError";
1633
- }
1634
- };
1635
- function expandHome(value) {
1636
- if (value === "~") return homedir();
1637
- if (value.startsWith("~/")) return join(homedir(), value.slice(2));
1638
- return value;
1639
- }
1640
- function envInt(name, fallback) {
1641
- const parsed = Number.parseInt(process.env[name] ?? "", 10);
1642
- return Number.isFinite(parsed) ? parsed : fallback;
1643
- }
1644
- function lockConfig() {
1645
- return {
1646
- lockDir: expandHome(process.env.CODEMEM_CODEX_HOOK_LOCK_DIR?.trim() || "~/.codemem/codex-hook-ingest.lock"),
1647
- ttlSeconds: Math.max(1, envInt("CODEMEM_CODEX_HOOK_LOCK_TTL_S", DEFAULT_LOCK_TTL_S)),
1648
- graceSeconds: Math.max(1, envInt("CODEMEM_CODEX_HOOK_LOCK_GRACE_S", DEFAULT_LOCK_GRACE_S))
1649
- };
1650
- }
1651
- function codexHookSpoolDir() {
1652
- return expandHome(process.env.CODEMEM_CODEX_HOOK_SPOOL_DIR?.trim() || "~/.codemem/codex-hook-spool");
1653
- }
1654
- function codexHookLockTtlSeconds() {
1655
- return lockConfig().ttlSeconds;
1656
- }
1657
- function hasCodexHookSpooledEntries() {
1658
- let entries;
1659
- try {
1660
- entries = readdirSync(codexHookSpoolDir());
1661
- } catch {
1662
- return false;
1663
- }
1664
- return entries.some((name) => name.endsWith(".json") && !name.startsWith(".hook-tmp-") && !name.startsWith(".bad-"));
1665
- }
1666
- function readTrimmed(path) {
1667
- try {
1668
- return readFileSync(path, "utf8").trim();
1669
- } catch {
1670
- return "";
1671
- }
1672
- }
1673
- function readLockMetadata(lockDir) {
1674
- const rawTs = readTrimmed(join(lockDir, "ts"));
1675
- const ts = rawTs === "" ? null : Number.parseInt(rawTs, 10);
1676
- return {
1677
- pid: readTrimmed(join(lockDir, "pid")),
1678
- ts: ts === null || !Number.isFinite(ts) ? null : ts,
1679
- owner: readTrimmed(join(lockDir, "owner"))
1680
- };
1681
- }
1682
- function isPidAlive(pidText) {
1683
- const pid = Number.parseInt(pidText, 10);
1684
- if (!Number.isFinite(pid) || pid <= 0) return false;
1685
- try {
1686
- process.kill(pid, 0);
1687
- return true;
1688
- } catch {
1689
- return false;
1690
- }
1691
- }
1692
- function lockIsStale(cfg) {
1693
- const snapshot = readLockMetadata(cfg.lockDir);
1694
- const nowS = Math.floor(Date.now() / 1e3);
1695
- if (snapshot.pid) {
1696
- if (isPidAlive(snapshot.pid)) return {
1697
- stale: snapshot.ts !== null && nowS - snapshot.ts > cfg.ttlSeconds,
1698
- snapshot
1699
- };
1700
- return {
1701
- stale: true,
1702
- snapshot
1703
- };
1704
- }
1705
- if (snapshot.ts !== null) return {
1706
- stale: nowS - snapshot.ts > cfg.graceSeconds,
1707
- snapshot
1708
- };
1709
- try {
1710
- return {
1711
- stale: nowS - Math.floor(statSync(cfg.lockDir).mtimeMs / 1e3) > cfg.graceSeconds,
1712
- snapshot
1713
- };
1714
- } catch {
1715
- return {
1716
- stale: true,
1717
- snapshot
1718
- };
1719
- }
1720
- }
1721
- function cleanupLockDir(lockDir) {
1722
- for (const name of [
1723
- "pid",
1724
- "ts",
1725
- "owner"
1726
- ]) try {
1727
- unlinkSync(join(lockDir, name));
1728
- } catch {}
1729
- try {
1730
- rmdirSync(lockDir);
1731
- } catch {}
1732
- }
1733
- function cleanupLockDirIfUnchanged(lockDir, snapshot) {
1734
- const current = readLockMetadata(lockDir);
1735
- if (current.pid === snapshot.pid && current.ts === snapshot.ts && current.owner === snapshot.owner) cleanupLockDir(lockDir);
1736
- }
1737
- function isErrnoException(err) {
1738
- return typeof err === "object" && err !== null && "code" in err;
1739
- }
1740
- function sleep(ms) {
1741
- return new Promise((resolve) => setTimeout(resolve, ms));
1742
- }
1743
- async function withCodexHookIngestLock(fn) {
1744
- const cfg = lockConfig();
1745
- mkdirSync(dirname(cfg.lockDir), { recursive: true });
1746
- const ownerToken = `${process.pid}-${Math.floor(Date.now() / 1e3)}-${randomInt(1e3, 1e4)}`;
1747
- let acquired = false;
1748
- for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS; attempt++) {
1749
- try {
1750
- mkdirSync(cfg.lockDir);
1751
- } catch (err) {
1752
- if (isErrnoException(err) && err.code === "EEXIST") {
1753
- const { stale, snapshot } = lockIsStale(cfg);
1754
- if (stale) cleanupLockDirIfUnchanged(cfg.lockDir, snapshot);
1755
- }
1756
- await sleep(LOCK_ACQUIRE_BACKOFF_MS);
1757
- continue;
1758
- }
1759
- try {
1760
- writeFileSync(join(cfg.lockDir, "ts"), String(Math.floor(Date.now() / 1e3)), "utf8");
1761
- writeFileSync(join(cfg.lockDir, "pid"), String(process.pid), "utf8");
1762
- writeFileSync(join(cfg.lockDir, "owner"), ownerToken, "utf8");
1763
- acquired = true;
1764
- break;
1765
- } catch {
1766
- cleanupLockDir(cfg.lockDir);
1767
- await sleep(LOCK_ACQUIRE_BACKOFF_MS);
1768
- }
1769
- }
1770
- if (!acquired) throw new CodexHookLockBusyError();
1771
- try {
1772
- return await fn();
1773
- } finally {
1774
- if (readTrimmed(join(cfg.lockDir, "owner")) === ownerToken) cleanupLockDir(cfg.lockDir);
1775
- }
1776
- }
1777
- function spoolCodexHookPayload(payload) {
1778
- const dir = codexHookSpoolDir();
1779
- try {
1780
- mkdirSync(dir, { recursive: true });
1781
- } catch {
1782
- logHookEvent("codemem codex-hook-ingest failed to create spool dir");
1783
- return false;
1784
- }
1785
- const tmpPath = join(dir, `.hook-tmp-${process.pid}-${Date.now()}-${randomInt(1e3, 1e4)}.json`);
1786
- try {
1787
- writeFileSync(tmpPath, JSON.stringify(payload), "utf8");
1788
- } catch {
1789
- logHookEvent("codemem codex-hook-ingest failed to allocate spool temp file");
1790
- return false;
1791
- }
1792
- const finalPath = join(dir, `hook-${Math.floor(Date.now() / 1e3)}-${process.pid}-${randomInt(1e3, 1e4)}.json`);
1793
- try {
1794
- renameSync(tmpPath, finalPath);
1795
- } catch {
1796
- try {
1797
- unlinkSync(tmpPath);
1798
- } catch {}
1799
- logHookEvent("codemem codex-hook-ingest failed to spool payload");
1800
- return false;
1801
- }
1802
- logHookEvent(`codemem codex-hook-ingest spooled payload: ${finalPath}`);
1803
- return true;
1804
- }
1805
- function recoverStaleCodexHookTmpSpool(ttlSeconds) {
1806
- const dir = codexHookSpoolDir();
1807
- try {
1808
- mkdirSync(dir, { recursive: true });
1809
- } catch {
1810
- return;
1811
- }
1812
- let entries;
1813
- try {
1814
- entries = readdirSync(dir);
1815
- } catch {
1816
- return;
1817
- }
1818
- const nowS = Date.now() / 1e3;
1819
- for (const name of entries) {
1820
- if (!name.startsWith(".hook-tmp-") || !name.endsWith(".json")) continue;
1821
- const tmpPath = join(dir, name);
1822
- try {
1823
- if (nowS - statSync(tmpPath).mtimeMs / 1e3 <= ttlSeconds) continue;
1824
- renameSync(tmpPath, join(dir, `hook-recovered-${Math.floor(nowS)}-${process.pid}-${randomInt(1e3, 1e4)}.json`));
1825
- } catch {}
1826
- }
1827
- }
1828
- function quarantineSpoolEntry(dir, name, reason) {
1829
- try {
1830
- renameSync(join(dir, name), join(dir, `.bad-${reason}-${Date.now()}-${randomInt(1e3, 1e4)}-${name}`));
1831
- } catch {
1832
- try {
1833
- unlinkSync(join(dir, name));
1834
- } catch {}
1835
- }
1836
- }
1837
- async function drainCodexHookSpool(handler) {
1838
- const dir = codexHookSpoolDir();
1839
- try {
1840
- mkdirSync(dir, { recursive: true });
1841
- } catch {
1842
- return {
1843
- processed: 0,
1844
- failed: 0
1845
- };
1846
- }
1847
- let entries;
1848
- try {
1849
- entries = readdirSync(dir);
1850
- } catch {
1851
- return {
1852
- processed: 0,
1853
- failed: 0
1854
- };
1855
- }
1856
- const result = {
1857
- processed: 0,
1858
- failed: 0
1859
- };
1860
- for (const name of entries.filter((entry) => entry.endsWith(".json") && !entry.startsWith(".hook-tmp-") && !entry.startsWith(".bad-")).sort()) {
1861
- const path = join(dir, name);
1862
- let parsed;
1863
- try {
1864
- parsed = JSON.parse(readFileSync(path, "utf8"));
1865
- } catch {
1866
- quarantineSpoolEntry(dir, name, "parse-error");
1867
- result.failed++;
1868
- continue;
1869
- }
1870
- if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
1871
- quarantineSpoolEntry(dir, name, "wrong-shape");
1872
- result.failed++;
1873
- continue;
1874
- }
1875
- let ok = false;
1876
- try {
1877
- ok = await handler(parsed);
1878
- } catch {
1879
- ok = false;
1880
- }
1881
- if (!ok) {
1882
- result.failed++;
1883
- continue;
1884
- }
1885
- try {
1886
- unlinkSync(path);
1887
- result.processed++;
1888
- } catch {}
1889
- }
1890
- return result;
1891
- }
1676
+ /**
1677
+ * Durability layer for `codex-hook-ingest`. The lock/spool/drain/
1678
+ * quarantine machinery is shared in `hook-ingest-spool.ts`; this file
1679
+ * wires the codex-specific config (dirs, TTL 120s, 20 acquire attempts,
1680
+ * error name/message).
1681
+ */
1682
+ var spool = createHookIngestSpool({
1683
+ logPrefix: "codemem codex-hook-ingest",
1684
+ lockDirEnv: "CODEMEM_CODEX_HOOK_LOCK_DIR",
1685
+ lockDirDefault: "~/.codemem/codex-hook-ingest.lock",
1686
+ lockTtlEnv: "CODEMEM_CODEX_HOOK_LOCK_TTL_S",
1687
+ lockTtlDefault: 120,
1688
+ lockGraceEnv: "CODEMEM_CODEX_HOOK_LOCK_GRACE_S",
1689
+ lockGraceDefault: 2,
1690
+ lockAcquireAttempts: 20,
1691
+ spoolDirEnv: "CODEMEM_CODEX_HOOK_SPOOL_DIR",
1692
+ spoolDirDefault: "~/.codemem/codex-hook-spool",
1693
+ lockBusyErrorName: "CodexHookLockBusyError",
1694
+ lockBusyErrorMessage: "codex-hook-ingest lock busy"
1695
+ });
1696
+ var CodexHookLockBusyError = spool.LockBusyError;
1697
+ var withCodexHookIngestLock = spool.withLock;
1698
+ var spoolCodexHookPayload = spool.spoolPayload;
1699
+ var drainCodexHookSpool = spool.drainSpool;
1700
+ var hasCodexHookSpooledEntries = spool.hasSpooledEntries;
1701
+ var recoverStaleCodexHookTmpSpool = spool.recoverStaleTmpSpool;
1702
+ var codexHookLockTtlSeconds = spool.lockTtlSeconds;
1703
+ spool.spoolDir;
1892
1704
  //#endregion
1893
1705
  //#region src/commands/codex-hook-ingest.ts
1894
1706
  /**
@@ -2388,7 +2200,8 @@ function formatCodexAdditionalContext(packText, maxChars) {
2388
2200
  return `${CODEMEM_CONTEXT_HEADER}${truncateAdditionalContext(normalized, bodyMaxChars)}`;
2389
2201
  }
2390
2202
  function resolveInjectProject(payload) {
2391
- return resolveHookProject(typeof payload.cwd === "string" ? payload.cwd : null, payload.project);
2203
+ const cwd = typeof payload.cwd === "string" ? payload.cwd : null;
2204
+ return resolveHookProject(cwd, payload.project);
2392
2205
  }
2393
2206
  function buildCodexInjectQuery(prompt, project) {
2394
2207
  return [prompt, project ?? ""].filter((part) => part.trim().length > 0).join(" ").slice(0, 500) || "recent work";
@@ -3426,8 +3239,8 @@ function escapeSqlLikePattern(value) {
3426
3239
  }
3427
3240
  function formatBytes(bytes) {
3428
3241
  if (bytes < 1024) return `${bytes} B`;
3429
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
3430
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
3242
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
3243
+ return `${(bytes / 1048576).toFixed(1)} MB`;
3431
3244
  }
3432
3245
  function parseOptionalPositiveInt$1(value) {
3433
3246
  if (!value) return void 0;
@@ -4116,8 +3929,9 @@ function resolveCandidateTarget(candidate) {
4116
3929
  path: join(homedir(), ".config", "opencode", "AGENTS.md"),
4117
3930
  display: "~/.config/opencode/AGENTS.md"
4118
3931
  };
3932
+ const root = resolveProjectRoot(process.cwd()) ?? process.cwd();
4119
3933
  return {
4120
- path: join(resolveProjectRoot(process.cwd()) ?? process.cwd(), "AGENTS.md"),
3934
+ path: join(root, "AGENTS.md"),
4121
3935
  display: "AGENTS.md"
4122
3936
  };
4123
3937
  }
@@ -4358,7 +4172,8 @@ function emitStructuredError(errorCode, message) {
4358
4172
  async function runEnqueueRawEvent(opts, dependencies = {}) {
4359
4173
  try {
4360
4174
  const payload = await (dependencies.readPayload ?? readStdinJson)();
4361
- const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
4175
+ const dbPath = resolveDbPath(resolveDbOpt(opts));
4176
+ const store = new MemoryStore(dbPath);
4362
4177
  try {
4363
4178
  ingestRawEvents(store, payload);
4364
4179
  } finally {
@@ -4480,7 +4295,8 @@ var defaultLogger = {
4480
4295
  error: (message) => console.error(`Error: ${message}`)
4481
4296
  };
4482
4297
  function readWorkerSyncConfig(configPath) {
4483
- return readCoordinatorSyncConfig(configPath ? readCodememConfigFileAtPath(configPath) : readCodememConfigFile());
4298
+ const config = configPath ? readCodememConfigFileAtPath(configPath) : readCodememConfigFile();
4299
+ return readCoordinatorSyncConfig(config);
4484
4300
  }
4485
4301
  function createSequentialBackfillCoordinator(store, jobPlans, options) {
4486
4302
  const pollIntervalMs = 1e3;
@@ -4575,7 +4391,7 @@ function startMaintenanceWorkerRuntime(options = {}) {
4575
4391
  } catch (error) {
4576
4392
  logger.warn(`WAL checkpoint failed: ${error instanceof Error ? error.message : String(error)}`);
4577
4393
  }
4578
- }, 300 * 1e3);
4394
+ }, 3e5);
4579
4395
  walCheckpointTimer.unref();
4580
4396
  if (!isEmbeddingDisabled()) runners.push(new VectorModelMigrationRunner({
4581
4397
  dbPath,
@@ -5319,7 +5135,7 @@ function createMemoryExtractionReplayCommand() {
5319
5135
  observerReasoningEffort: opts.reasoningEffort === void 0 ? observerConfig.observerReasoningEffort : opts.reasoningEffort.trim() || null,
5320
5136
  observerReasoningSummary: opts.reasoningSummary === void 0 ? observerConfig.observerReasoningSummary : opts.reasoningSummary.trim() || null,
5321
5137
  observerMaxOutputTokens: maxOutputTokens ?? observerConfig.observerMaxOutputTokens ?? observerConfig.observerMaxTokens,
5322
- observerExplicitConfigKeys: maxOutputTokens === null ? observerConfig.observerExplicitConfigKeys : [...new Set([...observerConfig.observerExplicitConfigKeys ?? [], "observerMaxOutputTokens"])]
5138
+ observerExplicitConfigKeys: maxOutputTokens === null ? observerConfig.observerExplicitConfigKeys : [.../* @__PURE__ */ new Set([...observerConfig.observerExplicitConfigKeys ?? [], "observerMaxOutputTokens"])]
5323
5139
  };
5324
5140
  const observer = new ObserverClient(observerConfigWithOverrides);
5325
5141
  const result = opts.observerTierRouting === true ? await replayBatchExtractionWithTierRouting(resolveDbOpt(opts), observerConfigWithOverrides, {
@@ -5436,7 +5252,7 @@ function createMemoryExtractionBenchmarkCommand() {
5436
5252
  observerReasoningEffort: opts.reasoningEffort === void 0 ? observerConfig.observerReasoningEffort : opts.reasoningEffort.trim() || null,
5437
5253
  observerReasoningSummary: opts.reasoningSummary === void 0 ? observerConfig.observerReasoningSummary : opts.reasoningSummary.trim() || null,
5438
5254
  observerMaxOutputTokens: maxOutputTokens ?? observerConfig.observerMaxOutputTokens ?? observerConfig.observerMaxTokens,
5439
- observerExplicitConfigKeys: maxOutputTokens === null ? observerConfig.observerExplicitConfigKeys : [...new Set([...observerConfig.observerExplicitConfigKeys ?? [], "observerMaxOutputTokens"])]
5255
+ observerExplicitConfigKeys: maxOutputTokens === null ? observerConfig.observerExplicitConfigKeys : [.../* @__PURE__ */ new Set([...observerConfig.observerExplicitConfigKeys ?? [], "observerMaxOutputTokens"])]
5440
5256
  };
5441
5257
  const observer = new ObserverClient(observerConfigWithOverrides);
5442
5258
  const runs = [];
@@ -5778,9 +5594,9 @@ memoryCommand.addCommand(createMemoryRelinkReportCommand());
5778
5594
  memoryCommand.addCommand(createMemoryRelinkPlanCommand());
5779
5595
  //#endregion
5780
5596
  //#region src/commands/pack.ts
5781
- var MAX_INTERNAL_LEDGER_INPUT_BYTES = 16 * 1024;
5597
+ var MAX_INTERNAL_LEDGER_INPUT_BYTES = 16384;
5782
5598
  var INTERNAL_LEDGER_INPUT_TIMEOUT_MS = 1e3;
5783
- var FORBIDDEN_INTERNAL_KEYS = new Set([
5599
+ var FORBIDDEN_INTERNAL_KEYS = /* @__PURE__ */ new Set([
5784
5600
  "body",
5785
5601
  "context",
5786
5602
  "pack",
@@ -5792,7 +5608,7 @@ var FORBIDDEN_INTERNAL_KEYS = new Set([
5792
5608
  "raw_prompt",
5793
5609
  "title"
5794
5610
  ]);
5795
- var INTERNAL_LEDGER_KEYS = new Set([
5611
+ var INTERNAL_LEDGER_KEYS = /* @__PURE__ */ new Set([
5796
5612
  "action",
5797
5613
  "attempt_id",
5798
5614
  "started_at",
@@ -5807,13 +5623,13 @@ var INTERNAL_LEDGER_KEYS = new Set([
5807
5623
  "failure_stage",
5808
5624
  "original_attempt_id"
5809
5625
  ]);
5810
- var INTERNAL_LEDGER_ACTIONS = new Set([
5626
+ var INTERNAL_LEDGER_ACTIONS = /* @__PURE__ */ new Set([
5811
5627
  "record",
5812
5628
  "delivery",
5813
5629
  "cache_reuse"
5814
5630
  ]);
5815
- var INTERNAL_RETRIEVAL_STATUSES = new Set(["skipped", "failed"]);
5816
- var INTERNAL_DELIVERY_STATUSES = new Set([
5631
+ var INTERNAL_RETRIEVAL_STATUSES = /* @__PURE__ */ new Set(["skipped", "failed"]);
5632
+ var INTERNAL_DELIVERY_STATUSES = /* @__PURE__ */ new Set([
5817
5633
  "handed_off",
5818
5634
  "failed",
5819
5635
  "unknown"
@@ -6724,7 +6540,8 @@ async function startForegroundViewer(invocation) {
6724
6540
  const sweeper = new RawEventSweeper(store, { observer });
6725
6541
  sweeper.start();
6726
6542
  const syncAbort = new AbortController();
6727
- const syncConfig = readCoordinatorSyncConfig(invocation.configPath ? readCodememConfigFileAtPath(invocation.configPath) : readCodememConfigFile());
6543
+ const config = invocation.configPath ? readCodememConfigFileAtPath(invocation.configPath) : readCodememConfigFile();
6544
+ const syncConfig = readCoordinatorSyncConfig(config);
6728
6545
  const syncEnabled = syncConfig.syncEnabled;
6729
6546
  const dbPath = resolveDbPath(invocation.dbPath ?? void 0);
6730
6547
  if (!await terminateTrustedMaintenanceWorker(dbPath, {
@@ -7020,29 +6837,39 @@ function migrateLegacyOpencodePlugin() {
7020
6837
  p.log.step("Removed legacy compat lib: ~/.config/opencode/lib/compat.js");
7021
6838
  } catch {}
7022
6839
  }
7023
- /** Detect and upgrade legacy uvx/uv-based MCP entries in OpenCode config. */
6840
+ function isExactStringArray(value, expected) {
6841
+ return Array.isArray(value) && value.length === expected.length && value.every((entry, index) => entry === expected[index]);
6842
+ }
6843
+ /** Detect and upgrade legacy uvx/uv or managed single-package MCP entries in OpenCode config. */
7024
6844
  function migrateLegacyOpencodeMcp(config) {
7025
6845
  const mcpConfig = config.mcp;
7026
6846
  if (!mcpConfig || typeof mcpConfig !== "object") return false;
7027
6847
  const entry = mcpConfig.codemem;
7028
6848
  if (!entry || typeof entry !== "object") return false;
7029
6849
  const command = entry.command;
7030
- if (Array.isArray(command) && command.some((arg) => typeof arg === "string" && (arg === "uvx" || arg === "uv")) || typeof command === "string" && (command === "uvx" || command === "uv")) {
7031
- p.log.step("Upgrading legacy uvx MCP entry to npx");
6850
+ const isLegacyUv = Array.isArray(command) && command.some((arg) => typeof arg === "string" && (arg === "uvx" || arg === "uv")) || typeof command === "string" && (command === "uvx" || command === "uv");
6851
+ const isManagedSinglePackageNpx = isExactStringArray(command, [
6852
+ "npx",
6853
+ "-y",
6854
+ "codemem",
6855
+ "mcp"
6856
+ ]) || isExactStringArray(command, [
6857
+ "npx",
6858
+ "codemem",
6859
+ "mcp"
6860
+ ]);
6861
+ if (isLegacyUv || isManagedSinglePackageNpx) {
6862
+ p.log.step("Upgrading managed MCP entry to the current npm launcher");
6863
+ const launcher = codememMcpLauncher();
7032
6864
  mcpConfig.codemem = {
7033
- type: "local",
7034
- command: [
7035
- "npx",
7036
- "codemem",
7037
- "mcp"
7038
- ],
7039
- enabled: true
6865
+ ...entry,
6866
+ command: [launcher.command, ...launcher.args]
7040
6867
  };
7041
6868
  return true;
7042
6869
  }
7043
6870
  return false;
7044
6871
  }
7045
- /** Detect and upgrade legacy uvx-based MCP entries in Claude settings. */
6872
+ /** Detect and upgrade legacy uvx or managed single-package MCP entries in Claude settings. */
7046
6873
  function migrateLegacyClaudeMcp(settings) {
7047
6874
  const mcpServers = settings.mcpServers;
7048
6875
  if (!mcpServers || typeof mcpServers !== "object") return false;
@@ -7050,15 +6877,19 @@ function migrateLegacyClaudeMcp(settings) {
7050
6877
  if (!entry || typeof entry !== "object") return false;
7051
6878
  const command = entry.command;
7052
6879
  const args = entry.args;
7053
- if (typeof command === "string" && (command === "uvx" || command === "uv") || Array.isArray(args) && args.some((arg) => typeof arg === "string" && (arg.startsWith("codemem==") || arg === "uvx"))) {
7054
- p.log.step("Upgrading legacy uvx Claude MCP entry to npx");
6880
+ const isLegacyUv = typeof command === "string" && (command === "uvx" || command === "uv") || Array.isArray(args) && args.some((arg) => typeof arg === "string" && (arg.startsWith("codemem==") || arg === "uvx"));
6881
+ const isManagedSinglePackageNpx = command === "npx" && isExactStringArray(args, [
6882
+ "-y",
6883
+ "codemem",
6884
+ "mcp"
6885
+ ]);
6886
+ if (isLegacyUv || isManagedSinglePackageNpx) {
6887
+ p.log.step("Upgrading managed Claude MCP entry to the current npm launcher");
6888
+ const launcher = codememMcpLauncher();
7055
6889
  mcpServers.codemem = {
7056
- command: "npx",
7057
- args: [
7058
- "-y",
7059
- "codemem",
7060
- "mcp"
7061
- ]
6890
+ ...entry,
6891
+ command: launcher.command,
6892
+ args: launcher.args
7062
6893
  };
7063
6894
  return true;
7064
6895
  }
@@ -7113,13 +6944,10 @@ function installMcp(force) {
7113
6944
  return true;
7114
6945
  }
7115
6946
  if (!migrated) {
6947
+ const launcher = codememMcpLauncher();
7116
6948
  mcpConfig.codemem = {
7117
6949
  type: "local",
7118
- command: [
7119
- "npx",
7120
- "codemem",
7121
- "mcp"
7122
- ],
6950
+ command: [launcher.command, ...launcher.args],
7123
6951
  enabled: true
7124
6952
  };
7125
6953
  config.mcp = mcpConfig;
@@ -7136,7 +6964,8 @@ function installMcp(force) {
7136
6964
  function isClaudeHooksPluginInstalled() {
7137
6965
  const pluginDir = join(claudeConfigDir(), "plugins", "codemem");
7138
6966
  if (existsSync(pluginDir)) return true;
7139
- if (existsSync(join(pluginDir, "hooks", "hooks.json"))) return true;
6967
+ const hooksJson = join(pluginDir, "hooks", "hooks.json");
6968
+ if (existsSync(hooksJson)) return true;
7140
6969
  return false;
7141
6970
  }
7142
6971
  function installClaudeMcp(force) {
@@ -7153,13 +6982,10 @@ function installClaudeMcp(force) {
7153
6982
  if ("codemem" in mcpServers && !force && !migrated) p.log.info(`Claude MCP entry already exists in ${settingsPath}`);
7154
6983
  else {
7155
6984
  if (!migrated) {
6985
+ const launcher = codememMcpLauncher();
7156
6986
  mcpServers.codemem = {
7157
- command: "npx",
7158
- args: [
7159
- "-y",
7160
- "codemem",
7161
- "mcp"
7162
- ]
6987
+ command: launcher.command,
6988
+ args: launcher.args
7163
6989
  };
7164
6990
  settings.mcpServers = mcpServers;
7165
6991
  }
@@ -7182,25 +7008,47 @@ function installClaudeMcp(force) {
7182
7008
  return true;
7183
7009
  }
7184
7010
  /** The MCP server table appended to Codex config.toml. */
7185
- var CODEX_MCP_BLOCK = [
7186
- "[mcp_servers.codemem]",
7187
- "command = \"npx\"",
7188
- "args = [\"-y\", \"codemem\", \"mcp\"]",
7189
- "startup_timeout_sec = 30",
7190
- "tool_timeout_sec = 60"
7191
- ].join("\n");
7011
+ function codexMcpBlock() {
7012
+ const launcher = codememMcpLauncher();
7013
+ const args = launcher.args.map((arg) => JSON.stringify(arg)).join(", ");
7014
+ return [
7015
+ "[mcp_servers.codemem]",
7016
+ `command = ${JSON.stringify(launcher.command)}`,
7017
+ `args = [${args}]`,
7018
+ "startup_timeout_sec = 30",
7019
+ "tool_timeout_sec = 60"
7020
+ ].join("\n");
7021
+ }
7192
7022
  var CODEX_MCP_TABLE_RE = /^[ \t]*\[[ \t]*mcp_servers[ \t]*\.[ \t]*("?)codemem\1[ \t]*\]/m;
7023
+ function migrateManagedCodexMcp(existing) {
7024
+ const tableMatch = CODEX_MCP_TABLE_RE.exec(existing);
7025
+ if (!tableMatch || tableMatch.index == null) return null;
7026
+ const blockStart = tableMatch.index;
7027
+ const nextTable = /^[ \t]*\[/gm;
7028
+ nextTable.lastIndex = blockStart + tableMatch[0].length;
7029
+ const blockEnd = nextTable.exec(existing)?.index ?? existing.length;
7030
+ const block = existing.slice(blockStart, blockEnd);
7031
+ const commandPattern = /^([ \t]*command[ \t]*=[ \t]*)"npx"([ \t]*(?:#.*)?)$/m;
7032
+ const argsPattern = /^([ \t]*args[ \t]*=[ \t]*)\[[ \t]*"-y"[ \t]*,[ \t]*"codemem"[ \t]*,[ \t]*"mcp"[ \t]*\]([ \t]*(?:#.*)?)$/m;
7033
+ if (!commandPattern.test(block) || !argsPattern.test(block)) return null;
7034
+ const launcher = codememMcpLauncher();
7035
+ const args = launcher.args.map((arg) => JSON.stringify(arg)).join(", ");
7036
+ const migrated = block.replace(commandPattern, `$1${JSON.stringify(launcher.command)}$2`).replace(argsPattern, `$1[${args}]$2`);
7037
+ return `${existing.slice(0, blockStart)}${migrated}${existing.slice(blockEnd)}`;
7038
+ }
7193
7039
  /** Marker substring identifying codemem-owned hook commands. */
7194
7040
  var CODEMEM_HOOK_MARKER = "codemem codex-hook-";
7195
7041
  /**
7196
7042
  * Resolve how Codex hooks should invoke codemem. Prefer a direct `codemem` call
7197
- * when it's on PATH (fast — no per-hook resolution); fall back to `npx -y codemem`
7198
- * only when codemem isn't installed (e.g. setup was run via `npx codemem setup`),
7199
- * so capture/recall still work without a global install. Mirrors the plugin
7200
- * wrapper's `codemem`-first / `npx` fallback model.
7043
+ * when it's on PATH (fast — no per-hook resolution); fall back to an `npx`
7044
+ * invocation that requests both `codemem` and `@codemem/embeddings` when codemem
7045
+ * isn't installed (e.g. setup was run via `npx codemem setup`). The paired
7046
+ * runtime keeps the local-store inject path (codex-hook-inject) able to embed
7047
+ * instead of degrading to FTS. Mirrors the MCP launcher's two-package model.
7201
7048
  */
7202
7049
  function codememCodexHookBase() {
7203
- return codememOnPath() ? "codemem" : "npx -y codemem";
7050
+ if (codememOnPath(true)) return "codemem";
7051
+ return "npx -y --package codemem --package @codemem/embeddings codemem";
7204
7052
  }
7205
7053
  /**
7206
7054
  * Build the codemem-owned hook groups keyed by Codex event name, given the
@@ -7242,28 +7090,127 @@ function isCodememHookGroup(group) {
7242
7090
  return hooks.some((h) => h != null && typeof h === "object" && typeof h.command === "string" && h.command.includes(CODEMEM_HOOK_MARKER));
7243
7091
  }
7244
7092
  /**
7245
- * True if a resolved bin path is a transient npx/dlx cache bin. When setup runs
7246
- * via `npx -y codemem setup --codex-only`, npx exposes this package's bin on PATH for
7247
- * the duration of the run, then removes it — so Codex would later fail to find a
7248
- * bare `codemem`. Such paths must NOT count as "on PATH" for hook command baking.
7093
+ * True if a resolved bin path is a transient/project-local bin that will not be
7094
+ * on PATH for the globally-configured MCP hosts (OpenCode/Claude/Codex).
7095
+ *
7096
+ * - `_npx` / `.pnpm/dlx`: npx/dlx caches exposed only for the duration of a
7097
+ * `npx -y codemem setup` run, then removed.
7098
+ * - `node_modules/.bin`: a project-local install exposed only while setup runs
7099
+ * through `npm exec` / `pnpm exec` / a package script. The global hosts do not
7100
+ * inherit that project-local PATH, so baking a bare `codemem` command would
7101
+ * later fail to resolve.
7102
+ *
7103
+ * Such paths must NOT count as "on PATH" for launcher/hook command baking.
7249
7104
  */
7250
7105
  function isTransientNpxBinPath(resolved) {
7251
- return /[/\\]_npx[/\\]/.test(resolved) || /[/\\]\.pnpm[/\\]dlx[/\\]/.test(resolved);
7106
+ return /[/\\]_npx[/\\]/.test(resolved) || /[/\\]\.pnpm[/\\]dlx[/\\]/.test(resolved) || /[/\\]node_modules[/\\]\.bin[/\\]/.test(resolved);
7107
+ }
7108
+ /** Parse a semver core (major.minor.patch) plus optional prerelease tail. */
7109
+ function parseSetupSemver(version) {
7110
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-.+)?$/.exec(version.trim());
7111
+ if (!match) return null;
7112
+ return {
7113
+ core: [
7114
+ Number(match[1]),
7115
+ Number(match[2]),
7116
+ Number(match[3])
7117
+ ],
7118
+ prerelease: version.includes("-")
7119
+ };
7120
+ }
7121
+ /**
7122
+ * True when a durable `codemem --version` output should be trusted to launch
7123
+ * `codemem mcp` directly. Mirrors the OpenCode plugin's runner check: accept the
7124
+ * exact build version, or a clean (non-prerelease) release at least as new as
7125
+ * this build. This prevents baking a bare launcher that later resolves to an
7126
+ * older global install sitting alongside the transient npx bin.
7127
+ */
7128
+ function isDurableCodememVersionTrusted(versionOutput) {
7129
+ if (versionOutput === VERSION) return true;
7130
+ const candidate = parseSetupSemver(versionOutput);
7131
+ const current = parseSetupSemver(VERSION);
7132
+ if (!candidate || !current || candidate.prerelease) return false;
7133
+ for (let i = 0; i < 3; i++) {
7134
+ const c = candidate.core[i] ?? 0;
7135
+ const v = current.core[i] ?? 0;
7136
+ if (c > v) return true;
7137
+ if (c < v) return false;
7138
+ }
7139
+ return true;
7252
7140
  }
7253
7141
  /**
7254
7142
  * Detect whether a durable `codemem` resolves on PATH (excluding a transient
7255
- * npx/dlx bin that vanishes after this process exits).
7143
+ * npx/dlx bin that vanishes after this process exits). When `requireTrustedVersion`
7144
+ * is set, the durable binary must also report a version compatible with this
7145
+ * build before it is trusted, so setup never bakes a bare launcher that resolves
7146
+ * to a stale global install.
7256
7147
  */
7257
- function codememOnPath() {
7258
- try {
7259
- const resolved = execFileSync(process.platform === "win32" ? "where" : "which", ["codemem"], { encoding: "utf-8" }).split(/\r?\n/).map((line) => line.trim()).find(Boolean);
7260
- if (!resolved) return false;
7261
- return !isTransientNpxBinPath(resolved);
7148
+ function codememOnPath(requireTrustedVersion = false) {
7149
+ try {
7150
+ const command = process.platform === "win32" ? "where" : "which";
7151
+ const args = process.platform === "win32" ? ["codemem"] : ["-a", "codemem"];
7152
+ const durable = execFileSync(command, args, { encoding: "utf-8" }).split(/\r?\n/).map((line) => line.trim()).filter(Boolean).filter((candidate) => !isTransientNpxBinPath(candidate));
7153
+ if (durable.length === 0) return false;
7154
+ if (!requireTrustedVersion) return true;
7155
+ const [firstDurable] = durable;
7156
+ if (firstDurable == null) return false;
7157
+ const version = probeDurableCodememVersion(firstDurable);
7158
+ return version != null && isDurableCodememVersionTrusted(version);
7262
7159
  } catch {
7263
7160
  return false;
7264
7161
  }
7265
7162
  }
7266
7163
  /**
7164
+ * Run `<binary> --version`, returning trimmed stdout or null on failure. On
7165
+ * Windows a global npm install resolves to a `.cmd`/`.bat` shim that Node's
7166
+ * shell-less execFileSync cannot execute directly, so invoke it through
7167
+ * `cmd.exe /c` there; POSIX binaries run directly.
7168
+ */
7169
+ function probeDurableCodememVersion(binaryPath) {
7170
+ try {
7171
+ return (process.platform === "win32" ? execFileSync("cmd.exe", [
7172
+ "/c",
7173
+ binaryPath,
7174
+ "--version"
7175
+ ], {
7176
+ encoding: "utf-8",
7177
+ timeout: 3e3,
7178
+ stdio: [
7179
+ "ignore",
7180
+ "pipe",
7181
+ "ignore"
7182
+ ]
7183
+ }) : execFileSync(binaryPath, ["--version"], {
7184
+ encoding: "utf-8",
7185
+ timeout: 3e3,
7186
+ stdio: [
7187
+ "ignore",
7188
+ "pipe",
7189
+ "ignore"
7190
+ ]
7191
+ })).trim();
7192
+ } catch {
7193
+ return null;
7194
+ }
7195
+ }
7196
+ function codememMcpLauncher(onPath = codememOnPath(true)) {
7197
+ return onPath ? {
7198
+ command: "codemem",
7199
+ args: ["mcp"]
7200
+ } : {
7201
+ command: "npx",
7202
+ args: [
7203
+ "-y",
7204
+ "--package",
7205
+ "codemem",
7206
+ "--package",
7207
+ "@codemem/embeddings",
7208
+ "codemem",
7209
+ "mcp"
7210
+ ]
7211
+ };
7212
+ }
7213
+ /**
7267
7214
  * Append the codemem MCP server table to Codex config.toml without rewriting
7268
7215
  * unrelated content. Returns true on success.
7269
7216
  */
@@ -7271,6 +7218,20 @@ function installCodexMcp(codexHome, force) {
7271
7218
  const configPath = join(codexHome, "config.toml");
7272
7219
  const existing = existsSync(configPath) ? readFileSync(configPath, "utf-8") : "";
7273
7220
  if (CODEX_MCP_TABLE_RE.test(existing)) {
7221
+ const migrated = migrateManagedCodexMcp(existing);
7222
+ if (migrated != null) {
7223
+ try {
7224
+ copyFileSync(configPath, `${configPath}.codemem.bak`);
7225
+ } catch {}
7226
+ try {
7227
+ writeFileSync(configPath, migrated, "utf-8");
7228
+ p.log.success(`Codex MCP entry upgraded: ${configPath}`);
7229
+ return true;
7230
+ } catch (err) {
7231
+ p.log.error(`Failed to write ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
7232
+ return false;
7233
+ }
7234
+ }
7274
7235
  if (force) p.log.info(`Codex MCP entry already exists in ${configPath} — left as-is (TOML is not rewritten in place)`);
7275
7236
  else p.log.info(`Codex MCP entry already exists in ${configPath}`);
7276
7237
  return true;
@@ -7280,7 +7241,7 @@ function installCodexMcp(codexHome, force) {
7280
7241
  } catch {}
7281
7242
  let next = existing;
7282
7243
  if (next.length > 0 && !next.endsWith("\n\n")) next += next.endsWith("\n") ? "\n" : "\n\n";
7283
- next += `${CODEX_MCP_BLOCK}\n`;
7244
+ next += `${codexMcpBlock()}\n`;
7284
7245
  try {
7285
7246
  writeFileSync(configPath, next, "utf-8");
7286
7247
  p.log.success(`Codex MCP entry installed: ${configPath}`);
@@ -7307,12 +7268,38 @@ function installCodexHooks(codexHome, force) {
7307
7268
  let hooks = config.hooks;
7308
7269
  if (hooks == null || typeof hooks !== "object" || Array.isArray(hooks)) hooks = {};
7309
7270
  const ours = buildCodememCodexHookGroups(codememCodexHookBase());
7271
+ const currentCommands = new Set(Object.values(ours).flatMap((groups) => groups.flatMap((group) => group.hooks.map((hook) => hook.command))));
7272
+ const groupNeedsMigration = (group) => {
7273
+ if (!isCodememHookGroup(group)) return false;
7274
+ const hooksList = group.hooks;
7275
+ if (!Array.isArray(hooksList)) return false;
7276
+ return hooksList.some((hook) => {
7277
+ if (hook == null || typeof hook !== "object") return false;
7278
+ const command = hook.command;
7279
+ if (typeof command !== "string") return false;
7280
+ return command.includes(CODEMEM_HOOK_MARKER) && !currentCommands.has(command);
7281
+ });
7282
+ };
7310
7283
  let changed = false;
7284
+ const isCodememOwnedHook = (hook) => hook != null && typeof hook === "object" && typeof hook.command === "string" && hook.command.includes(CODEMEM_HOOK_MARKER);
7285
+ const stripCodememHooks = (group) => {
7286
+ if (!isCodememHookGroup(group)) return group;
7287
+ const hooksList = group.hooks;
7288
+ if (!Array.isArray(hooksList)) return null;
7289
+ const preservedHooks = hooksList.filter((hook) => !isCodememOwnedHook(hook));
7290
+ if (preservedHooks.length === 0) return null;
7291
+ return {
7292
+ ...group,
7293
+ hooks: preservedHooks
7294
+ };
7295
+ };
7311
7296
  for (const [event, ourGroups] of Object.entries(ours)) {
7312
7297
  const current = hooks[event];
7313
7298
  const existingGroups = Array.isArray(current) ? [...current] : [];
7314
- if (existingGroups.some(isCodememHookGroup) && !force) continue;
7315
- const preserved = existingGroups.filter((g) => !isCodememHookGroup(g));
7299
+ const hasCodemem = existingGroups.some(isCodememHookGroup);
7300
+ const needsMigration = existingGroups.some(groupNeedsMigration);
7301
+ if (hasCodemem && !force && !needsMigration) continue;
7302
+ const preserved = existingGroups.map((group) => stripCodememHooks(group)).filter((group) => group != null);
7316
7303
  hooks[event] = [...preserved, ...ourGroups];
7317
7304
  changed = true;
7318
7305
  }
@@ -7348,8 +7335,11 @@ function installCodex(force) {
7348
7335
  p.log.error(`Failed to create Codex home ${codexHome}: ${err instanceof Error ? err.message : String(err)}`);
7349
7336
  return false;
7350
7337
  }
7351
- if (codememOnPath()) p.log.info("Codex hooks will call `codemem` directly (found on PATH).");
7352
- else p.log.info("`codemem` is not on PATH, so Codex hooks will run via `npx -y codemem` (works without a global install). For lower hook latency: npm i -g codemem");
7338
+ if (codememOnPath(true)) p.log.info("Codex hooks will call `codemem` directly (found on PATH).");
7339
+ else {
7340
+ const globalInstallCommand = process.platform === "linux" ? "env ONNXRUNTIME_NODE_INSTALL=skip npm install -g codemem @codemem/embeddings" : "npm install -g codemem @codemem/embeddings";
7341
+ p.log.info(`\`codemem\` is not on PATH, so Codex hooks will run via \`npx -y --package codemem --package @codemem/embeddings codemem\` (works without a global install). For lower hook latency: ${globalInstallCommand}`);
7342
+ }
7353
7343
  let ok = true;
7354
7344
  ok = installCodexMcp(codexHome, force) && ok;
7355
7345
  ok = installCodexHooks(codexHome, force) && ok;
@@ -7479,7 +7469,7 @@ var statsCommand = statsCmd.action((opts) => {
7479
7469
  //#region src/commands/status.ts
7480
7470
  var MAX_ATTENTION = 20;
7481
7471
  var DEFAULT_VIEWER_PORT = 38888;
7482
- var RECENT_FAILURE_WINDOW_MS = 1440 * 60 * 1e3;
7472
+ var RECENT_FAILURE_WINDOW_MS = 864e5;
7483
7473
  var OBSERVER_IDENTITY_KEYS = [
7484
7474
  "observer_provider",
7485
7475
  "observer_model",
@@ -8626,7 +8616,8 @@ bootstrapCmd.action(async (peerArg, opts) => {
8626
8616
  process.exitCode = 2;
8627
8617
  return;
8628
8618
  }
8629
- const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
8619
+ const dbPath = resolveDbPath(resolveDbOpt(opts));
8620
+ const store = new MemoryStore(dbPath);
8630
8621
  try {
8631
8622
  const pageSize = Math.max(1, Number.parseInt(opts.pageSize, 10) || 2e3);
8632
8623
  const keysDir = opts.keysDir ?? void 0;
@@ -8797,7 +8788,7 @@ syncCoordinatorAlias.hook("preAction", (_thisCmd, actionCmd) => {
8797
8788
  syncCommand.addCommand(syncCoordinatorAlias, { hidden: true });
8798
8789
  //#endregion
8799
8790
  //#region src/commands/update.ts
8800
- var INSTALL_TIMEOUT_MS = 300 * 1e3;
8791
+ var INSTALL_TIMEOUT_MS = 42e4;
8801
8792
  var VERIFY_TIMEOUT_MS = 3e4;
8802
8793
  var PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org/";
8803
8794
  var UPDATE_LOCK_FILE = "update-install.lock";
@@ -8819,7 +8810,7 @@ async function removeStaleInstallLock(lockPath) {
8819
8810
  const pid = Number.parseInt(rawPid.trim(), 10);
8820
8811
  if (Number.isSafeInteger(pid) && pid > 0) {
8821
8812
  if (isProcessAlive(pid)) return false;
8822
- } else if (Date.now() - lockStat.mtimeMs <= 33e4) return false;
8813
+ } else if (Date.now() - lockStat.mtimeMs <= 45e4) return false;
8823
8814
  await unlink(lockPath);
8824
8815
  return true;
8825
8816
  } catch {
@@ -8850,6 +8841,7 @@ function runCommand(command, args, timeoutMs, options = {}) {
8850
8841
  const child = spawn(command, args, {
8851
8842
  cwd: options.cwd,
8852
8843
  detached: process.platform !== "win32",
8844
+ env: options.env,
8853
8845
  shell: false,
8854
8846
  stdio: [
8855
8847
  "ignore",
@@ -8983,12 +8975,13 @@ function renderHumanStatus(status) {
8983
8975
  }
8984
8976
  var checkCommand = addJsonOption(new Command("check").description("Check for a newer stable codemem release")).addOption(new Option("-r, --refresh", "bypass the six-hour release cache")).configureHelp(helpStyle).action(async (options) => {
8985
8977
  try {
8978
+ const installKind = detectInstallKind({
8979
+ entryPath: process.argv[1] ?? "",
8980
+ env: process.env
8981
+ });
8986
8982
  const status = await getUpdateStatus({
8987
8983
  currentVersion: VERSION,
8988
- installKind: detectInstallKind({
8989
- entryPath: process.argv[1] ?? "",
8990
- env: process.env
8991
- }),
8984
+ installKind,
8992
8985
  refresh: options.refresh
8993
8986
  });
8994
8987
  if (status.latest_version === null) {
@@ -9014,15 +9007,29 @@ var checkCommand = addJsonOption(new Command("check").description("Check for a n
9014
9007
  }
9015
9008
  }
9016
9009
  });
9017
- var installCommand = addJsonOption(new Command("install").description("Install an eligible stable codemem update")).configureHelp(helpStyle).action(async (options) => {
9010
+ function updateInstallArgs(npmArgs, targetVersion) {
9011
+ const installArgs = [
9012
+ "install",
9013
+ "-g",
9014
+ "--registry",
9015
+ PUBLIC_NPM_REGISTRY,
9016
+ `--@codemem:registry=${PUBLIC_NPM_REGISTRY}`,
9017
+ `codemem@${targetVersion}`,
9018
+ `@codemem/embeddings@${targetVersion}`
9019
+ ];
9020
+ if (process.platform !== "win32") return installArgs;
9021
+ return [...npmArgs.slice(0, 3), windowsCommandLine(npmArgs[3] ?? "", installArgs)];
9022
+ }
9023
+ async function installUpdate(options) {
9018
9024
  let releaseInstallLock = null;
9019
9025
  try {
9026
+ const installKind = detectInstallKind({
9027
+ entryPath: process.argv[1] ?? "",
9028
+ env: process.env
9029
+ });
9020
9030
  const status = await getUpdateStatus({
9021
9031
  currentVersion: VERSION,
9022
- installKind: detectInstallKind({
9023
- entryPath: process.argv[1] ?? "",
9024
- env: process.env
9025
- }),
9032
+ installKind,
9026
9033
  refresh: true
9027
9034
  });
9028
9035
  if (!status.auto_update_eligible || !status.latest_version) {
@@ -9036,20 +9043,12 @@ var installCommand = addJsonOption(new Command("install").description("Install a
9036
9043
  }
9037
9044
  releaseInstallLock = await acquireInstallLock();
9038
9045
  const npm = await resolveInstallCommand();
9039
- const installation = await runCommand(npm.command, process.platform === "win32" ? [...npm.args.slice(0, 3), windowsCommandLine(npm.args[3] ?? "", [
9040
- "install",
9041
- "-g",
9042
- "--registry",
9043
- PUBLIC_NPM_REGISTRY,
9044
- `codemem@${targetVersion}`
9045
- ])] : [
9046
- "install",
9047
- "-g",
9048
- "--registry",
9049
- PUBLIC_NPM_REGISTRY,
9050
- `codemem@${targetVersion}`
9051
- ], INSTALL_TIMEOUT_MS, {
9046
+ const installation = await runCommand(npm.command, updateInstallArgs(npm.args, targetVersion), INSTALL_TIMEOUT_MS, {
9052
9047
  cwd: npm.cwd,
9048
+ env: process.platform === "linux" ? {
9049
+ ...process.env,
9050
+ ONNXRUNTIME_NODE_INSTALL: "skip"
9051
+ } : void 0,
9053
9052
  windowsVerbatimArguments: process.platform === "win32"
9054
9053
  });
9055
9054
  if (installation.exitCode !== 0) {
@@ -9076,8 +9075,9 @@ var installCommand = addJsonOption(new Command("install").description("Install a
9076
9075
  } finally {
9077
9076
  await releaseInstallLock?.();
9078
9077
  }
9079
- });
9080
- var updateCommand = new Command("update").description("Inspect and manage codemem updates").configureHelp(helpStyle).addCommand(checkCommand).addCommand(installCommand);
9078
+ }
9079
+ var installCommand = addJsonOption(new Command("install").description("Install an eligible stable codemem update")).configureHelp(helpStyle).action(installUpdate);
9080
+ var updateCommand = new Command("update").description("Inspect and manage codemem updates").enablePositionalOptions().configureHelp(helpStyle).addCommand(checkCommand).addCommand(installCommand);
9081
9081
  //#endregion
9082
9082
  //#region src/commands/version.ts
9083
9083
  var versionCommand = new Command("version").configureHelp(helpStyle).description("Print codemem version").action(() => {