nexusmem 0.3.3 → 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
@@ -820,10 +974,29 @@ CREATE TABLE node_links (
820
974
  -- direction yet, so only the forward lookup gets an index.
821
975
  CREATE INDEX idx_node_links_from ON node_links (from_node_id);
822
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
+ `;
823
995
  var MIGRATIONS = [
824
996
  { version: 1, up: (db) => db.exec(V1) },
825
997
  { version: 2, up: (db) => db.exec(V2) },
826
- { version: 3, up: (db) => db.exec(V3) }
998
+ { version: 3, up: (db) => db.exec(V3) },
999
+ { version: 4, up: (db) => db.exec(V4) }
827
1000
  ];
828
1001
  var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
829
1002
  function currentSchemaVersion(db) {
@@ -852,7 +1025,7 @@ var MemoryStore = class _MemoryStore {
852
1025
  }
853
1026
  db;
854
1027
  static open(dbPath) {
855
- mkdirSync(dirname2(dbPath), { recursive: true });
1028
+ mkdirSync(dirname3(dbPath), { recursive: true });
856
1029
  const db = new Database(dbPath);
857
1030
  db.pragma("journal_mode = WAL");
858
1031
  db.pragma("synchronous = NORMAL");
@@ -888,6 +1061,13 @@ var MemoryStore = class _MemoryStore {
888
1061
  (r) => r.id
889
1062
  );
890
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
+ }
891
1071
  /**
892
1072
  * Write a batch of nodes in one transaction.
893
1073
  *
@@ -1091,6 +1271,29 @@ var MemoryStore = class _MemoryStore {
1091
1271
  return this.db.prepare(`DELETE FROM nodes WHERE ${scope}`).run(params).changes;
1092
1272
  })();
1093
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
+ }
1094
1297
  /**
1095
1298
  * Nodes for this project that have no embedding yet (new, or invalidated
1096
1299
  * by a content change).
@@ -1214,9 +1417,9 @@ async function runInit(opts) {
1214
1417
  if (already && !opts.force) {
1215
1418
  const existing = await readConfig(ws);
1216
1419
  process.stderr.write(
1217
- `${pc2.yellow("already initialized")} ${relative(process.cwd(), ws.configPath) || ws.configPath}
1218
- project ${pc2.cyan(existing.projectId)}
1219
- 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)
1220
1423
  `
1221
1424
  );
1222
1425
  return 0;
@@ -1233,14 +1436,14 @@ async function runInit(opts) {
1233
1436
  }
1234
1437
  await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
1235
1438
  const lines = [
1236
- `${pc2.green("initialized")} ${ws.dir}`,
1237
- ` project ${pc2.cyan(projectId)}`,
1439
+ `${pc3.green("initialized")} ${ws.dir}`,
1440
+ ` project ${pc3.cyan(projectId)}`,
1238
1441
  ` repo ${repo.root}`,
1239
- ` branch ${repo.branch ?? pc2.yellow("(detached)")}`,
1442
+ ` branch ${repo.branch ?? pc3.yellow("(detached)")}`,
1240
1443
  ` schema v${LATEST_SCHEMA_VERSION}`
1241
1444
  ];
1242
1445
  if (opts.enableConversation) {
1243
- 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`);
1244
1447
  }
1245
1448
  if (opts.hook) {
1246
1449
  try {
@@ -1248,26 +1451,26 @@ async function runInit(opts) {
1248
1451
  const result = await installHook(target);
1249
1452
  lines.push(
1250
1453
  "",
1251
- `${pc2.green(result.changed ? "installed" : "already installed")} shell hook`,
1454
+ `${pc3.green(result.changed ? "installed" : "already installed")} shell hook`,
1252
1455
  ` profile ${target.profilePath}`,
1253
1456
  ` log ${target.logPath}`,
1254
1457
  ` open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect`
1255
1458
  );
1256
1459
  } catch (err) {
1257
1460
  if (err instanceof ProfileNotFoundError) {
1258
- lines.push("", `${pc2.yellow("hook not installed")} ${err.message}`);
1461
+ lines.push("", `${pc3.yellow("hook not installed")} ${err.message}`);
1259
1462
  } else {
1260
1463
  throw err;
1261
1464
  }
1262
1465
  }
1263
1466
  }
1264
- lines.push("", `Next: ${pc2.bold("nexusmem sync")}`, "");
1467
+ lines.push("", `Next: ${pc3.bold("nexusmem sync")}`, "");
1265
1468
  out(lines.join("\n"));
1266
1469
  return 0;
1267
1470
  }
1268
1471
 
1269
1472
  // src/cli/commands/projects.ts
1270
- import pc3 from "picocolors";
1473
+ import pc4 from "picocolors";
1271
1474
  async function runProjects(opts) {
1272
1475
  const { entries, missing } = await readLiveRegistry();
1273
1476
  const rows = entries.map((entry) => {
@@ -1286,7 +1489,7 @@ async function runProjects(opts) {
1286
1489
  });
1287
1490
  if (opts.prune) {
1288
1491
  const removed = await forgetProjects(missing.map((entry) => entry.projectId));
1289
- 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
1290
1493
  `);
1291
1494
  }
1292
1495
  if (opts.json) {
@@ -1294,28 +1497,28 @@ async function runProjects(opts) {
1294
1497
  `);
1295
1498
  return 0;
1296
1499
  }
1297
- process.stderr.write(`${pc3.dim("registry")} ${registryPath()}
1500
+ process.stderr.write(`${pc4.dim("registry")} ${registryPath()}
1298
1501
 
1299
1502
  `);
1300
1503
  if (rows.length === 0) {
1301
- 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
1302
1505
  `);
1303
1506
  return 0;
1304
1507
  }
