nexusmem 0.10.1 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -606,13 +606,41 @@ async function hookStatus(target) {
606
606
  }
607
607
 
608
608
  // src/hooks/install-git-precommit.ts
609
- import { chmod, mkdir as mkdir3, readFile as readFile3, unlink, writeFile as writeFile3 } from "fs/promises";
610
- import { dirname as dirname2, join as join4 } from "path";
609
+ import { join as join4 } from "path";
610
+
611
+ // src/hooks/git-hook-snippet.ts
612
+ var SHEBANG = "#!/bin/sh";
613
+ function isHookInstalled2(content, markers) {
614
+ return content.includes(markers.markStart);
615
+ }
616
+ function isForeignHook(content, markers) {
617
+ return content.trim().length > 0 && !isHookInstalled2(content, markers);
618
+ }
619
+ function stripHookSnippet2(content, markers) {
620
+ const startIdx = content.indexOf(markers.markStart);
621
+ const endIdx = content.indexOf(markers.markEnd);
622
+ if (startIdx === -1 || endIdx === -1) return content;
623
+ const afterBlock = content.slice(endIdx + markers.markEnd.length).replace(/^\r?\n/, "");
624
+ return content.slice(0, startIdx) + afterBlock;
625
+ }
626
+ function upsertHookSnippet2(content, markers, renderHookSnippet4) {
627
+ const stripped = stripHookSnippet2(content, markers).replace(/\s+$/, "");
628
+ const prefix = stripped.length > 0 ? `${stripped}
629
+
630
+ ` : "";
631
+ return `${prefix}${renderHookSnippet4()}`;
632
+ }
633
+ function ensureShebang(content) {
634
+ if (content.startsWith("#!")) return content;
635
+ return content.length > 0 ? `${SHEBANG}
636
+ ${content}` : `${SHEBANG}
637
+ `;
638
+ }
611
639
 
612
640
  // src/hooks/git-pre-commit.ts
613
641
  var MARK_START2 = "# >>> nexusmem precommit hook >>>";
614
642
  var MARK_END2 = "# <<< nexusmem precommit hook <<<";
