nexusmem 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli/index.ts
4
4
  import { Command } from "commander";
5
- import pc14 from "picocolors";
5
+ import pc17 from "picocolors";
6
6
 
7
7
  // src/config/workspace.ts
8
8
  import { existsSync } from "fs";
@@ -147,7 +147,15 @@ var ConfigSchema = z.object({
147
147
  maxCommits: z.number().int().positive().default(200),
148
148
  maxFilesPerCommit: z.number().int().positive().default(20),
149
149
  contextLines: z.number().int().nonnegative().default(3)
150
- }).default({ enabled: true, maxCommits: 200, maxFilesPerCommit: 20, contextLines: 3 })
150
+ }).default({ enabled: true, maxCommits: 200, maxFilesPerCommit: 20, contextLines: 3 }),
151
+ /**
152
+ * JS/TS import-graph edges (`file_edges` table), derived from the
153
+ * working tree. On by default like git/shell/docs: no secrets risk,
154
+ * just structural relationships between files already tracked in git.
155
+ */
156
+ structure: z.object({
157
+ enabled: z.boolean().default(true)
158
+ }).default({ enabled: true })
151
159
  }).default({
152
160
  git: { enabled: true, since: null, includeMerges: true },
153
161
  shell: { enabled: true, tailLines: 300 },
@@ -160,7 +168,8 @@ var ConfigSchema = z.object({
160
168
  maxPromptChars: 12e3
161
169
  },
162
170
  docs: { enabled: true, include: ["*.md"] },
163
- diff: { enabled: true, maxCommits: 200, maxFilesPerCommit: 20, contextLines: 3 }
171
+ diff: { enabled: true, maxCommits: 200, maxFilesPerCommit: 20, contextLines: 3 },
172
+ structure: { enabled: true }
164
173
  }),
165
174
  limits: z.object({
166
175
  maxFilesPerNode: z.number().int().positive().default(40),
@@ -549,20 +558,165 @@ async function hookStatus(target) {
549
558
  return { installed: isHookInstalled(current) };
550
559
  }
551
560
 
552
- // src/cli/commands/hook.ts
561
+ // src/hooks/install-git-precommit.ts
562
+ import { chmod, mkdir as mkdir3, readFile as readFile3, unlink, writeFile as writeFile3 } from "fs/promises";
563
+ import { dirname as dirname2, join as join4 } from "path";
564
+
565
+ // src/hooks/git-pre-commit.ts
566
+ var MARK_START2 = "# >>> nexusmem precommit hook >>>";
567
+ var MARK_END2 = "# <<< nexusmem precommit hook <<<";
568
+ var SHEBANG = "#!/bin/sh";
569
+ function renderHookSnippet2() {
570
+ return [
571
+ MARK_START2,
572
+ "# Runs `nexusmem precheck` before each commit -- advisory only, never",
573
+ "# blocks a commit on its own (this hook does not pass --strict).",
574
+ "# Installed by: nexusmem hook git install",
575
+ "# Remove with: nexusmem hook git remove",
576
+ "if command -v nexusmem >/dev/null 2>&1; then",
577
+ " nexusmem precheck",
578
+ "fi",
579
+ MARK_END2,
580
+ ""
581
+ ].join("\n");
582
+ }
583
+ function isHookInstalled2(content) {
584
+ return content.includes(MARK_START2);
585
+ }
586
+ function isForeignHook(content) {
587
+ return content.trim().length > 0 && !isHookInstalled2(content);
588
+ }
589
+ function stripHookSnippet2(content) {
590
+ const startIdx = content.indexOf(MARK_START2);
591
+ const endIdx = content.indexOf(MARK_END2);
592
+ if (startIdx === -1 || endIdx === -1) return content;
593
+ const afterBlock = content.slice(endIdx + MARK_END2.length).replace(/^\r?\n/, "");
594
+ return content.slice(0, startIdx) + afterBlock;
595
+ }
596
+ function upsertHookSnippet2(content) {
597
+ const stripped = stripHookSnippet2(content).replace(/\s+$/, "");
598
+ const prefix = stripped.length > 0 ? `${stripped}
599
+
600
+ ` : "";
601
+ return `${prefix}${renderHookSnippet2()}`;
602
+ }
603
+ function ensureShebang(content) {
604
+ if (content.startsWith("#!")) return content;
605
+ return content.length > 0 ? `${SHEBANG}
606
+ ${content}` : `${SHEBANG}
607
+ `;
608
+ }
609
+
610
+ // src/hooks/install-git-precommit.ts
611
+ var ForeignGitHookError = class extends Error {
612
+ constructor(hookPath) {
613
+ super(
614
+ `${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.`
615
+ );
616
+ this.hookPath = hookPath;
617
+ this.name = "ForeignGitHookError";
618
+ }
619
+ hookPath;
620
+ };
621
+ function resolveGitHookTarget(repoRoot) {
622
+ return { hookPath: join4(repoRoot, ".git", "hooks", "pre-commit") };
623
+ }
624
+ async function readHook(path) {
625
+ try {
626
+ return await readFile3(path, "utf8");
627
+ } catch {
628
+ return "";
629
+ }
630
+ }
631
+ async function installGitHook(target, opts = {}) {
632
+ const current = await readHook(target.hookPath);
633
+ const alreadyInstalled = isHookInstalled2(current);
634
+ const foreign = isForeignHook(current);
635
+ if (foreign && !alreadyInstalled && !opts.force) {
636
+ throw new ForeignGitHookError(target.hookPath);
637
+ }
638
+ const next = upsertHookSnippet2(ensureShebang(current));
639
+ if (next === current) return { changed: false, alreadyInstalled, appendedToForeign: false };
640
+ await mkdir3(dirname2(target.hookPath), { recursive: true });
641
+ await writeFile3(target.hookPath, next, "utf8");
642
+ await chmod(target.hookPath, 493).catch(() => {
643
+ });
644
+ return { changed: true, alreadyInstalled, appendedToForeign: foreign && !alreadyInstalled };
645
+ }
646
+ async function removeGitHook(target) {
647
+ const current = await readHook(target.hookPath);
648
+ if (!isHookInstalled2(current)) return { changed: false };
649
+ const stripped = stripHookSnippet2(current).trim();
650
+ if (stripped === "" || stripped === SHEBANG) {
651
+ await unlink(target.hookPath).catch(() => {
652
+ });
653
+ } else {
654
+ await writeFile3(target.hookPath, `${stripped}
655
+ `, "utf8");
656
+ }
657
+ return { changed: true };
658
+ }
659
+ async function gitHookStatus(target) {
660
+ const current = await readHook(target.hookPath);
661
+ return { installed: isHookInstalled2(current), foreign: isForeignHook(current) };
662
+ }
663
+
664
+ // src/cli/commands/hook-git.ts
553
665
  import pc from "picocolors";
666
+ async function runHookGitInstall(opts) {
667
+ const repo = await readRepoInfo(opts.cwd);
668
+ const target = resolveGitHookTarget(repo.root);
669
+ const result = await installGitHook(target, { force: opts.force });
670
+ const lines = [
671
+ result.changed ? `${pc.green(result.alreadyInstalled ? "updated" : "installed")} git pre-commit hook` : `${pc.dim("already up to date")}`,
672
+ ` hook ${target.hookPath}`
673
+ ];
674
+ if (result.appendedToForeign) {
675
+ lines.push(` ${pc.yellow("appended after an existing pre-commit hook -- review")} ${target.hookPath}`);
676
+ }
677
+ lines.push(
678
+ "",
679
+ `Runs ${pc.bold("nexusmem precheck")} before each commit -- advisory only, never blocks a commit on its own.`,
680
+ `Run ${pc.bold("nexusmem hook git remove")} to undo this.`,
681
+ ""
682
+ );
683
+ process.stdout.write(lines.join("\n"));
684
+ return 0;
685
+ }
686
+ async function runHookGitRemove(opts) {
687
+ const repo = await readRepoInfo(opts.cwd);
688
+ const target = resolveGitHookTarget(repo.root);
689
+ const result = await removeGitHook(target);
690
+ process.stdout.write(
691
+ result.changed ? `${pc.green("removed")} nexusmem's block from ${target.hookPath}
692
+ ` : `${pc.dim("nothing to remove")} \u2014 no nexusmem block found in ${target.hookPath}
693
+ `
694
+ );
695
+ return 0;
696
+ }
697
+ async function runHookGitStatus(opts) {
698
+ const repo = await readRepoInfo(opts.cwd);
699
+ const target = resolveGitHookTarget(repo.root);
700
+ const result = await gitHookStatus(target);
701
+ const statusLabel = result.installed ? pc.green("installed") : result.foreign ? pc.yellow("a foreign hook exists (not nexusmem) -- install --force to append") : pc.yellow("not installed");
702
+ process.stdout.write([`${pc.dim("hook ")} ${target.hookPath}`, `${pc.dim("status")} ${statusLabel}`, ""].join("\n"));
703
+ return 0;
704
+ }
705
+
706
+ // src/cli/commands/hook.ts
707
+ import pc2 from "picocolors";
554
708
  async function runHookInstall(opts) {
555
709
  const target = await resolveHookTarget(opts.profile, opts.logPath);
556
710
  const result = await installHook(target);
557
711
  process.stdout.write(
558
712
  [
559
- result.changed ? `${pc.green(result.alreadyInstalled ? "updated" : "installed")} shell hook` : `${pc.dim("already up to date")}`,
713
+ result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} shell hook` : `${pc2.dim("already up to date")}`,
560
714
  ` profile ${target.profilePath}`,
561
715
  ` log ${target.logPath}`,
562
716
  "",
563
717
  `New commands in any PowerShell session using this profile will now log their timestamp, cwd and exit code.`,
564
718
  `Open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect.`,
565
- `Run ${pc.bold("nexusmem hook remove")} to undo this.`,
719
+ `Run ${pc2.bold("nexusmem hook remove")} to undo this.`,
566
720
  ""
567
721
  ].join("\n")
568
722
  );
@@ -572,8 +726,8 @@ async function runHookRemove(opts) {
572
726
  const target = await resolveHookTarget(opts.profile, opts.logPath);
573
727
  const result = await removeHook(target);
574
728
  process.stdout.write(
575
- result.changed ? `${pc.green("removed")} shell hook from ${target.profilePath}
576
- ` : `${pc.dim("nothing to remove")} \u2014 no hook block found in ${target.profilePath}
729
+ result.changed ? `${pc2.green("removed")} shell hook from ${target.profilePath}
730
+ ` : `${pc2.dim("nothing to remove")} \u2014 no hook block found in ${target.profilePath}
577
731
  `
578
732
  );
579
733
  return 0;
@@ -583,9 +737,9 @@ async function runHookStatus(opts) {
583
737
  const result = await hookStatus(target);
584
738
  process.stdout.write(
585
739
  [
586
- `${pc.dim("profile")} ${target.profilePath}`,
587
- `${pc.dim("log ")} ${target.logPath}`,
588
- `${pc.dim("status ")} ${result.installed ? pc.green("installed") : pc.yellow("not installed")}`,
740
+ `${pc2.dim("profile")} ${target.profilePath}`,
741
+ `${pc2.dim("log ")} ${target.logPath}`,
742
+ `${pc2.dim("status ")} ${result.installed ? pc2.green("installed") : pc2.yellow("not installed")}`,
589
743
  ""
590
744
  ].join("\n")
591
745
  );
@@ -594,12 +748,12 @@ async function runHookStatus(opts) {
594
748
 
595
749
  // src/cli/commands/init.ts
596
750
  import { relative } from "path";
597
- import pc2 from "picocolors";
751
+ import pc3 from "picocolors";
598
752
 
599
753
  // src/config/registry.ts
600
754
  import { existsSync as existsSync2 } from "fs";
601
- import { mkdir as mkdir3, readFile as readFile3, rename, writeFile as writeFile3 } from "fs/promises";
602
- import { join as join4 } from "path";
755
+ import { mkdir as mkdir4, readFile as readFile4, rename, writeFile as writeFile4 } from "fs/promises";
756
+ import { join as join5 } from "path";
603
757
  import { z as z2 } from "zod";
604
758
  var ENTRY_SCHEMA = z2.object({
605
759
  projectId: z2.string().min(1),
@@ -614,12 +768,12 @@ var REGISTRY_SCHEMA = z2.object({
614
768
  projects: z2.array(ENTRY_SCHEMA).default([])
615
769
  });
616
770
  function registryPath() {
617
- return join4(globalWorkspaceDir(), "projects.json");
771
+ return join5(globalWorkspaceDir(), "projects.json");
618
772
  }
619
773
  async function readRegistry() {
620
774
  let raw;
621
775
  try {
622
- raw = await readFile3(registryPath(), "utf8");
776
+ raw = await readFile4(registryPath(), "utf8");
623
777
  } catch {
624
778
  return [];
625
779
  }
@@ -658,8 +812,8 @@ async function forgetProjects(projectIds) {
658
812
  async function writeRegistry(projects) {
659
813
  const path = registryPath();
660
814
  const tmp = `${path}.${process.pid}.tmp`;
661
- await mkdir3(globalWorkspaceDir(), { recursive: true });
662
- await writeFile3(tmp, `${JSON.stringify({ version: 1, projects }, null, 2)}
815
+ await mkdir4(globalWorkspaceDir(), { recursive: true });
816
+ await writeFile4(tmp, `${JSON.stringify({ version: 1, projects }, null, 2)}
663
817
  `, "utf8");
664
818
  await rename(tmp, path);
665
819
  }
@@ -693,7 +847,7 @@ function makeProjectId({ root, originUrl }) {
693
847
  // src/store/store.ts
694
848
  import Database from "better-sqlite3";
695
849
  import { mkdirSync } from "fs";
696
- import { dirname as dirname2 } from "path";
850
+ import { dirname as dirname3 } from "path";
697
851
  import * as sqliteVec from "sqlite-vec";
698
852
 
699
853
  // src/store/fts.ts
@@ -710,11 +864,6 @@ function toMatchQuery(input) {
710
864
  if (kept.length === 0) return null;
711
865
  return kept.map((t) => `"${t}"*`).join(" OR ");
712
866
  }
713
- function toStrictMatchQuery(input) {
714
- const kept = significantTokens(input);
715
- if (kept.length === 0) return null;
716
- return kept.map((t) => `"${t}"*`).join(" AND ");
717
- }
718
867
 
719
868
  // src/store/schema.ts
720
869
  var V1 = `
@@ -825,10 +974,29 @@ CREATE TABLE node_links (
825
974
  -- direction yet, so only the forward lookup gets an index.
826
975
  CREATE INDEX idx_node_links_from ON node_links (from_node_id);
827
976
  `;
977
+ var V4 = `
978
+ -- File-to-file structural relationships (currently: JS/TS import edges),
979
+ -- derived from the working tree rather than from any node's content. A
980
+ -- source file is not a node, so this cannot reuse node_links (both of its
981
+ -- columns FK to nodes.id) -- project_id has to be stored here explicitly
982
+ -- since there is no node to join through for it.
983
+ CREATE TABLE file_edges (
984
+ project_id TEXT NOT NULL,
985
+ from_path TEXT NOT NULL,
986
+ to_path TEXT NOT NULL,
987
+ kind TEXT NOT NULL,
988
+ PRIMARY KEY (project_id, from_path, to_path, kind)
989
+ );
990
+
991
+ -- "What imports this file" (e.g. blast-radius of a change) is the query a
992
+ -- future feature needs; the primary key already covers the forward direction.
993
+ CREATE INDEX idx_file_edges_to ON file_edges (project_id, to_path);
994
+ `;
828
995
  var MIGRATIONS = [
829
996
  { version: 1, up: (db) => db.exec(V1) },
830
997
  { version: 2, up: (db) => db.exec(V2) },
831
- { version: 3, up: (db) => db.exec(V3) }
998
+ { version: 3, up: (db) => db.exec(V3) },
999
+ { version: 4, up: (db) => db.exec(V4) }
832
1000
  ];
833
1001
  var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
834
1002
  function currentSchemaVersion(db) {
@@ -857,7 +1025,7 @@ var MemoryStore = class _MemoryStore {
857
1025
  }
858
1026
  db;
859
1027
  static open(dbPath) {
860
- mkdirSync(dirname2(dbPath), { recursive: true });
1028
+ mkdirSync(dirname3(dbPath), { recursive: true });
861
1029
  const db = new Database(dbPath);
862
1030
  db.pragma("journal_mode = WAL");
863
1031
  db.pragma("synchronous = NORMAL");
@@ -893,6 +1061,13 @@ var MemoryStore = class _MemoryStore {
893
1061
  (r) => r.id
894
1062
  );
895
1063
  }
1064
+ /** Total nodes held under the given project identities. */
1065
+ countProjectNodes(projectIds) {
1066
+ if (projectIds.length === 0) return 0;
1067
+ const placeholders = projectIds.map(() => "?").join(", ");
1068
+ const row = this.db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE project_id IN (${placeholders})`).get(...projectIds);
1069
+ return row.n;
1070
+ }
896
1071
  /**
897
1072
  * Write a batch of nodes in one transaction.
898
1073
  *
@@ -1096,6 +1271,29 @@ var MemoryStore = class _MemoryStore {
1096
1271
  return this.db.prepare(`DELETE FROM nodes WHERE ${scope}`).run(params).changes;
1097
1272
  })();
1098
1273
  }
1274
+ /**
1275
+ * Replace this project's entire `file_edges` snapshot in one transaction.
1276
+ *
1277
+ * Edges describe the current working tree, not history -- unlike
1278
+ * `pruneSourceNodes`'s incremental diff-against-a-scan, there is no cursor
1279
+ * to walk, so every `scan-structure`/sync run is a full rescan and this is
1280
+ * always a delete-then-insert of the whole set, never a partial update.
1281
+ */
1282
+ replaceFileEdges(projectId, edges) {
1283
+ this.db.transaction(() => {
1284
+ this.db.prepare("DELETE FROM file_edges WHERE project_id = ?").run(projectId);
1285
+ const insert = this.db.prepare(
1286
+ "INSERT OR IGNORE INTO file_edges (project_id, from_path, to_path, kind) VALUES (?, ?, ?, ?)"
1287
+ );
1288
+ for (const edge of edges) insert.run(projectId, edge.fromPath, edge.toPath, edge.kind);
1289
+ })();
1290
+ }
1291
+ /** Edge count + distinct source-file count, for `nexusmem status`'s `structure` line. */
1292
+ fileEdgeStats(projectId) {
1293
+ const edges = this.db.prepare("SELECT COUNT(*) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
1294
+ const files = this.db.prepare("SELECT COUNT(DISTINCT from_path) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
1295
+ return { edges, files };
1296
+ }
1099
1297
  /**
1100
1298
  * Nodes for this project that have no embedding yet (new, or invalidated
1101
1299
  * by a content change).
@@ -1219,9 +1417,9 @@ async function runInit(opts) {
1219
1417
  if (already && !opts.force) {
1220
1418
  const existing = await readConfig(ws);
1221
1419
  process.stderr.write(
1222
- `${pc2.yellow("already initialized")} ${relative(process.cwd(), ws.configPath) || ws.configPath}
1223
- project ${pc2.cyan(existing.projectId)}
1224
- use ${pc2.bold("--force")} to reset the config (the database is kept)
1420
+ `${pc3.yellow("already initialized")} ${relative(process.cwd(), ws.configPath) || ws.configPath}
1421
+ project ${pc3.cyan(existing.projectId)}
1422
+ use ${pc3.bold("--force")} to reset the config (the database is kept)
1225
1423
  `
1226
1424
  );
1227
1425
  return 0;
@@ -1238,14 +1436,14 @@ async function runInit(opts) {
1238
1436
  }
1239
1437
  await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
1240
1438
  const lines = [
1241
- `${pc2.green("initialized")} ${ws.dir}`,
1242
- ` project ${pc2.cyan(projectId)}`,
1439
+ `${pc3.green("initialized")} ${ws.dir}`,
1440
+ ` project ${pc3.cyan(projectId)}`,
1243
1441
  ` repo ${repo.root}`,
1244
- ` branch ${repo.branch ?? pc2.yellow("(detached)")}`,
1442
+ ` branch ${repo.branch ?? pc3.yellow("(detached)")}`,
1245
1443
  ` schema v${LATEST_SCHEMA_VERSION}`
1246
1444
  ];
1247
1445
  if (opts.enableConversation) {
1248
- lines.push(` ${pc2.yellow("conversation source enabled")} -- transcripts will be redacted-but-indexed on sync`);
1446
+ lines.push(` ${pc3.yellow("conversation source enabled")} -- transcripts will be redacted-but-indexed on sync`);
1249
1447
  }
1250
1448
  if (opts.hook) {
1251
1449
  try {
@@ -1253,26 +1451,26 @@ async function runInit(opts) {
1253
1451
  const result = await installHook(target);
1254
1452
  lines.push(
1255
1453
  "",
1256
- `${pc2.green(result.changed ? "installed" : "already installed")} shell hook`,
1454
+ `${pc3.green(result.changed ? "installed" : "already installed")} shell hook`,
1257
1455
  ` profile ${target.profilePath}`,
1258
1456
  ` log ${target.logPath}`,
1259
1457
  ` open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect`
1260
1458
  );
1261
1459
  } catch (err) {
1262
1460
  if (err instanceof ProfileNotFoundError) {
1263
- lines.push("", `${pc2.yellow("hook not installed")} ${err.message}`);
1461
+ lines.push("", `${pc3.yellow("hook not installed")} ${err.message}`);
1264
1462
  } else {
1265
1463
  throw err;
1266
1464
  }
1267
1465
  }
1268
1466
  }
1269
- lines.push("", `Next: ${pc2.bold("nexusmem sync")}`, "");
1467
+ lines.push("", `Next: ${pc3.bold("nexusmem sync")}`, "");
1270
1468
  out(lines.join("\n"));
1271
1469
  return 0;
1272
1470
  }
1273
1471
 
1274
1472
  // src/cli/commands/projects.ts
1275
- import pc3 from "picocolors";
1473
+ import pc4 from "picocolors";
1276
1474
  async function runProjects(opts) {
1277
1475
  const { entries, missing } = await readLiveRegistry();
1278
1476
  const rows = entries.map((entry) => {
@@ -1291,7 +1489,7 @@ async function runProjects(opts) {
1291
1489
  });
1292
1490
  if (opts.prune) {
1293
1491
  const removed = await forgetProjects(missing.map((entry) => entry.projectId));
1294
- process.stderr.write(`${pc3.yellow("pruned")} ${removed} project(s) whose database is gone
1492
+ process.stderr.write(`${pc4.yellow("pruned")} ${removed} project(s) whose database is gone
1295
1493
  `);
1296
1494
  }
1297
1495
  if (opts.json) {
@@ -1299,28 +1497,28 @@ async function runProjects(opts) {
1299
1497
  `);
1300
1498
  return 0;
1301
1499
  }
1302
- process.stderr.write(`${pc3.dim("registry")} ${registryPath()}
1500
+ process.stderr.write(`${pc4.dim("registry")} ${registryPath()}
1303
1501
 
1304
1502
  `);
1305
1503
  if (rows.length === 0) {
1306
- process.stderr.write(`${pc3.yellow("no projects registered")} -- run ${pc3.bold("nexusmem sync")} in a repository
1504
+ process.stderr.write(`${pc4.yellow("no projects registered")} -- run ${pc4.bold("nexusmem sync")} in a repository
1307
1505
  `);
1308
1506
  return 0;
1309
1507
  }
1310
1508
  for (const row of rows) {
1311
1509
  const seen = new Date(row.lastSeenAt).toISOString().slice(0, 16).replace("T", " ");
1312
- const count = row.nodes === null ? pc3.yellow("unreadable") : `${row.nodes} node(s)`;
1313
- process.stdout.write(`${pc3.cyan(row.projectId.slice(0, 8))} ${row.root}
1314
- ${pc3.dim(`${count}, last seen ${seen}`)}
1510
+ const count = row.nodes === null ? pc4.yellow("unreadable") : `${row.nodes} node(s)`;
1511
+ process.stdout.write(`${pc4.cyan(row.projectId.slice(0, 8))} ${row.root}
1512
+ ${pc4.dim(`${count}, last seen ${seen}`)}
1315
1513
  `);
1316
1514
  }
1317
1515
  if (!opts.prune && missing.length > 0) {
1318
1516
  process.stderr.write(
1319
1517
  `
1320
- ${pc3.yellow(`${missing.length} registered project(s) have no database on disk`)} ${pc3.dim("-- run with --prune to forget them")}
1518
+ ${pc4.yellow(`${missing.length} registered project(s) have no database on disk`)} ${pc4.dim("-- run with --prune to forget them")}
1321
1519
  `
1322
1520
  );
1323
- for (const entry of missing) process.stderr.write(` ${pc3.dim(entry.root)}
1521
+ for (const entry of missing) process.stderr.write(` ${pc4.dim(entry.root)}
1324
1522
  `);
1325
1523
  }
1326
1524
  return 0;
@@ -1521,6 +1719,23 @@ var RESOLVED_BY_DISCUSSION = "resolved_by:discussion";
1521
1719
  function normalizeCommand(command) {
1522
1720
  return command.trim().replace(/\s+/g, " ").toLowerCase();
1523
1721
  }
1722
+ var MAX_TOKEN_DOC_FREQUENCY = 0.2;
1723
+ var MIN_CORPUS_FOR_FREQUENCY_FILTER = 10;
1724
+ var DEFAULT_BOILERPLATE_KINDS = ["conversation_turn", "session_summary"];
1725
+ function filterBoilerplateTokens(db, projectId, tokens, kinds = DEFAULT_BOILERPLATE_KINDS) {
1726
+ if (tokens.length === 0) return tokens;
1727
+ const kindsPlaceholder = kinds.map(() => "?").join(", ");
1728
+ const total = db.prepare(`SELECT COUNT(*) AS c FROM nodes WHERE project_id = ? AND kind IN (${kindsPlaceholder})`).get(projectId, ...kinds).c;
1729
+ if (total < MIN_CORPUS_FOR_FREQUENCY_FILTER) return tokens;
1730
+ const countMatching = db.prepare(
1731
+ `SELECT COUNT(*) AS c FROM nodes_fts JOIN nodes n ON n.rowid = nodes_fts.rowid
1732
+ WHERE nodes_fts MATCH ? AND n.project_id = ? AND n.kind IN (${kindsPlaceholder})`
1733
+ );
1734
+ return tokens.filter((t) => {
1735
+ const matching = countMatching.get(`"${t}"*`, projectId, ...kinds).c;
1736
+ return matching / total <= MAX_TOKEN_DOC_FREQUENCY;
1737
+ });
1738
+ }
1524
1739
  function correlateFailures(store, projectId, opts = {}) {
1525
1740
  const retryWindowMs = opts.retryWindowMs ?? DEFAULT_RETRY_WINDOW_MS;
1526
1741
  const discussionWindowMs = opts.discussionWindowMs ?? DEFAULT_DISCUSSION_WINDOW_MS;
@@ -1565,7 +1780,8 @@ function correlateFailures(store, projectId, opts = {}) {
1565
1780
  store.linkNodes(failure.id, retry.id, RESOLVED_BY_RETRY);
1566
1781
  linkedByRetry += 1;
1567
1782
  }
1568
- const match = toStrictMatchQuery(failure.command);
1783
+ const tokens = filterBoilerplateTokens(db, projectId, significantTokens(failure.command));
1784
+ const match = tokens.length > 0 ? tokens.map((t) => `"${t}"*`).join(" AND ") : null;
1569
1785
  if (match) {
1570
1786
  const discussion = findDiscussion.get(match, projectId, failure.ts_epoch, failure.ts_epoch + discussionWindowMs);
1571
1787
  if (discussion) {
@@ -1576,6 +1792,25 @@ function correlateFailures(store, projectId, opts = {}) {
1576
1792
  }
1577
1793
  return { failuresExamined: failures.length, linkedByRetry, linkedByDiscussion };
1578
1794
  }
1795
+ function getChainStats(store, projectId) {
1796
+ const db = store.raw;
1797
+ const failuresTotal = db.prepare(
1798
+ `SELECT COUNT(*) AS c FROM nodes
1799
+ WHERE project_id = ? AND kind = 'shell_command'
1800
+ AND json_extract(meta, '$.exitCode') IS NOT NULL AND json_extract(meta, '$.exitCode') != 0`
1801
+ ).get(projectId).c;
1802
+ const countDistinctLinked = (relations) => db.prepare(
1803
+ `SELECT COUNT(DISTINCT nl.from_node_id) AS c
1804
+ FROM node_links nl JOIN nodes n ON n.id = nl.from_node_id
1805
+ WHERE n.project_id = ? AND nl.relation IN (${relations.map(() => "?").join(", ")})`
1806
+ ).get(projectId, ...relations).c;
1807
+ return {
1808
+ failuresTotal,
1809
+ resolvedByRetry: countDistinctLinked([RESOLVED_BY_RETRY]),
1810
+ resolvedByDiscussion: countDistinctLinked([RESOLVED_BY_DISCUSSION]),
1811
+ resolvedTotal: countDistinctLinked([RESOLVED_BY_RETRY, RESOLVED_BY_DISCUSSION])
1812
+ };
1813
+ }
1579
1814
 
1580
1815
  // src/retrieval/fuse.ts
1581
1816
  var RRF_K = 60;
@@ -1833,7 +2068,7 @@ var OllamaEmbeddingProvider = class {
1833
2068
  };
1834
2069
 
1835
2070
  // src/cli/commands/sync.ts
1836
- import pc4 from "picocolors";
2071
+ import pc5 from "picocolors";
1837
2072
 
1838
2073
  // src/conversation/chunk.ts
1839
2074
  var HEADING_LINE = /^#{1,6}\s+(.+)$/;
@@ -2821,25 +3056,25 @@ function collectShellHistory(entries, projectId, opts = {}) {
2821
3056
  }
2822
3057
 
2823
3058
  // src/conversation/claude-code-reader.ts
2824
- import { readFile as readFile4 } from "fs/promises";
3059
+ import { readFile as readFile5 } from "fs/promises";
2825
3060
  import { basename as basename2 } from "path";
2826
3061
 
2827
3062
  // src/conversation/paths.ts
2828
3063
  import { existsSync as existsSync3 } from "fs";
2829
3064
  import { readdir } from "fs/promises";
2830
3065
  import { homedir as homedir3 } from "os";
2831
- import { join as join5 } from "path";
3066
+ import { join as join6 } from "path";
2832
3067
  function claudeProjectSlug(repoRoot) {
2833
3068
  return repoRoot.replace(/[\\/:]/g, "-");
2834
3069
  }
2835
3070
  function claudeProjectTranscriptDir(repoRoot) {
2836
- return join5(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
3071
+ return join6(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
2837
3072
  }
2838
3073
  async function listTranscriptFiles(repoRoot) {
2839
3074
  const dir = claudeProjectTranscriptDir(repoRoot);
2840
3075
  if (!existsSync3(dir)) return [];
2841
3076
  const entries = await readdir(dir, { withFileTypes: true });
2842
- return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join5(dir, e.name));
3077
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join6(dir, e.name));
2843
3078
  }
2844
3079
 
2845
3080
  // src/conversation/claude-code-reader.ts
@@ -2914,15 +3149,15 @@ async function collectClaudeCodeTranscripts(repoRoot) {
2914
3149
  const files = await listTranscriptFiles(repoRoot);
2915
3150
  const turns = [];
2916
3151
  for (const file of files) {
2917
- const raw = await readFile4(file, "utf8");
3152
+ const raw = await readFile5(file, "utf8");
2918
3153
  turns.push(...parseClaudeCodeTranscript(raw, { sessionId: basename2(file, ".jsonl") }));
2919
3154
  }
2920
3155
  return turns;
2921
3156
  }
2922
3157
 
2923
3158
  // src/docs/read.ts
2924
- import { readFile as readFile5, stat } from "fs/promises";
2925
- import { join as join6 } from "path";
3159
+ import { readFile as readFile6, stat } from "fs/promises";
3160
+ import { join as join7 } from "path";
2926
3161
  var DEFAULT_PATHSPECS = ["*.md"];
2927
3162
  async function listDocFiles(repoRoot, opts = {}) {
2928
3163
  const pathspecs = opts.include ?? DEFAULT_PATHSPECS;
@@ -2935,11 +3170,11 @@ async function readDocFiles(repoRoot, opts = {}) {
2935
3170
  const unreadable = [];
2936
3171
  for (const relPath of paths) {
2937
3172
  const path = relPath.replace(/\\/g, "/");
2938
- const absPath = join6(repoRoot, relPath);
3173
+ const absPath = join7(repoRoot, relPath);
2939
3174
  let content;
2940
3175
  let mtime;
2941
3176
  try {
2942
- [content, { mtime }] = await Promise.all([readFile5(absPath, "utf8"), stat(absPath)]);
3177
+ [content, { mtime }] = await Promise.all([readFile6(absPath, "utf8"), stat(absPath)]);
2943
3178
  } catch {
2944
3179
  unreadable.push(path);
2945
3180
  continue;
@@ -2951,11 +3186,11 @@ async function readDocFiles(repoRoot, opts = {}) {
2951
3186
 
2952
3187
  // src/shell/detect.ts
2953
3188
  import { existsSync as existsSync4 } from "fs";
2954
- import { readFile as readFile7, stat as stat2 } from "fs/promises";
3189
+ import { readFile as readFile8, stat as stat2 } from "fs/promises";
2955
3190
 
2956
3191
  // src/shell/hook-log.ts
2957
- import { appendFile, mkdir as mkdir4, readFile as readFile6 } from "fs/promises";
2958
- import { dirname as dirname3 } from "path";
3192
+ import { appendFile, mkdir as mkdir5, readFile as readFile7 } from "fs/promises";
3193
+ import { dirname as dirname4 } from "path";
2959
3194
  function parseHookLogLine(line) {
2960
3195
  const trimmed = line.trim();
2961
3196
  if (!trimmed) return null;
@@ -2979,7 +3214,7 @@ function parseHookLogLine(line) {
2979
3214
  async function readHookLog(path, fromLine) {
2980
3215
  let raw;
2981
3216
  try {
2982
- raw = await readFile6(path, "utf8");
3217
+ raw = await readFile7(path, "utf8");
2983
3218
  } catch {
2984
3219
  return { entries: [], totalLines: fromLine };
2985
3220
  }
@@ -3113,7 +3348,7 @@ function hookEntryToRaw(e) {
3113
3348
  }
3114
3349
  async function tryReadScrapeSource(path, parse, tailLines) {
3115
3350
  if (!existsSync4(path)) return null;
3116
- const [raw, stats] = await Promise.all([readFile7(path, "utf8"), stat2(path)]);
3351
+ const [raw, stats] = await Promise.all([readFile8(path, "utf8"), stat2(path)]);
3117
3352
  return parse(raw, stats.mtimeMs, { tailLines });
3118
3353
  }
3119
3354
  async function collectAvailableShellHistory(opts = {}) {
@@ -3234,6 +3469,101 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
3234
3469
  })();
3235
3470
  }
3236
3471
 
3472
+ // src/structure/collect.ts
3473
+ import { readFile as readFile9 } from "fs/promises";
3474
+ import { join as join8 } from "path";
3475
+
3476
+ // src/structure/extract.ts
3477
+ var IMPORT_PATTERNS = [
3478
+ // import ... from '...'; export ... from '...'
3479
+ /\b(?:import|export)\b[^'"\n]*?\bfrom\s*['"]([^'"]+)['"]/g,
3480
+ // import '...'; (side-effect only)
3481
+ /\bimport\s*['"]([^'"]+)['"]/g,
3482
+ // require('...')
3483
+ /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
3484
+ // dynamic import('...')
3485
+ /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g
3486
+ ];
3487
+ function extractImportSpecifiers(source) {
3488
+ const seen = /* @__PURE__ */ new Set();
3489
+ for (const pattern of IMPORT_PATTERNS) {
3490
+ pattern.lastIndex = 0;
3491
+ let match;
3492
+ while ((match = pattern.exec(source)) !== null) {
3493
+ const specifier = match[1];
3494
+ if (specifier && (specifier.startsWith("./") || specifier.startsWith("../"))) {
3495
+ seen.add(specifier);
3496
+ }
3497
+ }
3498
+ }
3499
+ return [...seen];
3500
+ }
3501
+
3502
+ // src/structure/resolve.ts
3503
+ import { posix } from "path";
3504
+ var REWRITE_EXTENSIONS = {
3505
+ ".js": [".ts", ".tsx", ".js"],
3506
+ ".jsx": [".tsx", ".jsx"],
3507
+ ".mjs": [".mts", ".mjs"],
3508
+ ".cjs": [".cts", ".cjs"]
3509
+ };
3510
+ var APPEND_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".d.ts", ".json"];
3511
+ var INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.jsx"];
3512
+ function resolveSpecifier(fromPath, specifier, trackedPaths) {
3513
+ const fromDir = posix.dirname(fromPath);
3514
+ const joined = posix.normalize(posix.join(fromDir, specifier));
3515
+ if (trackedPaths.has(joined)) return joined;
3516
+ const ext = posix.extname(joined);
3517
+ const rewrites = REWRITE_EXTENSIONS[ext];
3518
+ if (rewrites) {
3519
+ const base = joined.slice(0, -ext.length);
3520
+ for (const replacement of rewrites) {
3521
+ const candidate = base + replacement;
3522
+ if (trackedPaths.has(candidate)) return candidate;
3523
+ }
3524
+ return null;
3525
+ }
3526
+ if (ext) return null;
3527
+ for (const suffix of APPEND_EXTENSIONS) {
3528
+ const candidate = joined + suffix;
3529
+ if (trackedPaths.has(candidate)) return candidate;
3530
+ }
3531
+ for (const indexFile of INDEX_FILES) {
3532
+ const candidate = posix.join(joined, indexFile);
3533
+ if (trackedPaths.has(candidate)) return candidate;
3534
+ }
3535
+ return null;
3536
+ }
3537
+
3538
+ // src/structure/collect.ts
3539
+ var TRACKED_PATHSPECS = ["*.ts", "*.tsx", "*.js", "*.jsx"];
3540
+ async function collectFileEdges(repoRoot) {
3541
+ const out = await git(repoRoot, ["ls-files", "--", ...TRACKED_PATHSPECS]);
3542
+ const paths = out.split("\n").map((line) => line.trim().replace(/\\/g, "/")).filter(Boolean);
3543
+ const trackedPaths = new Set(paths);
3544
+ const edges = [];
3545
+ const seenEdges = /* @__PURE__ */ new Set();
3546
+ const unreadable = [];
3547
+ for (const path of paths) {
3548
+ let content;
3549
+ try {
3550
+ content = await readFile9(join8(repoRoot, path), "utf8");
3551
+ } catch {
3552
+ unreadable.push(path);
3553
+ continue;
3554
+ }
3555
+ for (const specifier of extractImportSpecifiers(content)) {
3556
+ const target = resolveSpecifier(path, specifier, trackedPaths);
3557
+ if (!target || target === path) continue;
3558
+ const key = `${path}\0${target}`;
3559
+ if (seenEdges.has(key)) continue;
3560
+ seenEdges.add(key);
3561
+ edges.push({ fromPath: path, toPath: target, kind: "import" });
3562
+ }
3563
+ }
3564
+ return { edges, filesScanned: paths.length, unreadable };
3565
+ }
3566
+
3237
3567
  // src/vector/sync.ts
3238
3568
  var EMBEDDING_IDENTITY_KEY = "embedding.identity";
3239
3569
  var DEFAULT_BATCH_SIZE = 32;
@@ -3328,25 +3658,25 @@ function addStats(into, from) {
3328
3658
  async function syncGit(store, projectId, opts, repo, config, log) {
3329
3659
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
3330
3660
  if (!repo.head) {
3331
- log(`${pc4.yellow("git")} skipped -- repository has no commits yet`);
3661
+ log(`${pc5.yellow("git")} skipped -- repository has no commits yet`);
3332
3662
  return { totals, seen: 0 };
3333
3663
  }
3334
3664
  if (!config.sources.git.enabled) {
3335
- log(`${pc4.dim("git")} disabled in config`);
3665
+ log(`${pc5.dim("git")} disabled in config`);
3336
3666
  return { totals, seen: 0 };
3337
3667
  }
3338
3668
  let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, GIT_SOURCE);
3339
3669
  if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
3340
- log(`${pc4.yellow("git cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a full walk`);
3670
+ log(`${pc5.yellow("git cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a full walk`);
3341
3671
  cursor = null;
3342
3672
  }
3343
3673
  if (cursor === repo.head) {
3344
- log(`${pc4.green("git up to date")} at ${repo.head.slice(0, 7)}`);
3674
+ log(`${pc5.green("git up to date")} at ${repo.head.slice(0, 7)}`);
3345
3675
  store.setSyncCursor(projectId, GIT_SOURCE, repo.head);
3346
3676
  return { totals, seen: 0 };
3347
3677
  }
3348
3678
  log(
3349
- `${pc4.dim("git syncing")} ${repo.branch ?? "HEAD"} ${cursor ? `${cursor.slice(0, 7)}..${repo.head.slice(0, 7)}` : "(full history)"}`
3679
+ `${pc5.dim("git syncing")} ${repo.branch ?? "HEAD"} ${cursor ? `${cursor.slice(0, 7)}..${repo.head.slice(0, 7)}` : "(full history)"}`
3350
3680
  );
3351
3681
  let batch = [];
3352
3682
  let seen = 0;
@@ -3354,7 +3684,7 @@ async function syncGit(store, projectId, opts, repo, config, log) {
3354
3684
  if (batch.length === 0) return;
3355
3685
  addStats(totals, store.upsertNodes(batch));
3356
3686
  batch = [];
3357
- log(` ${pc4.dim(`${seen} commits read, ${totals.inserted} new`)}`);
3687
+ log(` ${pc5.dim(`${seen} commits read, ${totals.inserted} new`)}`);
3358
3688
  };
3359
3689
  const nodes = collectGitCommits(repo.root, projectId, {
3360
3690
  afterCommit: cursor,
@@ -3376,12 +3706,12 @@ async function syncDiffs(store, projectId, opts, repo, config, log) {
3376
3706
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
3377
3707
  if (!repo.head) return { totals, seen: 0 };
3378
3708
  if (!config.sources.diff.enabled) {
3379
- log(`${pc4.dim("diff")} disabled in config`);
3709
+ log(`${pc5.dim("diff")} disabled in config`);
3380
3710
  return { totals, seen: 0 };
3381
3711
  }
3382
3712
  let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, DIFF_SOURCE);
3383
3713
  if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
3384
- log(`${pc4.yellow("diff cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a bounded walk`);
3714
+ log(`${pc5.yellow("diff cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a bounded walk`);
3385
3715
  cursor = null;
3386
3716
  }
3387
3717
  if (cursor === repo.head) {
@@ -3410,13 +3740,13 @@ async function syncDiffs(store, projectId, opts, repo, config, log) {
3410
3740
  }
3411
3741
  flush();
3412
3742
  store.setSyncCursor(projectId, DIFF_SOURCE, repo.head);
3413
- log(` ${pc4.dim(`${DIFF_SOURCE}: ${seen} file diff(s) read`)}`);
3743
+ log(` ${pc5.dim(`${DIFF_SOURCE}: ${seen} file diff(s) read`)}`);
3414
3744
  return { totals, seen };
3415
3745
  }
3416
3746
  async function syncShell(store, projectId, opts, repoRoot, config, log) {
3417
3747
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
3418
3748
  if (!config.sources.shell.enabled) {
3419
- log(`${pc4.dim("shell")} disabled in config`);
3749
+ log(`${pc5.dim("shell")} disabled in config`);
3420
3750
  return { totals, seen: 0 };
3421
3751
  }
3422
3752
  const results = await collectAvailableShellHistory({
@@ -3425,7 +3755,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
3425
3755
  hookCursor: store.getSyncCursor(projectId, "shell:pwsh-hook")
3426
3756
  });
3427
3757
  if (results.length === 0) {
3428
- log(`${pc4.dim("shell")} no history source found on this machine`);
3758
+ log(`${pc5.dim("shell")} no history source found on this machine`);
3429
3759
  return { totals, seen: 0 };
3430
3760
  }
3431
3761
  let seen = 0;
@@ -3437,7 +3767,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
3437
3767
  addStats(totals, store.upsertNodes(nodes));
3438
3768
  }
3439
3769
  store.setSyncCursor(projectId, sourceKey, result.cursorAfter ?? `scanned:${result.entries.length}`);
3440
- log(` ${pc4.dim(`${sourceKey}: ${nodes.length} entr${nodes.length === 1 ? "y" : "ies"} read`)}`);
3770
+ log(` ${pc5.dim(`${sourceKey}: ${nodes.length} entr${nodes.length === 1 ? "y" : "ies"} read`)}`);
3441
3771
  }
3442
3772
  return { totals, seen };
3443
3773
  }
@@ -3449,13 +3779,13 @@ function syncConversation(store, projectId, turns, config, log, forceEnabled) {
3449
3779
  return { totals, seen: 0 };
3450
3780
  }
3451
3781
  if (turns.length === 0) {
3452
- log(`${pc4.dim("conversation")} no transcripts found`);
3782
+ log(`${pc5.dim("conversation")} no transcripts found`);
3453
3783
  return { totals, seen: 0 };
3454
3784
  }
3455
3785
  const nodes = collectConversationTurns(turns, projectId, { maxBodyChars: config.limits.maxBodyChars });
3456
3786
  if (nodes.length > 0) addStats(totals, store.upsertNodes(nodes));
3457
3787
  store.setSyncCursor(projectId, CONVERSATION_SOURCE, `scanned:${nodes.length}`);
3458
- log(` ${pc4.dim(`${CONVERSATION_SOURCE}: ${nodes.length} of ${turns.length} exchange(s) kept`)}`);
3788
+ log(` ${pc5.dim(`${CONVERSATION_SOURCE}: ${nodes.length} of ${turns.length} exchange(s) kept`)}`);
3459
3789
  return { totals, seen: nodes.length };
3460
3790
  }
3461
3791
  var SESSION_SOURCE = "session:claude-code";
@@ -3464,7 +3794,7 @@ async function syncSessions(store, projectId, turns, config, log) {
3464
3794
  const settings = config.sources.session;
3465
3795
  if (!settings.enabled) return { totals, seen: 0 };
3466
3796
  if (turns.length === 0) {
3467
- log(`${pc4.dim("session")} no transcripts found`);
3797
+ log(`${pc5.dim("session")} no transcripts found`);
3468
3798
  return { totals, seen: 0 };
3469
3799
  }
3470
3800
  const result = await collectSessionSummaries(turns, projectId, new OllamaChatProvider({ model: settings.model }), {
@@ -3476,12 +3806,12 @@ async function syncSessions(store, projectId, turns, config, log) {
3476
3806
  const meta = store.getNodeMeta(makeNodeId(projectId, "session_summary", sessionKey));
3477
3807
  return typeof meta?.contentHash === "string" ? meta.contentHash : null;
3478
3808
  },
3479
- onProgress: (done, total) => log(` ${pc4.dim(`session: summarizing ${done}/${total}`)}`)
3809
+ onProgress: (done, total) => log(` ${pc5.dim(`session: summarizing ${done}/${total}`)}`)
3480
3810
  });
3481
3811
  if (result.nodes.length > 0) addStats(totals, store.upsertNodes(result.nodes));
3482
3812
  if (result.providerUnavailable) {
3483
3813
  log(
3484
- `${pc4.dim("session")} summarization model unavailable (is Ollama running with \`${settings.model}\` pulled?) -- skipped`
3814
+ `${pc5.dim("session")} summarization model unavailable (is Ollama running with \`${settings.model}\` pulled?) -- skipped`
3485
3815
  );
3486
3816
  } else {
3487
3817
  const parts = [`${result.nodes.length} summarized`];
@@ -3489,7 +3819,7 @@ async function syncSessions(store, projectId, turns, config, log) {
3489
3819
  if (result.deferred > 0) parts.push(`${result.deferred} queued for the next sync`);
3490
3820
  if (result.unsettled > 0) parts.push(`${result.unsettled} still active`);
3491
3821
  if (result.failed > 0) parts.push(`${result.failed} failed`);
3492
- log(` ${pc4.dim(`${SESSION_SOURCE}: ${parts.join(", ")}`)}`);
3822
+ log(` ${pc5.dim(`${SESSION_SOURCE}: ${parts.join(", ")}`)}`);
3493
3823
  }
3494
3824
  store.setSyncCursor(projectId, SESSION_SOURCE, `scanned:${result.nodes.length}`);
3495
3825
  return { totals, seen: result.nodes.length };
@@ -3498,7 +3828,7 @@ var DOCS_SOURCE = "docs";
3498
3828
  async function syncDocs(store, projectId, repoRoot, config, log) {
3499
3829
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
3500
3830
  if (!config.sources.docs.enabled) {
3501
- log(`${pc4.dim("docs")} disabled in config`);
3831
+ log(`${pc5.dim("docs")} disabled in config`);
3502
3832
  return { totals, seen: 0 };
3503
3833
  }
3504
3834
  const { files, unreadable } = await readDocFiles(repoRoot, { include: config.sources.docs.include });
@@ -3512,14 +3842,25 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
3512
3842
  );
3513
3843
  store.setSyncCursor(projectId, DOCS_SOURCE, `scanned:${nodes.length}`);
3514
3844
  if (files.length === 0 && unreadable.length === 0) {
3515
- log(`${pc4.dim("docs")} no tracked .md files found`);
3845
+ log(`${pc5.dim("docs")} no tracked .md files found`);
3516
3846
  } else {
3517
- const prunedPart = pruned > 0 ? `, ${pc4.yellow(`${pruned} stale removed`)}` : "";
3847
+ const prunedPart = pruned > 0 ? `, ${pc5.yellow(`${pruned} stale removed`)}` : "";
3518
3848
  const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (kept)` : "";
3519
- log(` ${pc4.dim(`${DOCS_SOURCE}: ${nodes.length} section(s) from ${files.length} file(s)`)}${prunedPart}${pc4.dim(skippedPart)}`);
3849
+ log(` ${pc5.dim(`${DOCS_SOURCE}: ${nodes.length} section(s) from ${files.length} file(s)`)}${prunedPart}${pc5.dim(skippedPart)}`);
3520
3850
  }
3521
3851
  return { totals, seen: nodes.length };
3522
3852
  }
3853
+ async function syncStructure(store, projectId, repoRoot, config, log) {
3854
+ if (!config.sources.structure.enabled) {
3855
+ log(`${pc5.dim("structure")} disabled in config`);
3856
+ return { edges: 0, filesScanned: 0 };
3857
+ }
3858
+ const { edges, filesScanned, unreadable } = await collectFileEdges(repoRoot);
3859
+ store.replaceFileEdges(projectId, edges);
3860
+ const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (skipped)` : "";
3861
+ log(` ${pc5.dim(`structure: ${edges.length} edge(s) from ${filesScanned} file(s)`)}${pc5.dim(skippedPart)}`);
3862
+ return { edges: edges.length, filesScanned };
3863
+ }
3523
3864
  var STALE_SHELL_SOURCES = ["shell:pwsh", "shell:bash", "shell:zsh"];
3524
3865
  function collectPruneSources(opts) {
3525
3866
  const sources = /* @__PURE__ */ new Set();
@@ -3534,15 +3875,15 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
3534
3875
  const counts = sources.flatMap((source) => scopeIds.map((id) => ({ source, id, count: store.countSourceNodes(id, source) })));
3535
3876
  const total = counts.reduce((sum, c) => sum + c.count, 0);
3536
3877
  if (total === 0) {
3537
- out(`${pc4.dim("prune-source")} no node(s) match ${sources.join(", ")} -- nothing to do
3878
+ out(`${pc5.dim("prune-source")} no node(s) match ${sources.join(", ")} -- nothing to do
3538
3879
  `);
3539
3880
  return 0;
3540
3881
  }
3541
- const describe = (c) => ` ${pc4.dim(c.source)}${c.id !== projectId ? pc4.dim(` (prior identity ${c.id.slice(0, 8)})`) : ""}: ${c.count} node(s)`;
3882
+ const describe = (c) => ` ${pc5.dim(c.source)}${c.id !== projectId ? pc5.dim(` (prior identity ${c.id.slice(0, 8)})`) : ""}: ${c.count} node(s)`;
3542
3883
  if (!yes) {
3543
3884
  const lines = counts.filter((c) => c.count > 0).map(describe);
3544
3885
  out(
3545
- [`${pc4.yellow("would remove")} ${total} node(s):`, ...lines, pc4.dim("re-run with --yes to actually delete these -- this cannot be undone"), ""].join(
3886
+ [`${pc5.yellow("would remove")} ${total} node(s):`, ...lines, pc5.dim("re-run with --yes to actually delete these -- this cannot be undone"), ""].join(
3546
3887
  "\n"
3547
3888
  )
3548
3889
  );
@@ -3551,7 +3892,7 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
3551
3892
  let removed = 0;
3552
3893
  for (const { source, id } of counts) removed += store.pruneSourceNodes(id, source, []);
3553
3894
  const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
3554
- out(`${pc4.green("pruned")} ${removed} node(s) across ${sources.length} source(s)${identityPart}
3895
+ out(`${pc5.green("pruned")} ${removed} node(s) across ${sources.length} source(s)${identityPart}
3555
3896
  `);
3556
3897
  return 0;
3557
3898
  }
@@ -3568,7 +3909,7 @@ async function runSync(opts) {
3568
3909
  store.upsertProject({ id: projectId, root: repo.root, originUrl: repo.originUrl });
3569
3910
  if (opts.rebuild) {
3570
3911
  const removed = store.clearProject(projectId);
3571
- log(`${pc4.dim("rebuild")} dropped ${removed} existing node(s)`);
3912
+ log(`${pc5.dim("rebuild")} dropped ${removed} existing node(s)`);
3572
3913
  }
3573
3914
  const staleProjectIds = store.listOtherProjectIds(projectId);
3574
3915
  for (const staleId of staleProjectIds) {
@@ -3581,7 +3922,7 @@ async function runSync(opts) {
3581
3922
  ].filter((part) => part !== null);
3582
3923
  if (parts.length > 0) {
3583
3924
  log(
3584
- `${pc4.yellow("reconciled")} previous project identity ${pc4.dim(staleId)} (remote URL likely changed): ${parts.join(", ")}`
3925
+ `${pc5.yellow("reconciled")} previous project identity ${pc5.dim(staleId)} (remote URL likely changed): ${parts.join(", ")}`
3585
3926
  );
3586
3927
  }
3587
3928
  }
@@ -3605,31 +3946,32 @@ async function runSync(opts) {
3605
3946
  const conversation = syncConversation(store, projectId, turns, config, log, opts.conversationOverride);
3606
3947
  const sessions = await syncSessions(store, projectId, turns, config, log);
3607
3948
  const docs = await syncDocs(store, projectId, repo.root, config, log);
3949
+ const structure = await syncStructure(store, projectId, repo.root, config, log);
3608
3950
  let embedLine = "";
3609
3951
  if (!opts.noEmbed) {
3610
3952
  let lastLogged = 0;
3611
3953
  const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId, {
3612
3954
  maxNodes: opts.embedLimit,
3613
- onInvalidated: (count) => log(`${pc4.yellow("vector")} embedding model changed -- dropped ${count} vector(s), re-embedding from scratch`),
3955
+ onInvalidated: (count) => log(`${pc5.yellow("vector")} embedding model changed -- dropped ${count} vector(s), re-embedding from scratch`),
3614
3956
  onProgress: (attempted, total) => {
3615
3957
  if (total < PROGRESS_THRESHOLD || attempted - lastLogged < PROGRESS_EVERY) return;
3616
3958
  lastLogged = attempted;
3617
- log(` ${pc4.dim(`vector: ${attempted}/${total} embedded`)}`);
3959
+ log(` ${pc5.dim(`vector: ${attempted}/${total} embedded`)}`);
3618
3960
  }
3619
3961
  });
3620
3962
  if (result.embedded > 0) {
3621
- const skippedPart = result.skipped > 0 ? pc4.dim(`, ${result.skipped} skipped`) : "";
3622
- const remainingPart = result.remaining > 0 ? pc4.yellow(`, ${result.remaining} still pending`) : "";
3623
- embedLine = ` ${pc4.dim(`vector: ${result.embedded} node(s) embedded`)}${skippedPart}${remainingPart}
3963
+ const skippedPart = result.skipped > 0 ? pc5.dim(`, ${result.skipped} skipped`) : "";
3964
+ const remainingPart = result.remaining > 0 ? pc5.yellow(`, ${result.remaining} still pending`) : "";
3965
+ embedLine = ` ${pc5.dim(`vector: ${result.embedded} node(s) embedded`)}${skippedPart}${remainingPart}
3624
3966
  `;
3625
3967
  } else if (result.providerUnavailable) {
3626
- log(`${pc4.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
3968
+ log(`${pc5.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
3627
3969
  }
3628
3970
  }
3629
3971
  let linkLine = "";
3630
3972
  if (opts.linkFailures) {
3631
3973
  const linkStats = correlateFailures(store, projectId);
3632
- linkLine = ` ${pc4.dim(`chains: ${linkStats.failuresExamined} failure(s) examined, ${linkStats.linkedByRetry} linked by retry, ${linkStats.linkedByDiscussion} by discussion`)}
3974
+ linkLine = ` ${pc5.dim(`chains: ${linkStats.failuresExamined} failure(s) examined, ${linkStats.linkedByRetry} linked by retry, ${linkStats.linkedByDiscussion} by discussion`)}
3633
3975
  `;
3634
3976
  }
3635
3977
  store.markSynced(projectId);
@@ -3646,11 +3988,12 @@ async function runSync(opts) {
3646
3988
  const sessionPart = config.sources.session.enabled ? `, ${sessions.seen} session summar${sessions.seen === 1 ? "y" : "ies"}` : "";
3647
3989
  const docsPart = config.sources.docs.enabled ? `, ${docs.seen} doc section(s)` : "";
3648
3990
  const diffPart = config.sources.diff.enabled ? `, ${diffs.seen} file diff(s)` : "";
3991
+ const structurePart = config.sources.structure.enabled ? `, ${structure.edges} import edge(s)` : "";
3649
3992
  out(
3650
3993
  [
3651
- `${pc4.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${sessionPart}${docsPart} in ${elapsed}s`,
3652
- ` ${pc4.green(`+${totals.inserted} new`)} ${pc4.yellow(`~${totals.updated} updated`)} ${pc4.dim(`=${totals.unchanged} unchanged`)}`,
3653
- ` ${pc4.dim(`${stats.total} node(s) total across ${stats.distinctFiles} file path(s)`)}`,
3994
+ `${pc5.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${sessionPart}${docsPart}${structurePart} in ${elapsed}s`,
3995
+ ` ${pc5.green(`+${totals.inserted} new`)} ${pc5.yellow(`~${totals.updated} updated`)} ${pc5.dim(`=${totals.unchanged} unchanged`)}`,
3996
+ ` ${pc5.dim(`${stats.total} node(s) total across ${stats.distinctFiles} file path(s)`)}`,
3654
3997
  ""
3655
3998
  ].join("\n") + embedLine + linkLine
3656
3999
  );
@@ -3845,8 +4188,116 @@ async function runMcpServer() {
3845
4188
  await server.connect(transport);
3846
4189
  }
3847
4190
 
4191
+ // src/cli/commands/precheck.ts
4192
+ import pc6 from "picocolors";
4193
+
4194
+ // src/correlate/precheck.ts
4195
+ var DEFAULT_RECENT_DAYS = 30;
4196
+ function tokensForFile(path) {
4197
+ const base = path.split("/").pop() ?? path;
4198
+ const stem = base.replace(/\.[a-zA-Z0-9]+$/, "");
4199
+ const words = stem.split(/[^a-zA-Z0-9]+/).filter(Boolean);
4200
+ return significantTokens(words.join(" "));
4201
+ }
4202
+ function assessFiles(store, projectId, paths, opts = {}) {
4203
+ const db = store.raw;
4204
+ const recentDays = opts.recentDays ?? DEFAULT_RECENT_DAYS;
4205
+ const cutoffEpoch = Date.now() - recentDays * 24 * 60 * 60 * 1e3;
4206
+ const findFailures = db.prepare(
4207
+ `SELECT n.id AS id, json_extract(n.meta, '$.command') AS command, n.ts AS ts
4208
+ FROM nodes_fts
4209
+ JOIN nodes n ON n.rowid = nodes_fts.rowid
4210
+ WHERE nodes_fts MATCH ?
4211
+ AND n.project_id = ?
4212
+ AND n.kind = 'shell_command'
4213
+ AND json_extract(n.meta, '$.exitCode') IS NOT NULL
4214
+ AND json_extract(n.meta, '$.exitCode') != 0
4215
+ AND n.ts_epoch >= ?
4216
+ AND n.id NOT IN (SELECT from_node_id FROM node_links WHERE relation IN (?, ?))
4217
+ ORDER BY n.ts_epoch DESC`
4218
+ );
4219
+ const countCommits = db.prepare(
4220
+ `SELECT COUNT(DISTINCT nf.node_id) AS c
4221
+ FROM node_files nf
4222
+ JOIN nodes n ON n.id = nf.node_id
4223
+ WHERE n.project_id = ? AND n.kind = 'git_commit' AND nf.path = ? AND n.ts_epoch >= ?`
4224
+ );
4225
+ return paths.map((path) => {
4226
+ const tokens = filterBoilerplateTokens(db, projectId, tokensForFile(path), ["shell_command"]);
4227
+ const match = tokens.length > 0 ? tokens.map((t) => `"${t}"*`).join(" AND ") : null;
4228
+ const unresolvedFailures = match ? findFailures.all(match, projectId, cutoffEpoch, RESOLVED_BY_RETRY, RESOLVED_BY_DISCUSSION).filter((row) => Boolean(row.command)).map((row) => ({ id: row.id, command: row.command, ts: row.ts })) : [];
4229
+ const commitsRecent = countCommits.get(projectId, path, cutoffEpoch).c;
4230
+ return { path, unresolvedFailures, commitsRecent };
4231
+ });
4232
+ }
4233
+
4234
+ // src/cli/commands/precheck.ts
4235
+ var HIGH_CHURN_THRESHOLD = 4;
4236
+ async function stagedFiles(repoRoot) {
4237
+ const out = await git(repoRoot, ["diff", "--cached", "--name-only", "--diff-filter=ACM"]);
4238
+ return out.split("\n").map((line) => line.trim()).filter(Boolean);
4239
+ }
4240
+ async function workingTreeFiles(repoRoot) {
4241
+ const out = await git(repoRoot, ["diff", "--name-only", "--diff-filter=ACM"]);
4242
+ return out.split("\n").map((line) => line.trim()).filter(Boolean);
4243
+ }
4244
+ async function runPrecheck(opts) {
4245
+ const out = opts.out ?? ((chunk2) => void process.stderr.write(chunk2));
4246
+ const { repo, ws, projectId } = await loadContext(opts.cwd);
4247
+ const targetFiles = opts.files ?? (opts.working ? await workingTreeFiles(repo.root) : await stagedFiles(repo.root));
4248
+ if (targetFiles.length === 0) {
4249
+ if (!opts.quiet) out(`${pc6.dim("precheck")} no files to check
4250
+ `);
4251
+ return 0;
4252
+ }
4253
+ const store = MemoryStore.open(ws.dbPath);
4254
+ let risks;
4255
+ try {
4256
+ risks = assessFiles(store, projectId, targetFiles);
4257
+ } finally {
4258
+ store.close();
4259
+ }
4260
+ const flagged = risks.filter((r) => r.unresolvedFailures.length > 0 || r.commitsRecent >= HIGH_CHURN_THRESHOLD);
4261
+ if (flagged.length === 0) {
4262
+ if (!opts.quiet) out(`${pc6.green("precheck")} no warnings \u2014 looking good
4263
+ `);
4264
+ return 0;
4265
+ }
4266
+ out(`
4267
+ ${pc6.bold("nexusmem precheck")}
4268
+ ${pc6.dim("-".repeat(40))}
4269
+
4270
+ `);
4271
+ for (const risk of flagged) {
4272
+ out(` ${pc6.bold(risk.path)}
4273
+ `);
4274
+ if (risk.unresolvedFailures.length > 0) {
4275
+ out(` ${pc6.yellow("WARN")} What already failed here (${risk.unresolvedFailures.length} unresolved):
4276
+ `);
4277
+ for (const f of risk.unresolvedFailures.slice(0, 3)) {
4278
+ out(` ${pc6.dim(`${f.ts.slice(0, 10)} ${f.command.slice(0, 90)}`)}
4279
+ `);
4280
+ }
4281
+ if (risk.unresolvedFailures.length > 3) {
4282
+ out(` ${pc6.dim(`... and ${risk.unresolvedFailures.length - 3} more`)}
4283
+ `);
4284
+ }
4285
+ }
4286
+ if (risk.commitsRecent >= HIGH_CHURN_THRESHOLD) {
4287
+ out(` ${pc6.yellow("WARN")} high churn: ${risk.commitsRecent} commits touched this file recently
4288
+ `);
4289
+ }
4290
+ out("\n");
4291
+ }
4292
+ const failureCount = flagged.filter((r) => r.unresolvedFailures.length > 0).length;
4293
+ out(`${pc6.dim(`${flagged.length} file(s) flagged, ${failureCount} with unresolved failures.`)}
4294
+ `);
4295
+ if (opts.strict && failureCount > 0) return 1;
4296
+ return 0;
4297
+ }
4298
+
3848
4299
  // src/cli/commands/query.ts
3849
- import pc5 from "picocolors";
4300
+ import pc7 from "picocolors";
3850
4301
  async function runQuery(opts) {
3851
4302
  const { repo, ws, projectId } = await loadContext(opts.cwd);
3852
4303
  const opened = opts.allProjects ? await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath }) : null;
@@ -3868,15 +4319,15 @@ async function runQuery(opts) {
3868
4319
  const { bm25Count, vectorCount, hits, packed } = result;
3869
4320
  if (opened && !opts.json) {
3870
4321
  const searched = opened.sources.map((s) => s.label).join(", ");
3871
- process.stderr.write(`${pc5.dim("scope ")} ${opened.sources.length} project(s): ${searched}
4322
+ process.stderr.write(`${pc7.dim("scope ")} ${opened.sources.length} project(s): ${searched}
3872
4323
  `);
3873
4324
  for (const { entry } of opened.unreadable) {
3874
- process.stderr.write(`${pc5.yellow("unreadable")} ${entry.root} -- skipped
4325
+ process.stderr.write(`${pc7.yellow("unreadable")} ${entry.root} -- skipped
3875
4326
  `);
3876
4327
  }
3877
4328
  if (opened.missing.length > 0) {
3878
4329
  process.stderr.write(
3879
- `${pc5.dim("skipped")} ${opened.missing.length} registered project(s) whose database is not on disk ${pc5.dim("(nexusmem projects --prune to forget them)")}
4330
+ `${pc7.dim("skipped")} ${opened.missing.length} registered project(s) whose database is not on disk ${pc7.dim("(nexusmem projects --prune to forget them)")}
3880
4331
  `
3881
4332
  );
3882
4333
  }
@@ -3906,15 +4357,15 @@ async function runQuery(opts) {
3906
4357
  return 0;
3907
4358
  }
3908
4359
  if (matched === 0) {
3909
- process.stderr.write(`${pc5.yellow("no matches")} for "${opts.query}"
4360
+ process.stderr.write(`${pc7.yellow("no matches")} for "${opts.query}"
3910
4361
  `);
3911
4362
  return 0;
3912
4363
  }
3913
4364
  process.stderr.write(
3914
4365
  [
3915
- `${pc5.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc5.bold(String(packed.nodes.length))} into budget`,
3916
- `${pc5.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc5.dim(` (${packed.droppedForBudget} dropped for budget)`) : "") + (packed.droppedForDiversity ? pc5.dim(` (${packed.droppedForDiversity} dropped for diversity)`) : ""),
3917
- rawTokens > 0 ? `${pc5.dim("vs raw ")} ${rawTokens} tokens if these same matches were sent unpacked ${packerEfficiency >= 0 ? pc5.green(`(${(packerEfficiency * 100).toFixed(0)}% packer efficiency)`) : pc5.yellow(`(${(-packerEfficiency * 100).toFixed(0)}% larger -- overhead dominates on small result sets)`)}` : "",
4366
+ `${pc7.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc7.bold(String(packed.nodes.length))} into budget`,
4367
+ `${pc7.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc7.dim(` (${packed.droppedForBudget} dropped for budget)`) : "") + (packed.droppedForDiversity ? pc7.dim(` (${packed.droppedForDiversity} dropped for diversity)`) : ""),
4368
+ rawTokens > 0 ? `${pc7.dim("vs raw ")} ${rawTokens} tokens if these same matches were sent unpacked ${packerEfficiency >= 0 ? pc7.green(`(${(packerEfficiency * 100).toFixed(0)}% packer efficiency)`) : pc7.yellow(`(${(-packerEfficiency * 100).toFixed(0)}% larger -- overhead dominates on small result sets)`)}` : "",
3918
4369
  ""
3919
4370
  ].filter(Boolean).join("\n")
3920
4371
  );
@@ -3928,10 +4379,10 @@ async function runQuery(opts) {
3928
4379
  }
3929
4380
 
3930
4381
  // src/cli/commands/scan-conversation.ts
3931
- import pc7 from "picocolors";
4382
+ import pc9 from "picocolors";
3932
4383
 
3933
4384
  // src/cli/format.ts
3934
- import pc6 from "picocolors";
4385
+ import pc8 from "picocolors";
3935
4386
  var GIT_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
3936
4387
  var SHELL_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
3937
4388
  var CONVERSATION_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
@@ -3943,9 +4394,9 @@ function signalBand(signal, bands) {
3943
4394
  return "low";
3944
4395
  }
3945
4396
  var BAND_COLOR = {
3946
- high: pc6.green,
3947
- medium: pc6.yellow,
3948
- low: pc6.dim
4397
+ high: pc8.green,
4398
+ medium: pc8.yellow,
4399
+ low: pc8.dim
3949
4400
  };
3950
4401
  function formatSignal(signal, bands) {
3951
4402
  return BAND_COLOR[signalBand(signal, bands)](signal.toFixed(2));
@@ -3958,9 +4409,9 @@ async function runScanConversation(opts) {
3958
4409
  const files = await listTranscriptFiles(repo.root);
3959
4410
  if (!opts.json) {
3960
4411
  process.stderr.write(
3961
- files.length ? `${pc7.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
4412
+ files.length ? `${pc9.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
3962
4413
 
3963
- ` : `${pc7.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
4414
+ ` : `${pc9.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
3964
4415
  `
3965
4416
  );
3966
4417
  }
@@ -3977,7 +4428,7 @@ async function runScanConversation(opts) {
3977
4428
  const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
3978
4429
  process.stderr.write(
3979
4430
  `
3980
- ${pc7.bold(String(nodes.length))} of ${turns.length} exchange(s) above threshold ${pc7.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}` + (redactedTotal > 0 ? ` ${pc7.yellow(`${redactedTotal} secret-like value(s) redacted`)}` : "") + "\n"
4431
+ ${pc9.bold(String(nodes.length))} of ${turns.length} exchange(s) above threshold ${pc9.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}` + (redactedTotal > 0 ? ` ${pc9.yellow(`${redactedTotal} secret-like value(s) redacted`)}` : "") + "\n"
3981
4432
  );
3982
4433
  return 0;
3983
4434
  }
@@ -3986,20 +4437,20 @@ function formatNode(node) {
3986
4437
  }
3987
4438
 
3988
4439
  // src/cli/commands/scan-diff.ts
3989
- import pc9 from "picocolors";
4440
+ import pc11 from "picocolors";
3990
4441
 
3991
4442
  // src/cli/commands/scan-git.ts
3992
- import pc8 from "picocolors";
4443
+ import pc10 from "picocolors";
3993
4444
  async function runScanGit(opts) {
3994
4445
  const repo = await readRepoInfo(opts.cwd);
3995
4446
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
3996
4447
  if (!opts.json) {
3997
4448
  process.stderr.write(
3998
4449
  [
3999
- `${pc8.dim("repo ")} ${repo.root}`,
4000
- `${pc8.dim("branch ")} ${repo.branch ?? pc8.yellow("(detached)")}`,
4001
- `${pc8.dim("origin ")} ${repo.originUrl ?? pc8.dim("(none)")}`,
4002
- `${pc8.dim("project")} ${pc8.cyan(projectId)}`,
4450
+ `${pc10.dim("repo ")} ${repo.root}`,
4451
+ `${pc10.dim("branch ")} ${repo.branch ?? pc10.yellow("(detached)")}`,
4452
+ `${pc10.dim("origin ")} ${repo.originUrl ?? pc10.dim("(none)")}`,
4453
+ `${pc10.dim("project")} ${pc10.cyan(projectId)}`,
4003
4454
  ""
4004
4455
  ].join("\n")
4005
4456
  );
@@ -4033,14 +4484,14 @@ function formatNode2(node) {
4033
4484
  const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
4034
4485
  return [
4035
4486
  formatSignal(node.signal, GIT_SIGNAL_BANDS),
4036
- pc8.dim(date),
4037
- pc8.magenta(sha),
4487
+ pc10.dim(date),
4488
+ pc10.magenta(sha),
4038
4489
  node.title,
4039
- pc8.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
4490
+ pc10.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
4040
4491
  ].join(" ");
4041
4492
  }
4042
4493
  function summarize2(nodes) {
4043
- if (nodes.length === 0) return pc8.yellow("no commits matched");
4494
+ if (nodes.length === 0) return pc10.yellow("no commits matched");
4044
4495
  const timestamps = nodes.map((n) => n.ts).sort();
4045
4496
  const avgSignal = nodes.reduce((n, x) => n + x.signal, 0) / nodes.length;
4046
4497
  const totalTokens = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
@@ -4050,7 +4501,7 @@ function summarize2(nodes) {
4050
4501
  }
4051
4502
  const hottest = [...fileHits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([path, count]) => ` ${String(count).padStart(3)}x ${path}`);
4052
4503
  return [
4053
- `${pc8.bold(String(nodes.length))} nodes ${pc8.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
4504
+ `${pc10.bold(String(nodes.length))} nodes ${pc10.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
4054
4505
  ` avg signal ${avgSignal.toFixed(3)} ~${totalTokens.toLocaleString()} tokens if sent raw`,
4055
4506
  hottest.length ? ` hottest files:
4056
4507
  ${hottest.join("\n")}` : ""
@@ -4065,9 +4516,9 @@ async function runScanDiff(opts) {
4065
4516
  if (!opts.json) {
4066
4517
  process.stderr.write(
4067
4518
  [
4068
- `${pc9.dim("repo ")} ${repo.root}`,
4069
- `${pc9.dim("branch ")} ${repo.branch ?? pc9.yellow("(detached)")}`,
4070
- `${pc9.dim("project")} ${pc9.cyan(projectId)}`,
4519
+ `${pc11.dim("repo ")} ${repo.root}`,
4520
+ `${pc11.dim("branch ")} ${repo.branch ?? pc11.yellow("(detached)")}`,
4521
+ `${pc11.dim("project")} ${pc11.cyan(projectId)}`,
4071
4522
  ""
4072
4523
  ].join("\n")
4073
4524
  );
@@ -4097,28 +4548,28 @@ function formatNode3(node) {
4097
4548
  const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
4098
4549
  return [
4099
4550
  formatSignal(node.signal, DIFF_SIGNAL_BANDS),
4100
- pc9.dim(node.ts.slice(0, 10)),
4101
- pc9.magenta(sha),
4551
+ pc11.dim(node.ts.slice(0, 10)),
4552
+ pc11.magenta(sha),
4102
4553
  String(node.meta.path ?? ""),
4103
- pc9.dim(`(${churn}, ${node.meta.hunkCount ?? 0} hunk(s))`)
4554
+ pc11.dim(`(${churn}, ${node.meta.hunkCount ?? 0} hunk(s))`)
4104
4555
  ].join(" ");
4105
4556
  }
4106
4557
 
4107
4558
  // src/cli/commands/scan-docs.ts
4108
- import pc10 from "picocolors";
4559
+ import pc12 from "picocolors";
4109
4560
  async function runScanDocs(opts) {
4110
4561
  const repo = await readRepoInfo(opts.cwd);
4111
4562
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
4112
4563
  const { files, unreadable } = await readDocFiles(repo.root);
4113
4564
  if (!opts.json) {
4114
4565
  process.stderr.write(
4115
- files.length ? `${pc10.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
4566
+ files.length ? `${pc12.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
4116
4567
 
4117
- ` : `${pc10.yellow("no tracked .md files found")}
4568
+ ` : `${pc12.yellow("no tracked .md files found")}
4118
4569
  `
4119
4570
  );
4120
4571
  if (unreadable.length > 0) {
4121
- process.stderr.write(`${pc10.yellow("unreadable")} ${unreadable.join(", ")}
4572
+ process.stderr.write(`${pc12.yellow("unreadable")} ${unreadable.join(", ")}
4122
4573
 
4123
4574
  `);
4124
4575
  }
@@ -4134,7 +4585,7 @@ async function runScanDocs(opts) {
4134
4585
  const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
4135
4586
  process.stderr.write(
4136
4587
  `
4137
- ${pc10.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc10.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
4588
+ ${pc12.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc12.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
4138
4589
  `
4139
4590
  );
4140
4591
  return 0;
@@ -4144,13 +4595,13 @@ function formatNode4(node) {
4144
4595
  }
4145
4596
 
4146
4597
  // src/cli/commands/scan-session.ts
4147
- import pc11 from "picocolors";
4598
+ import pc13 from "picocolors";
4148
4599
  async function runScanSession(opts) {
4149
4600
  const repo = await readRepoInfo(opts.cwd);
4150
4601
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
4151
4602
  const turns = await collectClaudeCodeTranscripts(repo.root);
4152
4603
  if (turns.length === 0) {
4153
- process.stderr.write(`${pc11.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
4604
+ process.stderr.write(`${pc13.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
4154
4605
  `);
4155
4606
  return 0;
4156
4607
  }
@@ -4158,7 +4609,7 @@ async function runScanSession(opts) {
4158
4609
  const settled = selectSettledSessions(sessions, opts.settleMinutes);
4159
4610
  if (!opts.json) {
4160
4611
  process.stderr.write(
4161
- `${pc11.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
4612
+ `${pc13.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
4162
4613
 
4163
4614
  `
4164
4615
  );
@@ -4184,7 +4635,7 @@ async function runScanSession(opts) {
4184
4635
  }
4185
4636
  for (const preview of previews) {
4186
4637
  process.stdout.write(
4187
- `${pc11.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
4638
+ `${pc13.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
4188
4639
  ${preview.prompt}
4189
4640
 
4190
4641
  `
@@ -4196,7 +4647,7 @@ ${preview.prompt}
4196
4647
  settleMinutes: opts.settleMinutes,
4197
4648
  maxSessions: opts.maxSessions,
4198
4649
  onProgress: (done, total) => {
4199
- if (!opts.json) process.stderr.write(` ${pc11.dim(`summarizing ${done}/${total}`)}
4650
+ if (!opts.json) process.stderr.write(` ${pc13.dim(`summarizing ${done}/${total}`)}
4200
4651
  `);
4201
4652
  }
4202
4653
  });
@@ -4206,21 +4657,21 @@ ${preview.prompt}
4206
4657
  return 0;
4207
4658
  }
4208
4659
  for (const node of result.nodes) {
4209
- process.stdout.write(`${pc11.bold(node.title)}
4210
- ${pc11.dim(node.ts.slice(0, 16).replace("T", " "))}
4660
+ process.stdout.write(`${pc13.bold(node.title)}
4661
+ ${pc13.dim(node.ts.slice(0, 16).replace("T", " "))}
4211
4662
  ${node.body}
4212
4663
 
4213
4664
  `);
4214
4665
  }
4215
4666
  if (result.providerUnavailable) {
4216
4667
  process.stderr.write(
4217
- `${pc11.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
4668
+ `${pc13.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
4218
4669
  `
4219
4670
  );
4220
4671
  return 0;
4221
4672
  }
4222
4673
  process.stderr.write(
4223
- `${pc11.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc11.yellow(`${result.failed} failed`)}` : "") + ` ${pc11.dim(`(model ${opts.model})`)}
4674
+ `${pc13.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc13.yellow(`${result.failed} failed`)}` : "") + ` ${pc13.dim(`(model ${opts.model})`)}
4224
4675
  `
4225
4676
  );
4226
4677
  return 0;
@@ -4228,16 +4679,16 @@ ${node.body}
4228
4679
  var SCAN_SESSION_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
4229
4680
 
4230
4681
  // src/cli/commands/scan-shell.ts
4231
- import pc12 from "picocolors";
4682
+ import pc14 from "picocolors";
4232
4683
  async function runScanShell(opts) {
4233
4684
  const repo = await readRepoInfo(opts.cwd);
4234
4685
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
4235
4686
  const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
4236
4687
  if (!opts.json) {
4237
4688
  process.stderr.write(
4238
- results.length ? `${pc12.dim("sources found")} ${results.map((r) => r.name).join(", ")}
4689
+ results.length ? `${pc14.dim("sources found")} ${results.map((r) => r.name).join(", ")}
4239
4690
 
4240
- ` : `${pc12.yellow("no shell history source found on this machine")}
4691
+ ` : `${pc14.yellow("no shell history source found on this machine")}
4241
4692
  `
4242
4693
  );
4243
4694
  }
@@ -4246,7 +4697,7 @@ async function runScanShell(opts) {
4246
4697
  const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
4247
4698
  allNodes.push(...nodes);
4248
4699
  if (!opts.json) {
4249
- process.stdout.write(`${pc12.bold(`shell:${result.name}`)} ${pc12.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
4700
+ process.stdout.write(`${pc14.bold(`shell:${result.name}`)} ${pc14.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
4250
4701
  `);
4251
4702
  for (const node of nodes) process.stdout.write(`${formatNode5(node)}
4252
4703
  `);
@@ -4259,20 +4710,47 @@ async function runScanShell(opts) {
4259
4710
  return 0;
4260
4711
  }
4261
4712
  const approxTotal = allNodes.reduce((n, x) => n + approxTokens(x.body), 0);
4262
- process.stderr.write(`${pc12.bold(String(allNodes.length))} node(s) total ${pc12.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
4713
+ process.stderr.write(`${pc14.bold(String(allNodes.length))} node(s) total ${pc14.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
4263
4714
  `);
4264
4715
  return 0;
4265
4716
  }
4266
4717
  function formatNode5(node) {
4267
- const approx = node.meta.tsApprox ? pc12.dim("~") : " ";
4718
+ const approx = node.meta.tsApprox ? pc14.dim("~") : " ";
4268
4719
  const exit = node.meta.exitCode;
4269
- const exitLabel = typeof exit === "number" && exit !== 0 ? pc12.red(`exit ${exit}`) : "";
4720
+ const exitLabel = typeof exit === "number" && exit !== 0 ? pc14.red(`exit ${exit}`) : "";
4270
4721
  return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
4271
4722
  }
4272
4723
 
4724
+ // src/cli/commands/scan-structure.ts
4725
+ import pc15 from "picocolors";
4726
+ async function runScanStructure(opts) {
4727
+ const repo = await readRepoInfo(opts.cwd);
4728
+ const { edges, filesScanned, unreadable } = await collectFileEdges(repo.root);
4729
+ if (opts.json) {
4730
+ process.stdout.write(`${JSON.stringify(edges, null, 2)}
4731
+ `);
4732
+ return 0;
4733
+ }
4734
+ if (unreadable.length > 0) {
4735
+ process.stderr.write(`${pc15.yellow("unreadable")} ${unreadable.join(", ")}
4736
+
4737
+ `);
4738
+ }
4739
+ for (const edge of edges) {
4740
+ process.stdout.write(`${edge.fromPath} ${pc15.dim("->")} ${edge.toPath}
4741
+ `);
4742
+ }
4743
+ process.stderr.write(
4744
+ `
4745
+ ${pc15.bold(String(edges.length))} edge(s) from ${filesScanned} tracked .ts/.tsx/.js/.jsx file(s)
4746
+ `
4747
+ );
4748
+ return 0;
4749
+ }
4750
+
4273
4751
  // src/cli/commands/status.ts
4274
4752
  import { statSync } from "fs";
4275
- import pc13 from "picocolors";
4753
+ import pc16 from "picocolors";
4276
4754
  function humanBytes(bytes) {
4277
4755
  if (bytes < 1024) return `${bytes} B`;
4278
4756
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -4286,6 +4764,7 @@ function fileSize(path) {
4286
4764
  }
4287
4765
  }
4288
4766
  async function runStatus(opts) {
4767
+ const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
4289
4768
  const { repo, ws, projectId } = await loadContext(opts.cwd);
4290
4769
  const store = MemoryStore.open(ws.dbPath);
4291
4770
  try {
@@ -4293,28 +4772,38 @@ async function runStatus(opts) {
4293
4772
  const sources = store.listSyncState(projectId);
4294
4773
  const gitCursor = sources.find((s) => s.source === "git")?.cursor ?? null;
4295
4774
  const schema = currentSchemaVersion(store.raw);
4775
+ const chains = getChainStats(store, projectId);
4776
+ const otherProjectIds = store.listOtherProjectIds(projectId);
4777
+ const otherProjectNodes = store.countProjectNodes(otherProjectIds);
4778
+ const structure = store.fileEdgeStats(projectId);
4296
4779
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
4297
4780
  const kinds = Object.entries(stats.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
4298
- process.stdout.write(
4781
+ const staleProjectWarning = otherProjectIds.length ? `${pc16.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc16.bold(
4782
+ "nexusmem sync --prune-source <name>"
4783
+ )} to remove stale source data` : "";
4784
+ out(
4299
4785
  [
4300
- `${pc13.dim("repo ")} ${repo.root}`,
4301
- `${pc13.dim("branch ")} ${repo.branch ?? pc13.yellow("(detached)")}`,
4302
- `${pc13.dim("project ")} ${pc13.cyan(projectId)}`,
4303
- `${pc13.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc13.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
4304
- `${pc13.dim("database")} ${ws.dbPath} ${pc13.dim(`(${humanBytes(dbBytes)})`)}`,
4786
+ `${pc16.dim("repo ")} ${repo.root}`,
4787
+ `${pc16.dim("branch ")} ${repo.branch ?? pc16.yellow("(detached)")}`,
4788
+ `${pc16.dim("project ")} ${pc16.cyan(projectId)}`,
4789
+ `${pc16.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc16.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
4790
+ `${pc16.dim("database")} ${ws.dbPath} ${pc16.dim(`(${humanBytes(dbBytes)})`)}`,
4791
+ staleProjectWarning,
4305
4792
  "",
4306
- `${pc13.bold(String(stats.total))} node(s)${stats.total ? ` ${pc13.dim(`${stats.oldest?.slice(0, 10)} .. ${stats.newest?.slice(0, 10)}`)}` : ""}`,
4793
+ `${pc16.bold(String(stats.total))} node(s)${stats.total ? ` ${pc16.dim(`${stats.oldest?.slice(0, 10)} .. ${stats.newest?.slice(0, 10)}`)}` : ""}`,
4307
4794
  ...kinds,
4308
- stats.total ? ` ${pc13.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
4795
+ stats.total ? ` ${pc16.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
4309
4796
  "",
4310
- sources.length ? pc13.dim("sources") : pc13.yellow("no sources synced yet"),
4797
+ sources.length ? pc16.dim("sources") : pc16.yellow("no sources synced yet"),
4311
4798
  ...sources.map((s) => {
4312
4799
  const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
4313
4800
  const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
4314
- return ` ${s.source.padEnd(14)} ${pc13.dim(`last run ${when}`)} ${pc13.dim(`cursor ${cursorLabel}`)}`;
4801
+ return ` ${s.source.padEnd(14)} ${pc16.dim(`last run ${when}`)} ${pc16.dim(`cursor ${cursorLabel}`)}`;
4315
4802
  }),
4316
- gitCursor && gitCursor !== repo.head ? `${pc13.yellow("git behind HEAD")} \u2014 run ${pc13.bold("nexusmem sync")}` : "",
4317
- ""
4803
+ gitCursor && gitCursor !== repo.head ? `${pc16.yellow("git behind HEAD")} \u2014 run ${pc16.bold("nexusmem sync")}` : "",
4804
+ "",
4805
+ chains.failuresTotal ? `${pc16.dim("chains ")} ${pc16.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc16.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc16.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
4806
+ structure.edges ? `${pc16.dim("structure")} ${pc16.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : ""
4318
4807
  ].filter((line) => line !== "").join("\n").concat("\n")
4319
4808
  );
4320
4809
  return 0;
@@ -4329,7 +4818,7 @@ function isExpected(err) {
4329
4818
  // the user fixes, not stack traces they debug.
4330
4819
  err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
4331
4820
  // (antivirus, a bad install). Actionable, and not our stack to print.
4332
- err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError;
4821
+ err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError;
4333
4822
  }
4334
4823
  function guard(run) {
4335
4824
  return async () => {
@@ -4337,7 +4826,7 @@ function guard(run) {
4337
4826
  process.exitCode = await run();
4338
4827
  } catch (err) {
4339
4828
  if (isExpected(err)) {
4340
- process.stderr.write(`${pc14.red("error")} ${err.message}
4829
+ process.stderr.write(`${pc17.red("error")} ${err.message}
4341
4830
  `);
4342
4831
  process.exitCode = 1;
4343
4832
  return;
@@ -4390,6 +4879,14 @@ program.command("hook").description("Manage the opt-in PowerShell hook that logs
4390
4879
  new Command("remove").description("Remove the hook block from your PowerShell profile").option("--profile <path>", "override the auto-detected $PROFILE path").action((options) => guard(() => runHookRemove({ profile: options.profile }))())
4391
4880
  ).addCommand(
4392
4881
  new Command("status").description("Show whether the hook is installed").option("--profile <path>", "override the auto-detected $PROFILE path").action((options) => guard(() => runHookStatus({ profile: options.profile }))())
4882
+ ).addCommand(
4883
+ new Command("git").description("Manage the opt-in git pre-commit hook that runs `nexusmem precheck` before each commit").addCommand(
4884
+ new Command("install").description("Install (or update) the hook in .git/hooks/pre-commit").option("-C, --cwd <path>", "repository path", process.cwd()).option("--force", "append after an existing foreign pre-commit hook instead of refusing", false).action((options) => guard(() => runHookGitInstall({ cwd: options.cwd, force: options.force }))())
4885
+ ).addCommand(
4886
+ new Command("remove").description("Remove nexusmem's block from .git/hooks/pre-commit").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runHookGitRemove({ cwd: options.cwd }))())
4887
+ ).addCommand(
4888
+ 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 }))())
4889
+ )
4393
4890
  );
4394
4891
  program.command("status").description("Show what is currently remembered for this repository").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runStatus({ cwd: options.cwd }))());
4395
4892
  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("--json", "emit the packed result as JSON on stdout", false).action(
@@ -4451,10 +4948,22 @@ program.command("scan-session").description("Preview the session summaries a loc
4451
4948
  )()
4452
4949
  );
4453
4950
  program.command("scan-docs").description("Preview the MemoryNodes tracked .md files would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--min-signal <score>", "drop nodes below this signal", (v) => Number.parseFloat(v), 0).option("--json", "emit MemoryNodes as JSON on stdout", false).action((options) => guard(() => runScanDocs({ cwd: options.cwd, minSignal: options.minSignal, json: options.json }))());
4951
+ program.command("precheck").description("Warn about staged files with unresolved past failures or high recent churn (advisory, exits 0 unless --strict)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--files <paths...>", "check exactly these repo-relative paths instead of what is staged").option("--working", "check the working tree (unstaged changes) instead of what is staged for commit", false).option("--strict", "exit 1 when any file has an unresolved failure", false).option("-q, --quiet", "only print output when there is something to warn about", false).action(
4952
+ (options) => guard(
4953
+ () => runPrecheck({
4954
+ cwd: options.cwd,
4955
+ files: options.files,
4956
+ working: options.working,
4957
+ strict: options.strict,
4958
+ quiet: options.quiet
4959
+ })
4960
+ )()
4961
+ );
4962
+ program.command("scan-structure").description("Preview the JS/TS import-graph edges a sync would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--json", "emit edges as JSON on stdout", false).action((options) => guard(() => runScanStructure({ cwd: options.cwd, json: options.json }))());
4454
4963
  program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
4455
4964
  program.parseAsync(process.argv).catch((err) => {
4456
4965
  const message = err instanceof Error ? err.message : String(err);
4457
- process.stderr.write(`${pc14.red("error")} ${message}
4966
+ process.stderr.write(`${pc17.red("error")} ${message}
4458
4967
  `);
4459
4968
  process.exitCode = 1;
4460
4969
  });