1305
1508
  for (const row of rows) {
1306
1509
  const seen = new Date(row.lastSeenAt).toISOString().slice(0, 16).replace("T", " ");
1307
- const count = row.nodes === null ? pc3.yellow("unreadable") : `${row.nodes} node(s)`;
1308
- process.stdout.write(`${pc3.cyan(row.projectId.slice(0, 8))} ${row.root}
1309
- ${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}`)}
1310
1513
  `);
1311
1514
  }
1312
1515
  if (!opts.prune && missing.length > 0) {
1313
1516
  process.stderr.write(
1314
1517
  `
1315
- ${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")}
1316
1519
  `
1317
1520
  );
1318
- 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)}
1319
1522
  `);
1320
1523
  }
1321
1524
  return 0;
@@ -1518,16 +1721,18 @@ function normalizeCommand(command) {
1518
1721
  }
1519
1722
  var MAX_TOKEN_DOC_FREQUENCY = 0.2;
1520
1723
  var MIN_CORPUS_FOR_FREQUENCY_FILTER = 10;
1521
- function filterBoilerplateTokens(db, projectId, tokens) {
1724
+ var DEFAULT_BOILERPLATE_KINDS = ["conversation_turn", "session_summary"];
1725
+ function filterBoilerplateTokens(db, projectId, tokens, kinds = DEFAULT_BOILERPLATE_KINDS) {
1522
1726
  if (tokens.length === 0) return tokens;
1523
- const total = db.prepare(`SELECT COUNT(*) AS c FROM nodes WHERE project_id = ? AND kind IN ('conversation_turn', 'session_summary')`).get(projectId).c;
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;
1524
1729
  if (total < MIN_CORPUS_FOR_FREQUENCY_FILTER) return tokens;
1525
1730
  const countMatching = db.prepare(
1526
1731
  `SELECT COUNT(*) AS c FROM nodes_fts JOIN nodes n ON n.rowid = nodes_fts.rowid
1527
- WHERE nodes_fts MATCH ? AND n.project_id = ? AND n.kind IN ('conversation_turn', 'session_summary')`
1732
+ WHERE nodes_fts MATCH ? AND n.project_id = ? AND n.kind IN (${kindsPlaceholder})`
1528
1733
  );
1529
1734
  return tokens.filter((t) => {
1530
- const matching = countMatching.get(`"${t}"*`, projectId).c;
1735
+ const matching = countMatching.get(`"${t}"*`, projectId, ...kinds).c;
1531
1736
  return matching / total <= MAX_TOKEN_DOC_FREQUENCY;
1532
1737
  });
1533
1738
  }
@@ -1863,7 +2068,7 @@ var OllamaEmbeddingProvider = class {
1863
2068
  };
1864
2069
 
1865
2070
  // src/cli/commands/sync.ts
1866
- import pc4 from "picocolors";
2071
+ import pc5 from "picocolors";
1867
2072
 
1868
2073
  // src/conversation/chunk.ts
1869
2074
  var HEADING_LINE = /^#{1,6}\s+(.+)$/;
@@ -2851,25 +3056,25 @@ function collectShellHistory(entries, projectId, opts = {}) {
2851
3056
  }
2852
3057
 
2853
3058
  // src/conversation/claude-code-reader.ts
2854
- import { readFile as readFile4 } from "fs/promises";
3059
+ import { readFile as readFile5 } from "fs/promises";
2855
3060
  import { basename as basename2 } from "path";
2856
3061
 
2857
3062
  // src/conversation/paths.ts
2858
3063
  import { existsSync as existsSync3 } from "fs";
2859
3064
  import { readdir } from "fs/promises";
2860
3065
  import { homedir as homedir3 } from "os";
2861
- import { join as join5 } from "path";
3066
+ import { join as join6 } from "path";
2862
3067
  function claudeProjectSlug(repoRoot) {
2863
3068
  return repoRoot.replace(/[\\/:]/g, "-");
2864
3069
  }
2865
3070
  function claudeProjectTranscriptDir(repoRoot) {
2866
- return join5(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
3071
+ return join6(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
2867
3072
  }
2868
3073
  async function listTranscriptFiles(repoRoot) {
2869
3074
  const dir = claudeProjectTranscriptDir(repoRoot);
2870
3075
  if (!existsSync3(dir)) return [];
2871
3076
  const entries = await readdir(dir, { withFileTypes: true });
2872
- 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));
2873
3078
  }
2874
3079
 
2875
3080
  // src/conversation/claude-code-reader.ts
@@ -2944,15 +3149,15 @@ async function collectClaudeCodeTranscripts(repoRoot) {
2944
3149
  const files = await listTranscriptFiles(repoRoot);
2945
3150
  const turns = [];
2946
3151
  for (const file of files) {
2947
- const raw = await readFile4(file, "utf8");
3152
+ const raw = await readFile5(file, "utf8");
2948
3153
  turns.push(...parseClaudeCodeTranscript(raw, { sessionId: basename2(file, ".jsonl") }));
2949
3154
  }
2950
3155
  return turns;
2951
3156
  }
2952
3157
 
2953
3158
  // src/docs/read.ts
2954
- import { readFile as readFile5, stat } from "fs/promises";
2955
- import { join as join6 } from "path";
3159
+ import { readFile as readFile6, stat } from "fs/promises";
3160
+ import { join as join7 } from "path";
2956
3161
  var DEFAULT_PATHSPECS = ["*.md"];
2957
3162
  async function listDocFiles(repoRoot, opts = {}) {
2958
3163
  const pathspecs = opts.include ?? DEFAULT_PATHSPECS;
@@ -2965,11 +3170,11 @@ async function readDocFiles(repoRoot, opts = {}) {
2965
3170
  const unreadable = [];
2966
3171
  for (const relPath of paths) {
2967
3172
  const path = relPath.replace(/\\/g, "/");
2968
- const absPath = join6(repoRoot, relPath);
3173
+ const absPath = join7(repoRoot, relPath);
2969
3174
  let content;
2970
3175
  let mtime;
2971
3176
  try {
2972
- [content, { mtime }] = await Promise.all([readFile5(absPath, "utf8"), stat(absPath)]);
3177
+ [content, { mtime }] = await Promise.all([readFile6(absPath, "utf8"), stat(absPath)]);
2973
3178
  } catch {
2974
3179
  unreadable.push(path);
2975
3180
  continue;
@@ -2981,11 +3186,11 @@ async function readDocFiles(repoRoot, opts = {}) {
2981
3186
 
2982
3187
  // src/shell/detect.ts
2983
3188
  import { existsSync as existsSync4 } from "fs";
2984
- import { readFile as readFile7, stat as stat2 } from "fs/promises";
3189
+ import { readFile as readFile8, stat as stat2 } from "fs/promises";
2985
3190
 
2986
3191
  // src/shell/hook-log.ts
2987
- import { appendFile, mkdir as mkdir4, readFile as readFile6 } from "fs/promises";
2988
- 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";
2989
3194
  function parseHookLogLine(line) {
2990
3195
  const trimmed = line.trim();
2991
3196
  if (!trimmed) return null;
@@ -3009,7 +3214,7 @@ function parseHookLogLine(line) {
3009
3214
  async function readHookLog(path, fromLine) {
3010
3215
  let raw;
3011
3216
  try {
3012
- raw = await readFile6(path, "utf8");
3217
+ raw = await readFile7(path, "utf8");
3013
3218
  } catch {
3014
3219
  return { entries: [], totalLines: fromLine };
3015
3220
  }
@@ -3143,7 +3348,7 @@ function hookEntryToRaw(e) {
3143
3348
  }
3144
3349
  async function tryReadScrapeSource(path, parse, tailLines) {
3145
3350
  if (!existsSync4(path)) return null;
3146
- const [raw, stats] = await Promise.all([readFile7(path, "utf8"), stat2(path)]);
3351
+ const [raw, stats] = await Promise.all([readFile8(path, "utf8"), stat2(path)]);
3147
3352
  return parse(raw, stats.mtimeMs, { tailLines });
3148
3353
  }
3149
3354
  async function collectAvailableShellHistory(opts = {}) {
@@ -3264,6 +3469,101 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
3264
3469
  })();
3265
3470
  }
3266
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
+
3267
3567
  // src/vector/sync.ts
3268
3568
  var EMBEDDING_IDENTITY_KEY = "embedding.identity";
3269
3569
  var DEFAULT_BATCH_SIZE = 32;
@@ -3358,25 +3658,25 @@ function addStats(into, from) {
3358
3658
  async function syncGit(store, projectId, opts, repo, config, log) {
3359
3659
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
3360
3660
  if (!repo.head) {
3361
- log(`${pc4.yellow("git")} skipped -- repository has no commits yet`);
3661
+ log(`${pc5.yellow("git")} skipped -- repository has no commits yet`);
3362
3662
  return { totals, seen: 0 };
3363
3663
  }
3364
3664
  if (!config.sources.git.enabled) {
3365
- log(`${pc4.dim("git")} disabled in config`);
3665
+ log(`${pc5.dim("git")} disabled in config`);
3366
3666
  return { totals, seen: 0 };
3367
3667
  }
3368
3668
  let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, GIT_SOURCE);
3369
3669
  if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
3370
- 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`);
3371
3671
  cursor = null;
3372
3672
  }
3373
3673
  if (cursor === repo.head) {
3374
- 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)}`);
3375
3675
  store.setSyncCursor(projectId, GIT_SOURCE, repo.head);
3376
3676
  return { totals, seen: 0 };
3377
3677
  }
3378
3678
  log(
3379
- `${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)"}`
3380
3680
  );