615
- var SHEBANG = "#!/bin/sh";
643
+ var MARKERS = { markStart: MARK_START2, markEnd: MARK_END2 };
616
644
  function renderHookSnippet2() {
617
645
  return [
618
646
  MARK_START2,
@@ -627,47 +655,23 @@ function renderHookSnippet2() {
627
655
  ""
628
656
  ].join("\n");
629
657
  }
630
- function isHookInstalled2(content) {
631
- return content.includes(MARK_START2);
658
+ function isHookInstalled3(content) {
659
+ return isHookInstalled2(content, MARKERS);
632
660
  }
633
- function isForeignHook(content) {
634
- return content.trim().length > 0 && !isHookInstalled2(content);
661
+ function isForeignHook2(content) {
662
+ return isForeignHook(content, MARKERS);
635
663
  }
636
- function stripHookSnippet2(content) {
637
- const startIdx = content.indexOf(MARK_START2);
638
- const endIdx = content.indexOf(MARK_END2);
639
- if (startIdx === -1 || endIdx === -1) return content;
640
- const afterBlock = content.slice(endIdx + MARK_END2.length).replace(/^\r?\n/, "");
641
- return content.slice(0, startIdx) + afterBlock;
664
+ function stripHookSnippet3(content) {
665
+ return stripHookSnippet2(content, MARKERS);
642
666
  }
643
- function upsertHookSnippet2(content) {
644
- const stripped = stripHookSnippet2(content).replace(/\s+$/, "");
645
- const prefix = stripped.length > 0 ? `${stripped}
646
-
647
- ` : "";
648
- return `${prefix}${renderHookSnippet2()}`;
649
- }
650
- function ensureShebang(content) {
651
- if (content.startsWith("#!")) return content;
652
- return content.length > 0 ? `${SHEBANG}
653
- ${content}` : `${SHEBANG}
654
- `;
667
+ function upsertHookSnippet3(content) {
668
+ return upsertHookSnippet2(content, MARKERS, renderHookSnippet2);
655
669
  }
670
+ var ensureShebang2 = ensureShebang;
656
671
 
657
- // src/hooks/install-git-precommit.ts
658
- var ForeignGitHookError = class extends Error {
659
- constructor(hookPath) {
660
- super(
661
- `${hookPath} already has a pre-commit hook NexusMem did not install. Pass --force to append nexusmem's check to the end of it, or integrate manually.`
662
- );
663
- this.hookPath = hookPath;
664
- this.name = "ForeignGitHookError";
665
- }
666
- hookPath;
667
- };
668
- function resolveGitHookTarget(repoRoot) {
669
- return { hookPath: join4(repoRoot, ".git", "hooks", "pre-commit") };
670
- }
672
+ // src/hooks/git-hook-install.ts
673
+ import { chmod, mkdir as mkdir3, readFile as readFile3, unlink, writeFile as writeFile3 } from "fs/promises";
674
+ import { dirname as dirname2 } from "path";
671
675
  async function readHook(path) {
672
676
  try {
673
677
  return await readFile3(path, "utf8");
@@ -675,14 +679,14 @@ async function readHook(path) {
675
679
  return "";
676
680
  }
677
681
  }
678
- async function installGitHook(target, opts = {}) {
682
+ async function installGitHookGeneric(target, kind, opts = {}) {
679
683
  const current = await readHook(target.hookPath);
680
- const alreadyInstalled = isHookInstalled2(current);
681
- const foreign = isForeignHook(current);
684
+ const alreadyInstalled = kind.isHookInstalled(current);
685
+ const foreign = kind.isForeignHook(current);
682
686
  if (foreign && !alreadyInstalled && !opts.force) {
683
- throw new ForeignGitHookError(target.hookPath);
687
+ throw kind.createForeignError(target.hookPath);
684
688
  }
685
- const next = upsertHookSnippet2(ensureShebang(current));
689
+ const next = kind.upsertHookSnippet(kind.ensureShebang(current));
686
690
  if (next === current) return { changed: false, alreadyInstalled, appendedToForeign: false };
687
691
  await mkdir3(dirname2(target.hookPath), { recursive: true });
688
692
  await writeFile3(target.hookPath, next, "utf8");
@@ -690,11 +694,11 @@ async function installGitHook(target, opts = {}) {
690
694
  });
691
695
  return { changed: true, alreadyInstalled, appendedToForeign: foreign && !alreadyInstalled };
692
696
  }
693
- async function removeGitHook(target) {
697
+ async function removeGitHookGeneric(target, kind) {
694
698
  const current = await readHook(target.hookPath);
695
- if (!isHookInstalled2(current)) return { changed: false };
696
- const stripped = stripHookSnippet2(current).trim();
697
- if (stripped === "" || stripped === SHEBANG) {
699
+ if (!kind.isHookInstalled(current)) return { changed: false };
700
+ const stripped = kind.stripHookSnippet(current).trim();
701
+ if (stripped === "" || stripped === kind.SHEBANG) {
698
702
  await unlink(target.hookPath).catch(() => {
699
703
  });
700
704
  } else {
@@ -703,9 +707,114 @@ async function removeGitHook(target) {
703
707
  }
704
708
  return { changed: true };
705
709
  }
706
- async function gitHookStatus(target) {
710
+ async function gitHookStatusGeneric(target, kind) {
707
711
  const current = await readHook(target.hookPath);
708
- return { installed: isHookInstalled2(current), foreign: isForeignHook(current) };
712
+ return { installed: kind.isHookInstalled(current), foreign: kind.isForeignHook(current) };
713
+ }
714
+
715
+ // src/hooks/install-git-precommit.ts
716
+ var ForeignGitHookError = class extends Error {
717
+ constructor(hookPath) {
718
+ super(
719
+ `${hookPath} already has a pre-commit hook NexusMem did not install. Pass --force to append nexusmem's check to the end of it, or integrate manually.`
720
+ );
721
+ this.hookPath = hookPath;
722
+ this.name = "ForeignGitHookError";
723
+ }
724
+ hookPath;
725
+ };
726
+ var KIND = {
727
+ isHookInstalled: isHookInstalled3,
728
+ isForeignHook: isForeignHook2,
729
+ stripHookSnippet: stripHookSnippet3,
730
+ upsertHookSnippet: upsertHookSnippet3,
731
+ ensureShebang: ensureShebang2,
732
+ SHEBANG,
733
+ createForeignError: (hookPath) => new ForeignGitHookError(hookPath)
734
+ };
735
+ function resolveGitHookTarget(repoRoot) {
736
+ return { hookPath: join4(repoRoot, ".git", "hooks", "pre-commit") };
737
+ }
738
+ async function installGitHook(target, opts = {}) {
739
+ return installGitHookGeneric(target, KIND, opts);
740
+ }
741
+ async function removeGitHook(target) {
742
+ return removeGitHookGeneric(target, KIND);
743
+ }
744
+ async function gitHookStatus(target) {
745
+ return gitHookStatusGeneric(target, KIND);
746
+ }
747
+
748
+ // src/hooks/install-git-postcommit.ts
749
+ import { join as join5 } from "path";
750
+
751
+ // src/hooks/git-post-commit.ts
752
+ var MARK_START3 = "# >>> nexusmem postcommit hook >>>";
753
+ var MARK_END3 = "# <<< nexusmem postcommit hook <<<";
754
+ var MARKERS2 = { markStart: MARK_START3, markEnd: MARK_END3 };
755
+ var LOG_PATH = ".nexusmem/post-commit-sync.log";
756
+ var LOG_TRUNCATE_THRESHOLD = 2e3;
757
+ function renderHookSnippet3() {
758
+ return [
759
+ MARK_START3,
760
+ "# Runs a full `nexusmem sync` (including embedding) in the background after",
761
+ "# each commit -- detached, so this never makes `git commit` itself wait.",
762
+ `# Output goes to ${LOG_PATH} (reset once it passes ${LOG_TRUNCATE_THRESHOLD} lines), not the terminal.`,
763
+ "# Installed by: nexusmem hook git-post install",
764
+ "# Remove with: nexusmem hook git-post remove",
765
+ "if command -v nexusmem >/dev/null 2>&1; then",
766
+ " mkdir -p .nexusmem",
767
+ ` nohup sh -c '[ "$(wc -l <${LOG_PATH} 2>/dev/null || echo 0)" -gt ${LOG_TRUNCATE_THRESHOLD} ] && : >${LOG_PATH}; nexusmem sync --quiet --auto >>${LOG_PATH} 2>&1' >/dev/null 2>&1 &`,
768
+ "fi",
769
+ MARK_END3,
770
+ ""
771
+ ].join("\n");
772
+ }
773
+ function isHookInstalled4(content) {
774
+ return isHookInstalled2(content, MARKERS2);
775
+ }
776
+ function isForeignHook3(content) {
777
+ return isForeignHook(content, MARKERS2);
778
+ }
779
+ function stripHookSnippet4(content) {
780
+ return stripHookSnippet2(content, MARKERS2);
781
+ }
782
+ function upsertHookSnippet4(content) {
783
+ return upsertHookSnippet2(content, MARKERS2, renderHookSnippet3);
784
+ }
785
+ var ensureShebang3 = ensureShebang;
786
+
787
+ // src/hooks/install-git-postcommit.ts
788
+ var ForeignPostCommitHookError = class extends Error {
789
+ constructor(hookPath) {
790
+ super(
791
+ `${hookPath} already has a post-commit hook NexusMem did not install. Pass --force to append nexusmem's sync to the end of it, or integrate manually.`
792
+ );
793
+ this.hookPath = hookPath;
794
+ this.name = "ForeignPostCommitHookError";
795
+ }
796
+ hookPath;
797
+ };
798
+ var KIND2 = {
799
+ isHookInstalled: isHookInstalled4,
800
+ isForeignHook: isForeignHook3,
801
+ stripHookSnippet: stripHookSnippet4,
802
+ upsertHookSnippet: upsertHookSnippet4,
803
+ ensureShebang: ensureShebang3,
804
+ SHEBANG,
805
+ createForeignError: (hookPath) => new ForeignPostCommitHookError(hookPath)
806
+ };
807
+ function resolvePostCommitHookTarget(repoRoot) {
808
+ return { hookPath: join5(repoRoot, ".git", "hooks", "post-commit") };
809
+ }
810
+ async function installPostCommitGitHook(target, opts = {}) {
811
+ return installGitHookGeneric(target, KIND2, opts);
812
+ }
813
+ async function removePostCommitGitHook(target) {
814
+ return removeGitHookGeneric(target, KIND2);
815
+ }
816
+ async function postCommitGitHookStatus(target) {
817
+ return gitHookStatusGeneric(target, KIND2);
709
818
  }
710
819
 
711
820
  // src/store/deny-list.ts
@@ -2106,7 +2215,47 @@ async function runHookGitStatus(opts) {
2106
2215
  const repo = await readRepoInfo(opts.cwd);
2107
2216
  const target = resolveGitHookTarget(repo.root);
2108
2217
  const result = await gitHookStatus(target);
2109
- const statusLabel = result.installed ? pc2.green("installed") : result.foreign ? pc2.yellow("a foreign hook exists (not nexusmem) -- install --force to append") : pc2.yellow("not installed");
2218
+ const statusLabel = result.installed ? pc2.green("installed") : result.foreign ? pc2.yellow("a foreign hook exists (not nexusmem) -- nexusmem hook git install --force to append") : pc2.yellow("not installed");
2219
+ process.stdout.write([`${pc2.dim("hook ")} ${target.hookPath}`, `${pc2.dim("status")} ${statusLabel}`, ""].join("\n"));
2220
+ return 0;
2221
+ }
2222
+ async function runHookGitPostInstall(opts) {
2223
+ const repo = await readRepoInfo(opts.cwd);
2224
+ const target = resolvePostCommitHookTarget(repo.root);
2225
+ const result = await installPostCommitGitHook(target, { force: opts.force });
2226
+ const lines = [
2227
+ result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} git post-commit hook` : `${pc2.dim("already up to date")}`,
2228
+ ` hook ${target.hookPath}`
2229
+ ];
2230
+ if (result.appendedToForeign) {
2231
+ lines.push(` ${pc2.yellow("appended after an existing post-commit hook -- review")} ${target.hookPath}`);
2232
+ }
2233
+ lines.push(
2234
+ "",
2235
+ `Runs a full ${pc2.bold("nexusmem sync")} (including embedding) in the background after each commit --`,
2236
+ `detached, so it never makes ${pc2.bold("git commit")} itself wait. Output goes to .nexusmem/post-commit-sync.log.`,
2237
+ `Run ${pc2.bold("nexusmem hook git-post remove")} to undo this.`,
2238
+ ""
2239
+ );
2240
+ process.stdout.write(lines.join("\n"));
2241
+ return 0;
2242
+ }
2243
+ async function runHookGitPostRemove(opts) {
2244
+ const repo = await readRepoInfo(opts.cwd);
2245
+ const target = resolvePostCommitHookTarget(repo.root);
2246
+ const result = await removePostCommitGitHook(target);
2247
+ process.stdout.write(
2248
+ result.changed ? `${pc2.green("removed")} nexusmem's block from ${target.hookPath}
2249
+ ` : `${pc2.dim("nothing to remove")} \u2014 no nexusmem block found in ${target.hookPath}
2250
+ `
2251
+ );
2252
+ return 0;
2253
+ }
2254
+ async function runHookGitPostStatus(opts) {
2255
+ const repo = await readRepoInfo(opts.cwd);
2256
+ const target = resolvePostCommitHookTarget(repo.root);
2257
+ const result = await postCommitGitHookStatus(target);
2258
+ const statusLabel = result.installed ? pc2.green("installed") : result.foreign ? pc2.yellow("a foreign hook exists (not nexusmem) -- nexusmem hook git-post install --force to append") : pc2.yellow("not installed");
2110
2259
  process.stdout.write([`${pc2.dim("hook ")} ${target.hookPath}`, `${pc2.dim("status")} ${statusLabel}`, ""].join("\n"));
2111
2260
  return 0;
2112
2261
  }
@@ -2161,7 +2310,7 @@ import pc4 from "picocolors";
2161
2310
  // src/config/registry.ts
2162
2311
  import { existsSync as existsSync2 } from "fs";
2163
2312
  import { mkdir as mkdir4, readFile as readFile5, rename, writeFile as writeFile5 } from "fs/promises";
2164
- import { join as join5 } from "path";
2313
+ import { join as join6 } from "path";
2165
2314
  import { z as z3 } from "zod";
2166
2315
  var ENTRY_SCHEMA = z3.object({
2167
2316
  projectId: z3.string().min(1),
@@ -2176,7 +2325,7 @@ var REGISTRY_SCHEMA = z3.object({
2176
2325
  projects: z3.array(ENTRY_SCHEMA).default([])
2177
2326
  });
2178
2327
  function registryPath() {
2179
- return join5(globalWorkspaceDir(), "projects.json");
2328
+ return join6(globalWorkspaceDir(), "projects.json");
2180
2329
  }
2181
2330
  async function readRegistry() {
2182
2331
  let raw;
@@ -3989,8 +4138,8 @@ function scoreShellCommand(entry) {
3989
4138
  else if (cmd.length <= 3) score -= 0.05;
3990
4139
  return Number(Math.min(1, Math.max(0.05, score)).toFixed(3));
3991
4140
  }
3992
- function renderBody2(entry, maxChars) {
3993
- const parts = [`$ ${entry.command}`];
4141
+ function renderBody2(entry, command, maxChars) {
4142
+ const parts = [`$ ${command}`];
3994
4143
  const metaLine = [];
3995
4144
  if (entry.cwd) metaLine.push(`cwd: ${entry.cwd}`);
3996
4145
  if (entry.exitCode !== null) metaLine.push(`exit: ${entry.exitCode}`);
@@ -4000,7 +4149,8 @@ function renderBody2(entry, maxChars) {
4000
4149
  }
4001
4150
  function toMemoryNode3(entry, projectId, opts = {}) {
4002
4151
  const maxBody = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS5;
4003
- const titleLine = entry.command.split(/\r?\n/)[0] ?? entry.command;
4152
+ const { text: redactedCommand } = redact(entry.command);
4153
+ const titleLine = redactedCommand.split(/\r?\n/)[0] ?? redactedCommand;
4004
4154
  return {
4005
4155
  id: makeNodeId(projectId, "shell_command", entry.naturalKey),
4006
4156
  kind: "shell_command",
@@ -4008,7 +4158,7 @@ function toMemoryNode3(entry, projectId, opts = {}) {
4008
4158
  ts: entry.ts,
4009
4159
  source: `shell:${entry.shell}`,
4010
4160
  title: truncate(titleLine, MAX_TITLE_CHARS7),
4011
- body: renderBody2(entry, maxBody),
4161
+ body: renderBody2(entry, redactedCommand, maxBody),
4012
4162
  files: [],
4013
4163
  signal: scoreShellCommand(entry),
4014
4164
  provenance: "observed",
@@ -4034,18 +4184,18 @@ import { basename as basename2 } from "path";
4034
4184
  import { existsSync as existsSync3 } from "fs";
4035
4185
  import { readdir } from "fs/promises";
4036
4186
  import { homedir as homedir3 } from "os";
4037
- import { join as join6 } from "path";
4187
+ import { join as join7 } from "path";
4038
4188
  function claudeProjectSlug(repoRoot) {
4039
4189
  return repoRoot.replace(/[\\/:]/g, "-");
4040
4190
  }
4041
4191
  function claudeProjectTranscriptDir(repoRoot) {
4042
- return join6(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
4192
+ return join7(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
4043
4193
  }
4044
4194
  async function listTranscriptFiles(repoRoot) {
4045
4195
  const dir = claudeProjectTranscriptDir(repoRoot);
4046
4196
  if (!existsSync3(dir)) return [];
4047
4197
  const entries = await readdir(dir, { withFileTypes: true });
4048
- return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join6(dir, e.name));
4198
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join7(dir, e.name));
4049
4199
  }
4050
4200
 
4051
4201
  // src/conversation/claude-code-reader.ts
@@ -4128,7 +4278,7 @@ async function collectClaudeCodeTranscripts(repoRoot) {
4128
4278
 
4129
4279
  // src/docs/read.ts
4130
4280
  import { readFile as readFile7, stat } from "fs/promises";
4131
- import { join as join7 } from "path";
4281
+ import { join as join8 } from "path";
4132
4282
  var DEFAULT_PATHSPECS = ["*.md"];
4133
4283
  async function listDocFiles(repoRoot, opts = {}) {
4134
4284
  const pathspecs = opts.include ?? DEFAULT_PATHSPECS;
@@ -4141,7 +4291,7 @@ async function readDocFiles(repoRoot, opts = {}) {
4141
4291
  const unreadable = [];
4142
4292
  for (const relPath of paths) {
4143
4293
  const path = relPath.replace(/\\/g, "/");
4144
- const absPath = join7(repoRoot, relPath);
4294
+ const absPath = join8(repoRoot, relPath);
4145
4295
  let content;
4146
4296
  let mtime;
4147
4297
  try {
@@ -4671,7 +4821,7 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
4671
4821
 
4672
4822
  // src/structure/collect.ts
4673
4823
  import { readFile as readFile10 } from "fs/promises";
4674
- import { join as join8 } from "path";
4824
+ import { join as join9 } from "path";
4675
4825
 
4676
4826
  // src/structure/extract.ts
4677
4827
  var IMPORT_PATTERNS = [
@@ -5110,7 +5260,7 @@ async function collectFileEdges(repoRoot) {
5110
5260
  const trackedPaths = new Set(paths);
5111
5261
  let goModulePath = null;
5112
5262
  try {
5113
- goModulePath = parseGoModulePath(await readFile10(join8(repoRoot, "go.mod"), "utf8"));
5263
+ goModulePath = parseGoModulePath(await readFile10(join9(repoRoot, "go.mod"), "utf8"));
5114
5264
  } catch {
5115
5265
  goModulePath = null;
5116
5266
  }
@@ -5120,7 +5270,7 @@ async function collectFileEdges(repoRoot) {
5120
5270
  for (const path of paths) {
5121
5271
  let content;
5122
5272
  try {
5123
- content = await readFile10(join8(repoRoot, path), "utf8");
5273
+ content = await readFile10(join9(repoRoot, path), "utf8");
5124
5274
  } catch {
5125
5275
  unreadable.push(path);
5126
5276
  continue;
@@ -5209,6 +5359,52 @@ ${node.body}`)
5209
5359
  };
5210
5360
  }
5211
5361
 
5362
+ // src/cli/sync-lock.ts
5363
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs";
5364
+ import { join as join10 } from "path";
5365
+ function lockPath(wsDir) {
5366
+ return join10(wsDir, "sync.lock");
5367
+ }
5368
+ function readOwner(path) {
5369
+ try {
5370
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
5371
+ const pid = parsed?.pid;
5372
+ return typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? parsed : null;
5373
+ } catch {
5374
+ return null;
5375
+ }
5376
+ }
5377
+ function isPidAlive(pid) {
5378
+ try {
5379
+ process.kill(pid, 0);
5380
+ return true;
5381
+ } catch (err) {
5382
+ return err.code !== "ESRCH";
5383
+ }
5384
+ }
5385
+ function acquireSyncLock(wsDir) {
5386
+ mkdirSync2(wsDir, { recursive: true });
5387
+ const path = lockPath(wsDir);
5388
+ for (; ; ) {
5389
+ try {
5390
+ writeFileSync(path, `${JSON.stringify({ pid: process.pid })}
5391
+ `, { flag: "wx" });
5392
+ return { release: () => tryUnlink(path) };
5393
+ } catch (err) {
5394
+ if (err.code !== "EEXIST") throw err;
5395
+ }
5396
+ const owner = readOwner(path);
5397
+ if (owner && isPidAlive(owner.pid)) return null;
5398
+ tryUnlink(path);
5399
+ }
5400
+ }
5401
+ function tryUnlink(path) {
5402
+ try {
5403
+ unlinkSync(path);
5404
+ } catch {
5405
+ }
5406
+ }
5407
+
5212
5408
  // src/cli/commands/sync.ts
5213
5409
  var BATCH_SIZE = 500;
5214
5410
  var PROGRESS_THRESHOLD = 200;
@@ -5525,6 +5721,15 @@ async function runSync(opts) {
5525
5721
  `);
5526
5722
  };
5527
5723
  const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
5724
+ let lock = null;
5725
+ if (opts.auto) {
5726
+ lock = acquireSyncLock(ws.dir);
5727
+ if (!lock) {
5728
+ out(`${pc7.dim("auto-sync")} another sync is already running for this project -- skipping
5729
+ `);
5730
+ return 0;
5731
+ }
5732
+ }
5528
5733
  const store = MemoryStore.open(ws.dbPath);
5529
5734
  const started = Date.now();
5530
5735
  try {
@@ -5634,6 +5839,7 @@ async function runSync(opts) {
5634
5839
  return 0;
5635
5840
  } finally {
5636
5841
  store.close();
5842
+ lock?.release();
5637
5843
  }
5638
5844
  }
5639
5845
 
@@ -5735,6 +5941,44 @@ async function getStatus(input) {
5735
5941
  store.close();
5736
5942
  }
5737
5943
  }
5944
+ async function listStaleSuggestions(input) {
5945
+ const repo = await readRepoInfo(input.projectRoot);
5946
+ const ws = resolveWorkspace(repo.root);
5947
+ const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5948
+ const store = MemoryStore.open(ws.dbPath);
5949
+ try {
5950
+ return { suggestions: store.listContradictionSuggestions(projectId, { limit: input.limit }) };
5951
+ } finally {
5952
+ store.close();
5953
+ }
5954
+ }
5955
+ async function resolveStaleSuggestion(input) {
5956
+ if (input.action === "dismiss") {
5957
+ const repo = await readRepoInfo(input.projectRoot);
5958
+ const ws = resolveWorkspace(repo.root);
5959
+ const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5960
+ const store = MemoryStore.open(ws.dbPath);
5961
+ try {
5962
+ const dismissed = store.dismissContradictionSuggestion(projectId, input.candidateId);
5963
+ return {
5964
+ summary: dismissed > 0 ? `dismissed the contradiction suggestion for ${input.candidateId}` : `no open contradiction suggestion for ${input.candidateId}`
5965
+ };
5966
+ } finally {
5967
+ store.close();
5968
+ }
5969
+ }
5970
+ if (!input.againstId) {
5971
+ throw new Error("againstId is required to accept a stale suggestion");
5972
+ }
5973
+ const chunks = [];
5974
+ await runMarkStale({
5975
+ cwd: input.projectRoot,
5976
+ nodeId: input.candidateId,
5977
+ supersedesId: input.againstId,
5978
+ out: (chunk2) => chunks.push(chunk2)
5979
+ });
5980
+ return { summary: stripAnsi(chunks.join("").trim()) };
5981
+ }
5738
5982
 
5739
5983
  // src/mcp/server.ts
5740
5984
  function createServer() {
@@ -5824,6 +6068,41 @@ function createServer() {
5824
6068
  };
5825
6069
  }
5826
6070
  );
6071
+ server.registerTool(
6072
+ "list_stale_suggestions",
6073
+ {
6074
+ title: "List open contradiction suggestions",
6075
+ description: 'List open contradiction verdicts for a NexusMem-tracked repository -- candidates flagged by "nexusmem stale --check-contradictions" (or automatically during sync) as likely superseded by a newer, similar node. Nothing has been written yet; use resolve_stale_suggestion to act on one.',
6076
+ inputSchema: {
6077
+ projectRoot: z4.string().describe("Absolute path to the repository root"),
6078
+ limit: z4.number().int().positive().optional().describe("Max suggestions to return, most recently judged first. Default 50.")
6079
+ }
6080
+ },
6081
+ async ({ projectRoot, limit }) => {
6082
+ const result = await listStaleSuggestions({ projectRoot, limit });
6083
+ return {
6084
+ content: [{ type: "text", text: JSON.stringify(result.suggestions, null, 2) }],
6085
+ structuredContent: { suggestions: result.suggestions }
6086
+ };
6087
+ }
6088
+ );
6089
+ server.registerTool(
6090
+ "resolve_stale_suggestion",
6091
+ {
6092
+ title: "Accept or dismiss a contradiction suggestion",
6093
+ description: '"accept" writes a supersede link from candidateId to againstId (the same effect as "nexusmem mark-stale") -- the ranker down-weights candidateId from then on but never deletes it. "dismiss" silences the suggestion without changing ranking, so it stops resurfacing on future stale/sync runs.',
6094
+ inputSchema: {
6095
+ projectRoot: z4.string().describe("Absolute path to the repository root"),
6096
+ candidateId: z4.string().describe("Id of the stale/superseded node"),
6097
+ action: z4.enum(["accept", "dismiss"]).describe("Whether to write the supersede link or silence the suggestion"),
6098
+ againstId: z4.string().optional().describe('Id of the superseding node. Required for "accept", ignored for "dismiss".')
6099
+ }
6100
+ },
6101
+ async ({ projectRoot, candidateId, action, againstId }) => {
6102
+ const result = await resolveStaleSuggestion({ projectRoot, candidateId, action, againstId });
6103
+ return { content: [{ type: "text", text: result.summary }] };
6104
+ }
6105
+ );
5827
6106
  return server;
5828
6107
  }
5829
6108
  async function runMcpServer() {
@@ -6652,7 +6931,7 @@ function isExpected(err) {
6652
6931
  // the user fixes, not stack traces they debug.
6653
6932
  err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
6654
6933
  // (antivirus, a bad install). Actionable, and not our stack to print.
6655
- err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof DenyListError || err instanceof MarkStaleError || err instanceof QueryError || err instanceof ReviewError;
6934
+ err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof ForeignPostCommitHookError || err instanceof DenyListError || err instanceof MarkStaleError || err instanceof QueryError || err instanceof ReviewError;
6656
6935
  }
6657
6936
  function guard(run) {
6658
6937
  return async () => {
@@ -6688,7 +6967,11 @@ program.command("sync").description("Ingest new history into the local database"
6688
6967
  "--link-failures",
6689
6968
  "opt-in (experimental): after ingest, link failed shell commands to whatever later resolved them",
6690
6969
  false
6691
- ).option("-q, --quiet", "only print the final summary", false).action(
6970
+ ).option("-q, --quiet", "only print the final summary", false).option(
6971
+ "--auto",
6972
+ "used by the post-commit hook: skip (instead of running) if another --auto sync already holds this project's lock -- a manually-run sync never checks it",
6973
+ false
6974
+ ).action(
6692
6975
  (options) => guard(
6693
6976
  () => runSync({
6694
6977
  cwd: options.cwd,
@@ -6704,7 +6987,8 @@ program.command("sync").description("Ingest new history into the local database"
6704
6987
  pruneStaleShell: options.pruneStaleShell,
6705
6988
  yes: options.yes,
6706
6989
  linkFailures: options.linkFailures,
6707
- quiet: options.quiet
6990
+ quiet: options.quiet,
6991
+ auto: options.auto
6708
6992
  })
6709
6993
  )()
6710
6994
  );
@@ -6722,6 +7006,14 @@ program.command("hook").description("Manage the opt-in PowerShell hook that logs
6722
7006
  ).addCommand(
6723
7007
  new Command("status").description("Show whether the git pre-commit hook is installed").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runHookGitStatus({ cwd: options.cwd }))())
6724
7008
  )
7009
+ ).addCommand(
7010
+ new Command("git-post").description("Manage the opt-in git post-commit hook that runs a full `nexusmem sync` (with embedding) after each commit").addCommand(
7011
+ new Command("install").description("Install (or update) the hook in .git/hooks/post-commit").option("-C, --cwd <path>", "repository path", process.cwd()).option("--force", "append after an existing foreign post-commit hook instead of refusing", false).action((options) => guard(() => runHookGitPostInstall({ cwd: options.cwd, force: options.force }))())
7012
+ ).addCommand(
7013
+ new Command("remove").description("Remove nexusmem's block from .git/hooks/post-commit").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runHookGitPostRemove({ cwd: options.cwd }))())
7014
+ ).addCommand(
7015
+ new Command("status").description("Show whether the git post-commit hook is installed").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runHookGitPostStatus({ cwd: options.cwd }))())
7016
+ )
6725
7017
  );
6726
7018
  program.command("status").description("Show what is currently remembered for this repository").option("-C, --cwd <path>", "repository path", process.cwd()).option("--share", "print a plain-text summary formatted for sharing, e.g. on X or Reddit").action((options) => guard(() => runStatus({ cwd: options.cwd, share: options.share }))());
6727
7019
  program.command("query").description("Search remembered history and print a token-budgeted context block").argument("<text>", "free-text query").option("-C, --cwd <path>", "repository path", process.cwd()).option("-b, --budget <tokens>", "max tokens in the packed context", (v) => Number.parseInt(v, 10), 2e3).option("-n, --candidates <count>", "how many search hits to rank before packing", (v) => Number.parseInt(v, 10), 30).option("--half-life <days>", "days for a node's recency weight to halve", (v) => Number.parseFloat(v)).option("--no-vector", "BM25 only -- skip embedding the query and vector search").option("-a, --all-projects", "search every registered repository, not just this one", false).option("--as-of <date>", 'bi-temporal read: only nodes recorded at or before this date -- "what did the store hold then", not "what happened then"').option("--json", "emit the packed result as JSON on stdout", false).action(