3381
3681
  let batch = [];
3382
3682
  let seen = 0;
@@ -3384,7 +3684,7 @@ async function syncGit(store, projectId, opts, repo, config, log) {
3384
3684
  if (batch.length === 0) return;
3385
3685
  addStats(totals, store.upsertNodes(batch));
3386
3686
  batch = [];
3387
- log(` ${pc4.dim(`${seen} commits read, ${totals.inserted} new`)}`);
3687
+ log(` ${pc5.dim(`${seen} commits read, ${totals.inserted} new`)}`);
3388
3688
  };
3389
3689
  const nodes = collectGitCommits(repo.root, projectId, {
3390
3690
  afterCommit: cursor,
@@ -3406,12 +3706,12 @@ async function syncDiffs(store, projectId, opts, repo, config, log) {
3406
3706
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
3407
3707
  if (!repo.head) return { totals, seen: 0 };
3408
3708
  if (!config.sources.diff.enabled) {
3409
- log(`${pc4.dim("diff")} disabled in config`);
3709
+ log(`${pc5.dim("diff")} disabled in config`);
3410
3710
  return { totals, seen: 0 };
3411
3711
  }
3412
3712
  let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, DIFF_SOURCE);
3413
3713
  if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
3414
- 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`);
3415
3715
  cursor = null;
3416
3716
  }
3417
3717
  if (cursor === repo.head) {
@@ -3440,13 +3740,13 @@ async function syncDiffs(store, projectId, opts, repo, config, log) {
3440
3740
  }
3441
3741
  flush();
3442
3742
  store.setSyncCursor(projectId, DIFF_SOURCE, repo.head);
3443
- log(` ${pc4.dim(`${DIFF_SOURCE}: ${seen} file diff(s) read`)}`);
3743
+ log(` ${pc5.dim(`${DIFF_SOURCE}: ${seen} file diff(s) read`)}`);
3444
3744
  return { totals, seen };
3445
3745
  }
3446
3746
  async function syncShell(store, projectId, opts, repoRoot, config, log) {
3447
3747
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
3448
3748
  if (!config.sources.shell.enabled) {
3449
- log(`${pc4.dim("shell")} disabled in config`);
3749
+ log(`${pc5.dim("shell")} disabled in config`);
3450
3750
  return { totals, seen: 0 };
3451
3751
  }
3452
3752
  const results = await collectAvailableShellHistory({
@@ -3455,7 +3755,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
3455
3755
  hookCursor: store.getSyncCursor(projectId, "shell:pwsh-hook")
3456
3756
  });
3457
3757
  if (results.length === 0) {
3458
- log(`${pc4.dim("shell")} no history source found on this machine`);
3758
+ log(`${pc5.dim("shell")} no history source found on this machine`);
3459
3759
  return { totals, seen: 0 };
3460
3760
  }
3461
3761
  let seen = 0;
@@ -3467,7 +3767,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
3467
3767
  addStats(totals, store.upsertNodes(nodes));
3468
3768
  }
3469
3769
  store.setSyncCursor(projectId, sourceKey, result.cursorAfter ?? `scanned:${result.entries.length}`);
3470
- 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`)}`);
3471
3771
  }
3472
3772
  return { totals, seen };
3473
3773
  }
@@ -3479,13 +3779,13 @@ function syncConversation(store, projectId, turns, config, log, forceEnabled) {
3479
3779
  return { totals, seen: 0 };
3480
3780
  }
3481
3781
  if (turns.length === 0) {
3482
- log(`${pc4.dim("conversation")} no transcripts found`);
3782
+ log(`${pc5.dim("conversation")} no transcripts found`);
3483
3783
  return { totals, seen: 0 };
3484
3784
  }
3485
3785
  const nodes = collectConversationTurns(turns, projectId, { maxBodyChars: config.limits.maxBodyChars });
3486
3786
  if (nodes.length > 0) addStats(totals, store.upsertNodes(nodes));
3487
3787
  store.setSyncCursor(projectId, CONVERSATION_SOURCE, `scanned:${nodes.length}`);
3488
- 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`)}`);
3489
3789
  return { totals, seen: nodes.length };
3490
3790
  }
3491
3791
  var SESSION_SOURCE = "session:claude-code";
@@ -3494,7 +3794,7 @@ async function syncSessions(store, projectId, turns, config, log) {
3494
3794
  const settings = config.sources.session;
3495
3795
  if (!settings.enabled) return { totals, seen: 0 };
3496
3796
  if (turns.length === 0) {
3497
- log(`${pc4.dim("session")} no transcripts found`);
3797
+ log(`${pc5.dim("session")} no transcripts found`);
3498
3798
  return { totals, seen: 0 };
3499
3799
  }
3500
3800
  const result = await collectSessionSummaries(turns, projectId, new OllamaChatProvider({ model: settings.model }), {
@@ -3506,12 +3806,12 @@ async function syncSessions(store, projectId, turns, config, log) {
3506
3806
  const meta = store.getNodeMeta(makeNodeId(projectId, "session_summary", sessionKey));
3507
3807
  return typeof meta?.contentHash === "string" ? meta.contentHash : null;
3508
3808
  },
3509
- onProgress: (done, total) => log(` ${pc4.dim(`session: summarizing ${done}/${total}`)}`)
3809
+ onProgress: (done, total) => log(` ${pc5.dim(`session: summarizing ${done}/${total}`)}`)
3510
3810
  });
3511
3811
  if (result.nodes.length > 0) addStats(totals, store.upsertNodes(result.nodes));
3512
3812
  if (result.providerUnavailable) {
3513
3813
  log(
3514
- `${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`
3515
3815
  );
3516
3816
  } else {
3517
3817
  const parts = [`${result.nodes.length} summarized`];
@@ -3519,7 +3819,7 @@ async function syncSessions(store, projectId, turns, config, log) {
3519
3819
  if (result.deferred > 0) parts.push(`${result.deferred} queued for the next sync`);
3520
3820
  if (result.unsettled > 0) parts.push(`${result.unsettled} still active`);
3521
3821
  if (result.failed > 0) parts.push(`${result.failed} failed`);
3522
- log(` ${pc4.dim(`${SESSION_SOURCE}: ${parts.join(", ")}`)}`);
3822
+ log(` ${pc5.dim(`${SESSION_SOURCE}: ${parts.join(", ")}`)}`);
3523
3823
  }
3524
3824
  store.setSyncCursor(projectId, SESSION_SOURCE, `scanned:${result.nodes.length}`);
3525
3825
  return { totals, seen: result.nodes.length };
@@ -3528,7 +3828,7 @@ var DOCS_SOURCE = "docs";
3528
3828
  async function syncDocs(store, projectId, repoRoot, config, log) {
3529
3829
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
3530
3830
  if (!config.sources.docs.enabled) {
3531
- log(`${pc4.dim("docs")} disabled in config`);
3831
+ log(`${pc5.dim("docs")} disabled in config`);
3532
3832
  return { totals, seen: 0 };
3533
3833
  }
3534
3834
  const { files, unreadable } = await readDocFiles(repoRoot, { include: config.sources.docs.include });
@@ -3542,14 +3842,25 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
3542
3842
  );
3543
3843
  store.setSyncCursor(projectId, DOCS_SOURCE, `scanned:${nodes.length}`);
3544
3844
  if (files.length === 0 && unreadable.length === 0) {
3545
- log(`${pc4.dim("docs")} no tracked .md files found`);
3845
+ log(`${pc5.dim("docs")} no tracked .md files found`);
3546
3846
  } else {
3547
- const prunedPart = pruned > 0 ? `, ${pc4.yellow(`${pruned} stale removed`)}` : "";
3847
+ const prunedPart = pruned > 0 ? `, ${pc5.yellow(`${pruned} stale removed`)}` : "";
3548
3848
  const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (kept)` : "";
3549
- 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)}`);
3550
3850
  }
3551
3851
  return { totals, seen: nodes.length };
3552
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
+ }
3553
3864
  var STALE_SHELL_SOURCES = ["shell:pwsh", "shell:bash", "shell:zsh"];
3554
3865
  function collectPruneSources(opts) {
3555
3866
  const sources = /* @__PURE__ */ new Set();
@@ -3564,15 +3875,15 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
3564
3875
  const counts = sources.flatMap((source) => scopeIds.map((id) => ({ source, id, count: store.countSourceNodes(id, source) })));
3565
3876
  const total = counts.reduce((sum, c) => sum + c.count, 0);
3566
3877
  if (total === 0) {
3567
- 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
3568
3879
  `);
3569
3880
  return 0;
3570
3881
  }
3571
- 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)`;
3572
3883
  if (!yes) {
3573
3884
  const lines = counts.filter((c) => c.count > 0).map(describe);
3574
3885
  out(
3575
- [`${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(
3576
3887
  "\n"
3577
3888
  )
3578
3889
  );
@@ -3581,7 +3892,7 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
3581
3892
  let removed = 0;
3582
3893
  for (const { source, id } of counts) removed += store.pruneSourceNodes(id, source, []);
3583
3894
  const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
3584
- 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}
3585
3896
  `);
3586
3897
  return 0;
3587
3898
  }
@@ -3598,7 +3909,7 @@ async function runSync(opts) {
3598
3909
  store.upsertProject({ id: projectId, root: repo.root, originUrl: repo.originUrl });
3599
3910
  if (opts.rebuild) {
3600
3911
  const removed = store.clearProject(projectId);
3601
- log(`${pc4.dim("rebuild")} dropped ${removed} existing node(s)`);
3912
+ log(`${pc5.dim("rebuild")} dropped ${removed} existing node(s)`);
3602
3913
  }
3603
3914
  const staleProjectIds = store.listOtherProjectIds(projectId);
3604
3915
  for (const staleId of staleProjectIds) {
@@ -3611,7 +3922,7 @@ async function runSync(opts) {
3611
3922
  ].filter((part) => part !== null);
3612
3923
  if (parts.length > 0) {
3613
3924
  log(
3614
- `${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(", ")}`
3615
3926
  );
3616
3927
  }
3617
3928
  }
@@ -3635,31 +3946,32 @@ async function runSync(opts) {
3635
3946
  const conversation = syncConversation(store, projectId, turns, config, log, opts.conversationOverride);
3636
3947
  const sessions = await syncSessions(store, projectId, turns, config, log);
3637
3948
  const docs = await syncDocs(store, projectId, repo.root, config, log);
3949
+ const structure = await syncStructure(store, projectId, repo.root, config, log);
3638
3950
  let embedLine = "";
3639
3951
  if (!opts.noEmbed) {
3640
3952
  let lastLogged = 0;
3641
3953
  const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId, {
3642
3954
  maxNodes: opts.embedLimit,
3643
- 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`),
3644
3956
  onProgress: (attempted, total) => {
3645
3957
  if (total < PROGRESS_THRESHOLD || attempted - lastLogged < PROGRESS_EVERY) return;
3646
3958
  lastLogged = attempted;
3647
- log(` ${pc4.dim(`vector: ${attempted}/${total} embedded`)}`);
3959
+ log(` ${pc5.dim(`vector: ${attempted}/${total} embedded`)}`);
3648
3960
  }
3649
3961
  });
3650
3962
  if (result.embedded > 0) {
3651
- const skippedPart = result.skipped > 0 ? pc4.dim(`, ${result.skipped} skipped`) : "";
3652
- const remainingPart = result.remaining > 0 ? pc4.yellow(`, ${result.remaining} still pending`) : "";
3653
- 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}
3654
3966
  `;
3655
3967
  } else if (result.providerUnavailable) {
3656
- 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`);
3657
3969
  }
3658
3970
  }
3659
3971
  let linkLine = "";
3660
3972
  if (opts.linkFailures) {
3661
3973
  const linkStats = correlateFailures(store, projectId);
3662
- 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`)}
3663
3975
  `;
3664
3976
  }
3665
3977
  store.markSynced(projectId);
@@ -3676,11 +3988,12 @@ async function runSync(opts) {
3676
3988
  const sessionPart = config.sources.session.enabled ? `, ${sessions.seen} session summar${sessions.seen === 1 ? "y" : "ies"}` : "";
3677
3989
  const docsPart = config.sources.docs.enabled ? `, ${docs.seen} doc section(s)` : "";
3678
3990
  const diffPart = config.sources.diff.enabled ? `, ${diffs.seen} file diff(s)` : "";
3991
+ const structurePart = config.sources.structure.enabled ? `, ${structure.edges} import edge(s)` : "";
3679
3992
  out(
3680
3993
  [
3681
- `${pc4.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${sessionPart}${docsPart} in ${elapsed}s`,
3682
- ` ${pc4.green(`+${totals.inserted} new`)} ${pc4.yellow(`~${totals.updated} updated`)} ${pc4.dim(`=${totals.unchanged} unchanged`)}`,
3683
- ` ${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)`)}`,
3684
3997
  ""
3685
3998
  ].join("\n") + embedLine + linkLine
3686
3999
  );
@@ -3875,8 +4188,116 @@ async function runMcpServer() {
3875
4188
  await server.connect(transport);
3876
4189
  }
3877
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
+
3878
4299
  // src/cli/commands/query.ts
3879
- import pc5 from "picocolors";
4300
+ import pc7 from "picocolors";
3880
4301
  async function runQuery(opts) {
3881
4302
  const { repo, ws, projectId } = await loadContext(opts.cwd);
3882
4303
  const opened = opts.allProjects ? await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath }) : null;
@@ -3898,15 +4319,15 @@ async function runQuery(opts) {
3898
4319
  const { bm25Count, vectorCount, hits, packed } = result;
3899
4320
  if (opened && !opts.json) {
3900
4321
  const searched = opened.sources.map((s) => s.label).join(", ");
3901
- 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}
3902
4323
  `);
3903
4324
  for (const { entry } of opened.unreadable) {
3904
- process.stderr.write(`${pc5.yellow("unreadable")} ${entry.root} -- skipped
4325
+ process.stderr.write(`${pc7.yellow("unreadable")} ${entry.root} -- skipped
3905
4326
  `);
3906
4327
  }
3907
4328
  if (opened.missing.length > 0) {
3908
4329
  process.stderr.write(
3909
- `${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)")}
3910
4331
  `
3911
4332
  );
3912
4333
  }
@@ -3936,15 +4357,15 @@ async function runQuery(opts) {
3936
4357
  return 0;
3937
4358
  }
3938
4359
  if (matched === 0) {
3939
- process.stderr.write(`${pc5.yellow("no matches")} for "${opts.query}"
4360
+ process.stderr.write(`${pc7.yellow("no matches")} for "${opts.query}"
3940
4361
  `);
3941
4362
  return 0;
3942
4363
  }
3943
4364
  process.stderr.write(
3944
4365
  [
3945
- `${pc5.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc5.bold(String(packed.nodes.length))} into budget`,
3946
- `${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)`) : ""),
3947
- 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)`)}` : "",
3948
4369
  ""
3949
4370
  ].filter(Boolean).join("\n")
3950
4371
  );
@@ -3958,10 +4379,10 @@ async function runQuery(opts) {
3958
4379
  }
3959
4380
 
3960
4381
  // src/cli/commands/scan-conversation.ts
3961
- import pc7 from "picocolors";
4382
+ import pc9 from "picocolors";
3962
4383
 
3963
4384
  // src/cli/format.ts
3964
- import pc6 from "picocolors";
4385
+ import pc8 from "picocolors";
3965
4386
  var GIT_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
3966
4387
  var SHELL_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
3967
4388
  var CONVERSATION_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
@@ -3973,9 +4394,9 @@ function signalBand(signal, bands) {
3973
4394
  return "low";
3974
4395
  }
3975
4396
  var BAND_COLOR = {
3976
- high: pc6.green,
3977
- medium: pc6.yellow,
3978
- low: pc6.dim
4397
+ high: pc8.green,
4398
+ medium: pc8.yellow,
4399
+ low: pc8.dim
3979
4400
  };
3980
4401
  function formatSignal(signal, bands) {
3981
4402
  return BAND_COLOR[signalBand(signal, bands)](signal.toFixed(2));
@@ -3988,9 +4409,9 @@ async function runScanConversation(opts) {
3988
4409
  const files = await listTranscriptFiles(repo.root);
3989
4410
  if (!opts.json) {
3990
4411
  process.stderr.write(
3991
- 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)}
3992
4413
 
3993
- ` : `${pc7.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
4414
+ ` : `${pc9.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
3994
4415
  `
3995
4416
  );
3996
4417
  }
@@ -4007,7 +4428,7 @@ async function runScanConversation(opts) {
4007
4428
  const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
4008
4429
  process.stderr.write(
4009
4430
  `
4010
- ${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"
4011
4432
  );
4012
4433
  return 0;
4013
4434
  }
@@ -4016,20 +4437,20 @@ function formatNode(node) {
4016
4437
  }
4017
4438
 
4018
4439
  // src/cli/commands/scan-diff.ts
4019
- import pc9 from "picocolors";
4440
+ import pc11 from "picocolors";
4020
4441
 
4021
4442
  // src/cli/commands/scan-git.ts
4022
- import pc8 from "picocolors";
4443
+ import pc10 from "picocolors";
4023
4444
  async function runScanGit(opts) {
4024
4445
  const repo = await readRepoInfo(opts.cwd);
4025
4446
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
4026
4447
  if (!opts.json) {
4027
4448
  process.stderr.write(
4028
4449
  [
4029
- `${pc8.dim("repo ")} ${repo.root}`,
4030
- `${pc8.dim("branch ")} ${repo.branch ?? pc8.yellow("(detached)")}`,
4031
- `${pc8.dim("origin ")} ${repo.originUrl ?? pc8.dim("(none)")}`,
4032
- `${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)}`,
4033
4454
  ""
4034
4455
  ].join("\n")
4035
4456
  );
@@ -4063,14 +4484,14 @@ function formatNode2(node) {
4063
4484
  const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
4064
4485
  return [
4065
4486
  formatSignal(node.signal, GIT_SIGNAL_BANDS),
4066
- pc8.dim(date),
4067
- pc8.magenta(sha),
4487
+ pc10.dim(date),
4488
+ pc10.magenta(sha),
4068
4489
  node.title,
4069
- pc8.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
4490
+ pc10.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
4070
4491
  ].join(" ");
4071
4492
  }
4072
4493
  function summarize2(nodes) {
4073
- if (nodes.length === 0) return pc8.yellow("no commits matched");
4494
+ if (nodes.length === 0) return pc10.yellow("no commits matched");
4074
4495
  const timestamps = nodes.map((n) => n.ts).sort();
4075
4496
  const avgSignal = nodes.reduce((n, x) => n + x.signal, 0) / nodes.length;
4076
4497
  const totalTokens = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
@@ -4080,7 +4501,7 @@ function summarize2(nodes) {
4080
4501
  }
4081
4502
  const hottest = [...fileHits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([path, count]) => ` ${String(count).padStart(3)}x ${path}`);
4082
4503
  return [
4083
- `${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)}`)}`,
4084
4505
  ` avg signal ${avgSignal.toFixed(3)} ~${totalTokens.toLocaleString()} tokens if sent raw`,
4085
4506
  hottest.length ? ` hottest files:
4086
4507
  ${hottest.join("\n")}` : ""
@@ -4095,9 +4516,9 @@ async function runScanDiff(opts) {
4095
4516
  if (!opts.json) {
4096
4517
  process.stderr.write(
4097
4518
  [
4098
- `${pc9.dim("repo ")} ${repo.root}`,
4099
- `${pc9.dim("branch ")} ${repo.branch ?? pc9.yellow("(detached)")}`,
4100
- `${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)}`,
4101
4522
  ""
4102
4523
  ].join("\n")
4103
4524
  );
@@ -4127,28 +4548,28 @@ function formatNode3(node) {
4127
4548
  const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
4128
4549
  return [
4129
4550
  formatSignal(node.signal, DIFF_SIGNAL_BANDS),
4130
- pc9.dim(node.ts.slice(0, 10)),
4131
- pc9.magenta(sha),
4551
+ pc11.dim(node.ts.slice(0, 10)),
4552
+ pc11.magenta(sha),
4132
4553
  String(node.meta.path ?? ""),
4133
- pc9.dim(`(${churn}, ${node.meta.hunkCount ?? 0} hunk(s))`)
4554
+ pc11.dim(`(${churn}, ${node.meta.hunkCount ?? 0} hunk(s))`)
4134
4555
  ].join(" ");
4135
4556
  }
4136
4557
 
4137
4558
  // src/cli/commands/scan-docs.ts
4138
- import pc10 from "picocolors";
4559
+ import pc12 from "picocolors";
4139
4560
  async function runScanDocs(opts) {
4140
4561
  const repo = await readRepoInfo(opts.cwd);
4141
4562
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
4142
4563
  const { files, unreadable } = await readDocFiles(repo.root);
4143
4564
  if (!opts.json) {
4144
4565
  process.stderr.write(
4145
- 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(", ")}
4146
4567
 
4147
- ` : `${pc10.yellow("no tracked .md files found")}
4568
+ ` : `${pc12.yellow("no tracked .md files found")}
4148
4569
  `
4149
4570
  );
4150
4571
  if (unreadable.length > 0) {
4151
- process.stderr.write(`${pc10.yellow("unreadable")} ${unreadable.join(", ")}
4572
+ process.stderr.write(`${pc12.yellow("unreadable")} ${unreadable.join(", ")}
4152
4573
 
4153
4574
  `);
4154
4575
  }
@@ -4164,7 +4585,7 @@ async function runScanDocs(opts) {
4164
4585
  const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
4165
4586
  process.stderr.write(
4166
4587
  `
4167
- ${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`)}
4168
4589
  `
4169
4590
  );
4170
4591
  return 0;
@@ -4174,13 +4595,13 @@ function formatNode4(node) {
4174
4595
  }
4175
4596
 
4176
4597
  // src/cli/commands/scan-session.ts
4177
- import pc11 from "picocolors";
4598
+ import pc13 from "picocolors";
4178
4599
  async function runScanSession(opts) {
4179
4600
  const repo = await readRepoInfo(opts.cwd);
4180
4601
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
4181
4602
  const turns = await collectClaudeCodeTranscripts(repo.root);
4182
4603
  if (turns.length === 0) {
4183
- 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)}
4184
4605
  `);
4185
4606
  return 0;
4186
4607
  }
@@ -4188,7 +4609,7 @@ async function runScanSession(opts) {
4188
4609
  const settled = selectSettledSessions(sessions, opts.settleMinutes);
4189
4610
  if (!opts.json) {
4190
4611
  process.stderr.write(
4191
- `${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+)
4192
4613
 
4193
4614
  `
4194
4615
  );
@@ -4214,7 +4635,7 @@ async function runScanSession(opts) {
4214
4635
  }
4215
4636
  for (const preview of previews) {
4216
4637
  process.stdout.write(
4217
- `${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
4218
4639
  ${preview.prompt}
4219
4640
 
4220
4641
  `
@@ -4226,7 +4647,7 @@ ${preview.prompt}
4226
4647
  settleMinutes: opts.settleMinutes,
4227
4648
  maxSessions: opts.maxSessions,
4228
4649
  onProgress: (done, total) => {
4229
- if (!opts.json) process.stderr.write(` ${pc11.dim(`summarizing ${done}/${total}`)}
4650
+ if (!opts.json) process.stderr.write(` ${pc13.dim(`summarizing ${done}/${total}`)}
4230
4651
  `);
4231
4652
  }
4232
4653
  });
@@ -4236,21 +4657,21 @@ ${preview.prompt}
4236
4657
  return 0;
4237
4658
  }
4238
4659
  for (const node of result.nodes) {
4239
- process.stdout.write(`${pc11.bold(node.title)}
4240
- ${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", " "))}
4241
4662
  ${node.body}
4242
4663
 
4243
4664
  `);
4244
4665
  }
4245
4666
  if (result.providerUnavailable) {
4246
4667
  process.stderr.write(
4247
- `${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}\`)
4248
4669
  `
4249
4670
  );
4250
4671
  return 0;
4251
4672
  }
4252
4673
  process.stderr.write(
4253
- `${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})`)}
4254
4675
  `
4255
4676
  );
4256
4677
  return 0;
@@ -4258,16 +4679,16 @@ ${node.body}
4258
4679
  var SCAN_SESSION_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
4259
4680
 
4260
4681
  // src/cli/commands/scan-shell.ts
4261
- import pc12 from "picocolors";
4682
+ import pc14 from "picocolors";
4262
4683
  async function runScanShell(opts) {
4263
4684
  const repo = await readRepoInfo(opts.cwd);
4264
4685
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
4265
4686
  const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
4266
4687
  if (!opts.json) {
4267
4688
  process.stderr.write(
4268
- 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(", ")}
4269
4690
 
4270
- ` : `${pc12.yellow("no shell history source found on this machine")}
4691
+ ` : `${pc14.yellow("no shell history source found on this machine")}
4271
4692
  `
4272
4693
  );
4273
4694
  }
@@ -4276,7 +4697,7 @@ async function runScanShell(opts) {
4276
4697
  const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
4277
4698
  allNodes.push(...nodes);
4278
4699
  if (!opts.json) {
4279
- 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)`)}
4280
4701
  `);
4281
4702
  for (const node of nodes) process.stdout.write(`${formatNode5(node)}
4282
4703
  `);
@@ -4289,20 +4710,47 @@ async function runScanShell(opts) {
4289
4710
  return 0;
4290
4711
  }
4291
4712
  const approxTotal = allNodes.reduce((n, x) => n + approxTokens(x.body), 0);
4292
- 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`)}
4293
4714
  `);
4294
4715
  return 0;
4295
4716
  }
4296
4717
  function formatNode5(node) {
4297
- const approx = node.meta.tsApprox ? pc12.dim("~") : " ";
4718
+ const approx = node.meta.tsApprox ? pc14.dim("~") : " ";
4298
4719
  const exit = node.meta.exitCode;
4299
- const exitLabel = typeof exit === "number" && exit !== 0 ? pc12.red(`exit ${exit}`) : "";
4720
+ const exitLabel = typeof exit === "number" && exit !== 0 ? pc14.red(`exit ${exit}`) : "";
4300
4721
  return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
4301
4722
  }
4302
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
+
4303
4751
  // src/cli/commands/status.ts
4304
4752
  import { statSync } from "fs";
4305
- import pc13 from "picocolors";
4753
+ import pc16 from "picocolors";
4306
4754
  function humanBytes(bytes) {
4307
4755
  if (bytes < 1024) return `${bytes} B`;
4308
4756
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -4316,6 +4764,7 @@ function fileSize(path) {
4316
4764
  }
4317
4765
  }
4318
4766
  async function runStatus(opts) {
4767
+ const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
4319
4768
  const { repo, ws, projectId } = await loadContext(opts.cwd);
4320
4769
  const store = MemoryStore.open(ws.dbPath);
4321
4770
  try {
@@ -4324,29 +4773,37 @@ async function runStatus(opts) {
4324
4773
  const gitCursor = sources.find((s) => s.source === "git")?.cursor ?? null;
4325
4774
  const schema = currentSchemaVersion(store.raw);
4326
4775
  const chains = getChainStats(store, projectId);
4776
+ const otherProjectIds = store.listOtherProjectIds(projectId);
4777
+ const otherProjectNodes = store.countProjectNodes(otherProjectIds);
4778
+ const structure = store.fileEdgeStats(projectId);
4327
4779
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
4328
4780
  const kinds = Object.entries(stats.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
4329
- 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(
4330
4785
  [
4331
- `${pc13.dim("repo ")} ${repo.root}`,
4332
- `${pc13.dim("branch ")} ${repo.branch ?? pc13.yellow("(detached)")}`,
4333
- `${pc13.dim("project ")} ${pc13.cyan(projectId)}`,
4334
- `${pc13.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc13.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
4335
- `${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,
4336
4792
  "",
4337
- `${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)}`)}` : ""}`,
4338
4794
  ...kinds,
4339
- stats.total ? ` ${pc13.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
4795
+ stats.total ? ` ${pc16.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
4340
4796
  "",
4341
- sources.length ? pc13.dim("sources") : pc13.yellow("no sources synced yet"),
4797
+ sources.length ? pc16.dim("sources") : pc16.yellow("no sources synced yet"),
4342
4798
  ...sources.map((s) => {
4343
4799
  const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
4344
4800
  const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
4345
- 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}`)}`;
4346
4802
  }),
4347
- gitCursor && gitCursor !== repo.head ? `${pc13.yellow("git behind HEAD")} \u2014 run ${pc13.bold("nexusmem sync")}` : "",
4803
+ gitCursor && gitCursor !== repo.head ? `${pc16.yellow("git behind HEAD")} \u2014 run ${pc16.bold("nexusmem sync")}` : "",
4348
4804
  "",
4349
- chains.failuresTotal ? `${pc13.dim("chains ")} ${pc13.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc13.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc13.bold("nexusmem sync --link-failures")} to link more` : ""}` : ""
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)` : ""
4350
4807
  ].filter((line) => line !== "").join("\n").concat("\n")
4351
4808
  );
4352
4809
  return 0;
@@ -4361,7 +4818,7 @@ function isExpected(err) {
4361
4818
  // the user fixes, not stack traces they debug.
4362
4819
  err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
4363
4820
  // (antivirus, a bad install). Actionable, and not our stack to print.
4364
- err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError;
4821
+ err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError;
4365
4822
  }
4366
4823
  function guard(run) {
4367
4824
  return async () => {
@@ -4369,7 +4826,7 @@ function guard(run) {
4369
4826
  process.exitCode = await run();
4370
4827
  } catch (err) {
4371
4828
  if (isExpected(err)) {
4372
- process.stderr.write(`${pc14.red("error")} ${err.message}
4829
+ process.stderr.write(`${pc17.red("error")} ${err.message}
4373
4830
  `);
4374
4831
  process.exitCode = 1;
4375
4832
  return;
@@ -4422,6 +4879,14 @@ program.command("hook").description("Manage the opt-in PowerShell hook that logs
4422
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 }))())
4423
4880
  ).addCommand(
4424
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
+ )
4425
4890
  );
4426
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 }))());
4427
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(
@@ -4483,10 +4948,22 @@ program.command("scan-session").description("Preview the session summaries a loc
4483
4948
  )()
4484
4949
  );
4485
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 }))());
4486
4963
  program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
4487
4964
  program.parseAsync(process.argv).catch((err) => {
4488
4965
  const message = err instanceof Error ? err.message : String(err);
4489
- process.stderr.write(`${pc14.red("error")} ${message}
4966
+ process.stderr.write(`${pc17.red("error")} ${message}
4490
4967
  `);
4491
4968
  process.exitCode = 1;
4492
4969
  });