nexusmem 0.1.2 → 0.2.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 pc11 from "picocolors";
5
+ import pc13 from "picocolors";
6
6
 
7
7
  // src/config/workspace.ts
8
8
  import { existsSync } from "fs";
@@ -51,12 +51,28 @@ var ConfigSchema = z.object({
51
51
  enabled: z.boolean().default(true),
52
52
  /** git pathspecs passed to `git ls-files`. */
53
53
  include: z.array(z.string()).default(["*.md"])
54
- }).default({ enabled: true, include: ["*.md"] })
54
+ }).default({ enabled: true, include: ["*.md"] }),
55
+ /**
56
+ * The patch text of each commit, one node per changed file.
57
+ *
58
+ * Bounded by `maxCommits` rather than by `git.since`, because patches
59
+ * are an order of magnitude bulkier than commit messages: an unbounded
60
+ * first sync of a long-lived repository would spend most of its time
61
+ * and database on code nobody will ask about. Later syncs walk only
62
+ * `cursor..HEAD`, so the cap effectively applies to the first run.
63
+ */
64
+ diff: z.object({
65
+ enabled: z.boolean().default(true),
66
+ maxCommits: z.number().int().positive().default(200),
67
+ maxFilesPerCommit: z.number().int().positive().default(20),
68
+ contextLines: z.number().int().nonnegative().default(3)
69
+ }).default({ enabled: true, maxCommits: 200, maxFilesPerCommit: 20, contextLines: 3 })
55
70
  }).default({
56
71
  git: { enabled: true, since: null, includeMerges: true },
57
72
  shell: { enabled: true, tailLines: 300 },
58
73
  conversation: { enabled: false },
59
- docs: { enabled: true, include: ["*.md"] }
74
+ docs: { enabled: true, include: ["*.md"] },
75
+ diff: { enabled: true, maxCommits: 200, maxFilesPerCommit: 20, contextLines: 3 }
60
76
  }),
61
77
  limits: z.object({
62
78
  maxFilesPerNode: z.number().int().positive().default(40),
@@ -314,25 +330,31 @@ import { dirname } from "path";
314
330
 
315
331
  // src/shell/paths.ts
316
332
  import { execFile } from "child_process";
333
+ import { homedir as homedir2 } from "os";
334
+ import { join as join3 } from "path";
335
+ import { promisify } from "util";
336
+
337
+ // src/config/paths.ts
317
338
  import { homedir } from "os";
318
339
  import { join as join2 } from "path";
319
- import { promisify } from "util";
340
+ function globalWorkspaceDir() {
341
+ return process.env.NEXUSMEM_HOME ?? join2(homedir(), ".nexusmem");
342
+ }
343
+
344
+ // src/shell/paths.ts
320
345
  var execFileAsync = promisify(execFile);
321
346
  function psReadLineHistoryPath() {
322
- const appData = process.env.APPDATA ?? join2(homedir(), "AppData", "Roaming");
323
- return join2(appData, "Microsoft", "Windows", "PowerShell", "PSReadLine", "ConsoleHost_history.txt");
347
+ const appData = process.env.APPDATA ?? join3(homedir2(), "AppData", "Roaming");
348
+ return join3(appData, "Microsoft", "Windows", "PowerShell", "PSReadLine", "ConsoleHost_history.txt");
324
349
  }
325
350
  function bashHistoryPath() {
326
- return process.env.HISTFILE_BASH ?? join2(homedir(), ".bash_history");
351
+ return process.env.HISTFILE_BASH ?? join3(homedir2(), ".bash_history");
327
352
  }
328
353
  function zshHistoryPath() {
329
- return process.env.HISTFILE ?? join2(homedir(), ".zsh_history");
330
- }
331
- function globalWorkspaceDir() {
332
- return join2(homedir(), ".nexusmem");
354
+ return process.env.HISTFILE ?? join3(homedir2(), ".zsh_history");
333
355
  }
334
356
  function hookLogPath() {
335
- return join2(globalWorkspaceDir(), "shell-history.jsonl");
357
+ return join3(globalWorkspaceDir(), "shell-history.jsonl");
336
358
  }
337
359
  async function resolvePowerShellProfilePath(exe = "powershell") {
338
360
  try {
@@ -486,6 +508,74 @@ async function runHookStatus(opts) {
486
508
  import { relative } from "path";
487
509
  import pc2 from "picocolors";
488
510
 
511
+ // src/config/registry.ts
512
+ import { existsSync as existsSync2 } from "fs";
513
+ import { mkdir as mkdir3, readFile as readFile3, rename, writeFile as writeFile3 } from "fs/promises";
514
+ import { join as join4 } from "path";
515
+ import { z as z2 } from "zod";
516
+ var ENTRY_SCHEMA = z2.object({
517
+ projectId: z2.string().min(1),
518
+ root: z2.string().min(1),
519
+ dbPath: z2.string().min(1),
520
+ originUrl: z2.string().nullable().default(null),
521
+ /** Epoch ms of the last `init`/`sync` that recorded this entry. */
522
+ lastSeenAt: z2.number().int().nonnegative()
523
+ });
524
+ var REGISTRY_SCHEMA = z2.object({
525
+ version: z2.literal(1),
526
+ projects: z2.array(ENTRY_SCHEMA).default([])
527
+ });
528
+ function registryPath() {
529
+ return join4(globalWorkspaceDir(), "projects.json");
530
+ }
531
+ async function readRegistry() {
532
+ let raw;
533
+ try {
534
+ raw = await readFile3(registryPath(), "utf8");
535
+ } catch {
536
+ return [];
537
+ }
538
+ try {
539
+ const parsed = REGISTRY_SCHEMA.safeParse(JSON.parse(raw));
540
+ if (!parsed.success) return [];
541
+ return [...parsed.data.projects].sort((a, b) => b.lastSeenAt - a.lastSeenAt);
542
+ } catch {
543
+ return [];
544
+ }
545
+ }
546
+ async function readLiveRegistry() {
547
+ const all = await readRegistry();
548
+ const entries = [];
549
+ const missing = [];
550
+ for (const entry of all) {
551
+ (existsSync2(entry.dbPath) ? entries : missing).push(entry);
552
+ }
553
+ return { entries, missing };
554
+ }
555
+ async function recordProject(input) {
556
+ const existing = await readRegistry();
557
+ const entry = { ...input, lastSeenAt: Date.now() };
558
+ const projects = [entry, ...existing.filter((e) => e.projectId !== input.projectId)];
559
+ await writeRegistry(projects);
560
+ return projects;
561
+ }
562
+ async function forgetProjects(projectIds) {
563
+ const existing = await readRegistry();
564
+ const drop = new Set(projectIds);
565
+ const kept = existing.filter((e) => !drop.has(e.projectId));
566
+ if (kept.length === existing.length) return 0;
567
+ await writeRegistry(kept);
568
+ return existing.length - kept.length;
569
+ }
570
+ async function writeRegistry(projects) {
571
+ const path = registryPath();
572
+ const tmp = `${path}.${process.pid}.tmp`;
573
+ await mkdir3(globalWorkspaceDir(), { recursive: true });
574
+ await writeFile3(tmp, `${JSON.stringify({ version: 1, projects }, null, 2)}
575
+ `, "utf8");
576
+ await rename(tmp, path);
577
+ }
578
+
489
579
  // src/core/ids.ts
490
580
  import { createHash } from "crypto";
491
581
  var KEY_SEP = "\0";
@@ -905,6 +995,7 @@ async function runInit(opts) {
905
995
  } finally {
906
996
  store.close();
907
997
  }
998
+ await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
908
999
  const lines = [
909
1000
  `${pc2.green("initialized")} ${ws.dir}`,
910
1001
  ` project ${pc2.cyan(projectId)}`,
@@ -939,10 +1030,68 @@ async function runInit(opts) {
939
1030
  return 0;
940
1031
  }
941
1032
 
1033
+ // src/cli/commands/projects.ts
1034
+ import pc3 from "picocolors";
1035
+ async function runProjects(opts) {
1036
+ const { entries, missing } = await readLiveRegistry();
1037
+ const rows = entries.map((entry) => {
1038
+ let nodes = null;
1039
+ try {
1040
+ const store = MemoryStore.open(entry.dbPath);
1041
+ try {
1042
+ nodes = store.stats(entry.projectId).total;
1043
+ } finally {
1044
+ store.close();
1045
+ }
1046
+ } catch {
1047
+ nodes = null;
1048
+ }
1049
+ return { ...entry, nodes };
1050
+ });
1051
+ if (opts.prune) {
1052
+ const removed = await forgetProjects(missing.map((entry) => entry.projectId));
1053
+ process.stderr.write(`${pc3.yellow("pruned")} ${removed} project(s) whose database is gone
1054
+ `);
1055
+ }
1056
+ if (opts.json) {
1057
+ process.stdout.write(`${JSON.stringify({ registry: registryPath(), projects: rows, missing }, null, 2)}
1058
+ `);
1059
+ return 0;
1060
+ }
1061
+ process.stderr.write(`${pc3.dim("registry")} ${registryPath()}
1062
+
1063
+ `);
1064
+ if (rows.length === 0) {
1065
+ process.stderr.write(`${pc3.yellow("no projects registered")} -- run ${pc3.bold("nexusmem sync")} in a repository
1066
+ `);
1067
+ return 0;
1068
+ }
1069
+ for (const row of rows) {
1070
+ const seen = new Date(row.lastSeenAt).toISOString().slice(0, 16).replace("T", " ");
1071
+ const count = row.nodes === null ? pc3.yellow("unreadable") : `${row.nodes} node(s)`;
1072
+ process.stdout.write(`${pc3.cyan(row.projectId.slice(0, 8))} ${row.root}
1073
+ ${pc3.dim(`${count}, last seen ${seen}`)}
1074
+ `);
1075
+ }
1076
+ if (!opts.prune && missing.length > 0) {
1077
+ process.stderr.write(
1078
+ `
1079
+ ${pc3.yellow(`${missing.length} registered project(s) have no database on disk`)} ${pc3.dim("-- run with --prune to forget them")}
1080
+ `
1081
+ );
1082
+ for (const entry of missing) process.stderr.write(` ${pc3.dim(entry.root)}
1083
+ `);
1084
+ }
1085
+ return 0;
1086
+ }
1087
+
942
1088
  // src/mcp/server.ts
943
1089
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
944
1090
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
945
- import { z as z2 } from "zod";
1091
+ import { z as z3 } from "zod";
1092
+
1093
+ // src/mcp/tools.ts
1094
+ import { basename as basename2 } from "path";
946
1095
 
947
1096
  // src/core/text.ts
948
1097
  function truncate(s, max) {
@@ -956,22 +1105,126 @@ function approxTokens(text) {
956
1105
  var DEFAULT_SUMMARY_CHARS = 320;
957
1106
  var NODE_OVERHEAD_TOKENS = 8;
958
1107
  var CONVERSATION_ANSWER_MARKER = "\n\nA: ";
959
- function summarize(hit, maxChars) {
1108
+ var HUNK_BOUNDARY = "\n@@ ";
1109
+ var STOPWORDS = /* @__PURE__ */ new Set([
1110
+ "the",
1111
+ "and",
1112
+ "for",
1113
+ "are",
1114
+ "was",
1115
+ "were",
1116
+ "that",
1117
+ "this",
1118
+ "with",
1119
+ "from",
1120
+ "into",
1121
+ "not",
1122
+ "but",
1123
+ "what",
1124
+ "why",
1125
+ "how",
1126
+ "when",
1127
+ "where",
1128
+ "which",
1129
+ "who",
1130
+ "does",
1131
+ "did",
1132
+ "has",
1133
+ "have",
1134
+ "had",
1135
+ "can",
1136
+ "could",
1137
+ "would",
1138
+ "should",
1139
+ "all",
1140
+ "any",
1141
+ "every",
1142
+ "each",
1143
+ "its",
1144
+ "our",
1145
+ "you",
1146
+ "your",
1147
+ "about"
1148
+ ]);
1149
+ function queryTerms(query) {
1150
+ const words = query.toLowerCase().match(/[a-z0-9_]{3,}/g) ?? [];
1151
+ return [...new Set(words.filter((w) => !STOPWORDS.has(w)).map(singularize))];
1152
+ }
1153
+ function codeTokens(text) {
1154
+ const spaced = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
1155
+ return new Set((spaced.match(/[a-z0-9]{2,}/g) ?? []).map(singularize));
1156
+ }
1157
+ function singularize(word) {
1158
+ return word.length > 3 && word.endsWith("s") && !word.endsWith("ss") ? word.slice(0, -1) : word;
1159
+ }
1160
+ function pickHunk(patch, query) {
1161
+ const first = patch.indexOf("@@ ");
1162
+ if (first === -1) return patch;
1163
+ const hunks = [];
1164
+ let rest = patch.slice(first);
1165
+ for (; ; ) {
1166
+ const next = rest.indexOf(HUNK_BOUNDARY, 1);
1167
+ if (next === -1) {
1168
+ hunks.push(rest);
1169
+ break;
1170
+ }
1171
+ hunks.push(rest.slice(0, next));
1172
+ rest = rest.slice(next + 1);
1173
+ }
1174
+ const terms = queryTerms(query);
1175
+ if (terms.length === 0) return hunks[0] ?? patch;
1176
+ let best = hunks[0] ?? patch;
1177
+ let bestScore = 0;
1178
+ for (const hunk of hunks) {
1179
+ const tokens = codeTokens(hunk);
1180
+ const score = terms.reduce((n, term) => n + (tokens.has(term) ? 1 : 0), 0);
1181
+ if (score > bestScore) {
1182
+ best = hunk;
1183
+ bestScore = score;
1184
+ }
1185
+ }
1186
+ return focusHunk(best, terms);
1187
+ }
1188
+ function focusHunk(hunk, terms) {
1189
+ const lines = hunk.split("\n");
1190
+ const header = lines[0] ?? "";
1191
+ const body = lines.slice(1);
1192
+ const isChange = (line) => line.startsWith("+") || line.startsWith("-");
1193
+ let idx = terms.length ? body.findIndex((line) => {
1194
+ if (!isChange(line)) return false;
1195
+ const tokens = codeTokens(line);
1196
+ return terms.some((term) => tokens.has(term));
1197
+ }) : -1;
1198
+ if (idx === -1) idx = body.findIndex(isChange);
1199
+ if (idx <= 1) return hunk;
1200
+ return [header, ...body.slice(idx - 1)].join("\n");
1201
+ }
1202
+ function summarize(hit, maxChars, query) {
960
1203
  const answerIdx = hit.body.indexOf(CONVERSATION_ANSWER_MARKER);
961
1204
  if (answerIdx !== -1) {
962
1205
  const answer = hit.body.slice(answerIdx + CONVERSATION_ANSWER_MARKER.length).trim();
963
1206
  if (answer) return truncate(answer, maxChars);
964
1207
  }
1208
+ if (hit.kind === "code_diff") {
1209
+ const patchStart = hit.body.indexOf(HUNK_BOUNDARY);
1210
+ if (patchStart !== -1) {
1211
+ const head = hit.body.slice(0, patchStart).trim();
1212
+ const hunk = pickHunk(hit.body.slice(patchStart + 1), query);
1213
+ return truncate(`${head}
1214
+ ${hunk}`, maxChars);
1215
+ }
1216
+ }
965
1217
  const rest = hit.body.startsWith(hit.title) ? hit.body.slice(hit.title.length).trim() : hit.body;
966
1218
  return truncate(rest || hit.title, maxChars);
967
1219
  }
968
1220
  function packContext(ranked, tokensBudget, opts = {}) {
969
1221
  const summaryChars = opts.summaryChars ?? DEFAULT_SUMMARY_CHARS;
1222
+ const query = opts.query ?? "";
970
1223
  const nodes = [];
971
1224
  let tokensUsed = 0;
972
1225
  let droppedForBudget = 0;
973
1226
  for (const hit of ranked) {
974
- const summary = summarize(hit, summaryChars);
1227
+ const summary = summarize(hit, summaryChars, query);
975
1228
  const tokens = approxTokens(hit.title) + approxTokens(summary) + NODE_OVERHEAD_TOKENS;
976
1229
  if (tokensUsed + tokens > tokensBudget) {
977
1230
  droppedForBudget += 1;
@@ -985,7 +1238,8 @@ function packContext(ranked, tokensBudget, opts = {}) {
985
1238
  signal: hit.signal,
986
1239
  score: hit.score,
987
1240
  summary,
988
- tokens
1241
+ tokens,
1242
+ ...hit.project ? { project: hit.project } : {}
989
1243
  });
990
1244
  tokensUsed += tokens;
991
1245
  }
@@ -995,9 +1249,14 @@ function renderContextBlock(query, result) {
995
1249
  if (result.nodes.length === 0) return `No remembered context matched "${query}".`;
996
1250
  const lines = [`Relevant history for: ${query}`, ""];
997
1251
  for (const node of result.nodes) {
998
- lines.push(`- ${node.ts.slice(0, 10)} ${node.title}`);
1252
+ const project = node.project ? `[${node.project}] ` : "";
1253
+ lines.push(`- ${node.ts.slice(0, 10)} ${project}${node.title}`);
999
1254
  if (node.summary && node.summary !== node.title) {
1000
- lines.push(` ${node.summary.replace(/\n+/g, " ")}`);
1255
+ if (node.kind === "code_diff") {
1256
+ for (const line of node.summary.split("\n")) lines.push(` ${line}`);
1257
+ } else {
1258
+ lines.push(` ${node.summary.replace(/\n+/g, " ")}`);
1259
+ }
1001
1260
  }
1002
1261
  }
1003
1262
  return lines.join("\n");
@@ -1076,6 +1335,31 @@ function rankHits(hits, opts = {}) {
1076
1335
  }
1077
1336
 
1078
1337
  // src/retrieval/query-pipeline.ts
1338
+ async function runCrossProjectQuery(sources, query, opts) {
1339
+ const queryVector = opts.embeddingProvider ? await opts.embeddingProvider.embed(query) : null;
1340
+ const lists = [];
1341
+ const hits = [];
1342
+ const perProject = [];
1343
+ let bm25Count = 0;
1344
+ let vectorCount = 0;
1345
+ for (const source of sources) {
1346
+ const label = (hit) => ({ ...hit, project: source.label });
1347
+ const bm25Hits = source.store.search(source.projectId, query, opts.candidates).map(label);
1348
+ const vectorHits = queryVector ? source.store.vectorSearch(source.projectId, queryVector, opts.candidates) : [];
1349
+ bm25Count += bm25Hits.length;
1350
+ vectorCount += vectorHits.length;
1351
+ perProject.push({ label: source.label, bm25: bm25Hits.length, vector: vectorHits.length });
1352
+ if (bm25Hits.length > 0) lists.push(bm25Hits);
1353
+ if (vectorHits.length > 0) {
1354
+ lists.push(vectorHits.map((hit) => ({ ...hit, rank: 0, project: source.label })));
1355
+ }
1356
+ hits.push(...mergeSearchAndVectorHits(bm25Hits, vectorHits).map(label));
1357
+ }
1358
+ const relevanceScores = reciprocalRankFusion(lists);
1359
+ const ranked = rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores });
1360
+ const packed = packContext(ranked, opts.budget, { query });
1361
+ return { bm25Count, vectorCount, hits, packed, perProject };
1362
+ }
1079
1363
  async function runHybridQuery(store, projectId, query, opts) {
1080
1364
  const bm25Hits = store.search(projectId, query, opts.candidates);
1081
1365
  let vectorHits = [];
@@ -1086,10 +1370,55 @@ async function runHybridQuery(store, projectId, query, opts) {
1086
1370
  const hits = vectorHits.length > 0 ? mergeSearchAndVectorHits(bm25Hits, vectorHits) : bm25Hits;
1087
1371
  const relevanceScores = vectorHits.length > 0 ? reciprocalRankFusion([bm25Hits, vectorHits]) : void 0;
1088
1372
  const ranked = rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores });
1089
- const packed = packContext(ranked, opts.budget);
1373
+ const packed = packContext(ranked, opts.budget, { query });
1090
1374
  return { bm25Count: bm25Hits.length, vectorCount: vectorHits.length, hits, packed };
1091
1375
  }
1092
1376
 
1377
+ // src/retrieval/sources.ts
1378
+ import { basename } from "path";
1379
+ async function openAllProjectSources(current) {
1380
+ const { entries, missing } = await readLiveRegistry();
1381
+ const wanted = [current];
1382
+ for (const entry of entries) {
1383
+ if (entry.projectId === current.projectId) continue;
1384
+ wanted.push({ projectId: entry.projectId, root: entry.root, dbPath: entry.dbPath });
1385
+ }
1386
+ const labels = labelProjects(wanted);
1387
+ const sources = [];
1388
+ const unreadable = [];
1389
+ wanted.forEach((project, index) => {
1390
+ try {
1391
+ sources.push({
1392
+ store: MemoryStore.open(project.dbPath),
1393
+ projectId: project.projectId,
1394
+ label: labels[index] ?? project.projectId.slice(0, 8)
1395
+ });
1396
+ } catch (err) {
1397
+ const entry = entries.find((e) => e.projectId === project.projectId);
1398
+ if (entry) unreadable.push({ entry, reason: err.message });
1399
+ }
1400
+ });
1401
+ return {
1402
+ sources,
1403
+ missing,
1404
+ unreadable,
1405
+ close: () => {
1406
+ for (const source of sources) source.store.close();
1407
+ }
1408
+ };
1409
+ }
1410
+ function labelProjects(projects) {
1411
+ const counts = /* @__PURE__ */ new Map();
1412
+ for (const project of projects) {
1413
+ const name = basename(project.root) || project.root;
1414
+ counts.set(name, (counts.get(name) ?? 0) + 1);
1415
+ }
1416
+ return projects.map((project) => {
1417
+ const name = basename(project.root) || project.root;
1418
+ return (counts.get(name) ?? 0) > 1 ? `${name}#${project.projectId.slice(0, 6)}` : name;
1419
+ });
1420
+ }
1421
+
1093
1422
  // src/vector/embed.ts
1094
1423
  var DEFAULT_BASE_URL = "http://127.0.0.1:11434";
1095
1424
  var DEFAULT_MODEL = "nomic-embed-text";
@@ -1129,7 +1458,7 @@ var OllamaEmbeddingProvider = class {
1129
1458
  };
1130
1459
 
1131
1460
  // src/cli/commands/sync.ts
1132
- import pc3 from "picocolors";
1461
+ import pc4 from "picocolors";
1133
1462
 
1134
1463
  // src/conversation/chunk.ts
1135
1464
  var HEADING_LINE = /^#{1,6}\s+(.+)$/;
@@ -1169,21 +1498,31 @@ function chunkAssistantText(text, maxChars) {
1169
1498
 
1170
1499
  // src/conversation/redact.ts
1171
1500
  var RULES = [
1172
- { name: "private-key-block", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
1173
- { name: "aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
1174
- { name: "github-token", pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
1175
- { name: "slack-token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
1176
- { name: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
1501
+ {
1502
+ name: "private-key-block",
1503
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
1504
+ highConfidence: true
1505
+ },
1506
+ { name: "aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/g, highConfidence: true },
1507
+ { name: "github-token", pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, highConfidence: true },
1508
+ { name: "slack-token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, highConfidence: true },
1509
+ {
1510
+ name: "jwt",
1511
+ pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
1512
+ highConfidence: true
1513
+ },
1177
1514
  // key/token/secret/password = "value" or : value, in code, JSON, env-file or prose form.
1178
1515
  {
1179
1516
  name: "key-value-secret",
1180
- pattern: /\b((?:api[_-]?key|secret|password|passwd|token|access[_-]?key)s?)\s*[:=]\s*['"]?[A-Za-z0-9_\-./+=]{8,}['"]?/gi
1517
+ pattern: /\b((?:api[_-]?key|secret|password|passwd|token|access[_-]?key)s?)\s*[:=]\s*['"]?[A-Za-z0-9_\-./+=]{8,}['"]?/gi,
1518
+ highConfidence: false
1181
1519
  }
1182
1520
  ];
1183
- function redact(text) {
1521
+ function redact(text, profile = "all") {
1184
1522
  let redactedCount = 0;
1185
1523
  let out = text;
1186
1524
  for (const rule of RULES) {
1525
+ if (profile === "high-confidence" && !rule.highConfidence) continue;
1187
1526
  out = out.replace(rule.pattern, (_match, ...rest) => {
1188
1527
  redactedCount += 1;
1189
1528
  const key = typeof rest[0] === "string" ? rest[0] : null;
@@ -1198,8 +1537,8 @@ var DEFAULT_MAX_BODY_CHARS = 2500;
1198
1537
  var DEFAULT_MAX_CHUNK_CHARS = 900;
1199
1538
  var MAX_TITLE_CHARS = 200;
1200
1539
  var MAX_FILES_PER_NODE = 20;
1201
- var EXPLANATION_MARKERS = /\b(because|the reason|design decision|trade-?off|instead of|rationale|so that)\b|เพราะ|ทำไม|เหตุผล/i;
1202
- var TRIVIAL_ACK = /^(ok|okay|thanks?|ขอบคุณ|ครับ|ค่ะ|got it|sounds good|👍|done)\.?!?$/i;
1540
+ var EXPLANATION_MARKERS = /\b(because|the reason|design decision|trade-?off|instead of|rationale|so that)\b/i;
1541
+ var TRIVIAL_ACK = /^(ok|okay|thanks?|got it|sounds good|👍|done)\.?!?$/i;
1203
1542
  function scoreConversationTurn(userText, replyText) {
1204
1543
  const text = `${userText}
1205
1544
  ${replyText}`;
@@ -1273,63 +1612,6 @@ function collectConversationTurns(turns, projectId, opts = {}) {
1273
1612
  return turns.filter((t) => t.assistantText.length > 0).flatMap((turn) => toMemoryNodes(turn, projectId, opts));
1274
1613
  }
1275
1614
 
1276
- // src/collectors/docs.ts
1277
- var DEFAULT_MAX_BODY_CHARS2 = 2e3;
1278
- var DEFAULT_MAX_CHUNK_CHARS2 = 1200;
1279
- var MAX_TITLE_CHARS2 = 200;
1280
- var EXPLANATION_MARKERS2 = /\b(because|the reason|design decision|trade-?off|instead of|rationale|why)\b/i;
1281
- function scoreDocSection(path, heading, text) {
1282
- let score = 0.45;
1283
- if (EXPLANATION_MARKERS2.test(text)) score += 0.25;
1284
- if (/(^|\/)readme\.md$/i.test(path)) score += 0.1;
1285
- if (heading === null) score -= 0.1;
1286
- if (text.length < 80) score -= 0.15;
1287
- return Number(Math.min(1, Math.max(0.05, score)).toFixed(3));
1288
- }
1289
- function slugify(heading, index) {
1290
- if (heading === null) return `_preamble-${index}`;
1291
- const slug = heading.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1292
- return slug || `_section-${index}`;
1293
- }
1294
- function sectionTitle(path, heading, index, count) {
1295
- if (heading) return truncate(`${path} \u2014 ${heading}`, MAX_TITLE_CHARS2);
1296
- if (count > 1) return truncate(`${path} (part ${index + 1}/${count})`, MAX_TITLE_CHARS2);
1297
- return truncate(path, MAX_TITLE_CHARS2);
1298
- }
1299
- function toMemoryNodes2(file, projectId, opts = {}) {
1300
- const maxBody = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS2;
1301
- const maxChunk = opts.maxChunkChars ?? DEFAULT_MAX_CHUNK_CHARS2;
1302
- const chunks = chunkAssistantText(file.content, maxChunk);
1303
- if (chunks.length === 0) return [];
1304
- const seenSlugs = /* @__PURE__ */ new Map();
1305
- return chunks.map((chunk, index) => {
1306
- const baseSlug = slugify(chunk.heading, index);
1307
- const occurrence = seenSlugs.get(baseSlug) ?? 0;
1308
- seenSlugs.set(baseSlug, occurrence + 1);
1309
- const naturalKey = occurrence === 0 ? `${file.path}#${baseSlug}` : `${file.path}#${baseSlug}:${occurrence}`;
1310
- return {
1311
- id: makeNodeId(projectId, "doc_section", naturalKey),
1312
- kind: "doc_section",
1313
- projectId,
1314
- ts: file.ts,
1315
- source: "docs",
1316
- title: sectionTitle(file.path, chunk.heading, index, chunks.length),
1317
- body: truncate(chunk.text, maxBody),
1318
- files: [{ path: file.path, insertions: null, deletions: null, binary: false }],
1319
- signal: scoreDocSection(file.path, chunk.heading, chunk.text),
1320
- meta: {
1321
- path: file.path,
1322
- heading: chunk.heading,
1323
- chunkIndex: index,
1324
- chunkCount: chunks.length
1325
- }
1326
- };
1327
- });
1328
- }
1329
- function collectDocFiles(files, projectId, opts = {}) {
1330
- return files.flatMap((file) => toMemoryNodes2(file, projectId, opts));
1331
- }
1332
-
1333
1615
  // src/git/parse.ts
1334
1616
  var RECORD_SEP = "";
1335
1617
  var UNIT_SEP = "";
@@ -1439,8 +1721,182 @@ function unquoteGitPath(p) {
1439
1721
  return Buffer.from(bytes).toString("utf8");
1440
1722
  }
1441
1723
 
1442
- // src/git/log.ts
1724
+ // src/git/diff.ts
1725
+ var GIT_DIFF_LOG_FORMAT = "%x1e%H%x1f%h%x1f%aI%x1f%s%x1f";
1726
+ var FIELD_COUNT2 = 5;
1443
1727
  var EMPTY_HISTORY = /does not have any commits yet|unknown revision|bad revision|ambiguous argument/i;
1728
+ function buildDiffLogArgs(opts = {}) {
1729
+ const { rev = "HEAD", afterCommit, since, maxCount, contextLines = 3, paths } = opts;
1730
+ const args = [
1731
+ "log",
1732
+ `--format=${GIT_DIFF_LOG_FORMAT}`,
1733
+ "--patch",
1734
+ "--no-color",
1735
+ // A merge produces no patch at all unless `-m`/`--cc` is passed, and the
1736
+ // combined diff those print is a different format from the one parsed
1737
+ // here. Excluding merges up front keeps the parser honest about what it
1738
+ // supports; the merge itself is still remembered as a `git_commit` node.
1739
+ "--no-merges",
1740
+ "--find-renames",
1741
+ // Never run a user-configured textconv filter: it would execute an
1742
+ // arbitrary program from repo config during a sync, and its output is not
1743
+ // the diff we claim to be indexing.
1744
+ "--no-textconv",
1745
+ `--unified=${Math.max(0, contextLines)}`
1746
+ ];
1747
+ if (maxCount && maxCount > 0) args.push(`--max-count=${maxCount}`);
1748
+ if (since) args.push(`--since=${since}`);
1749
+ args.push(afterCommit ? `${afterCommit}..${rev}` : rev);
1750
+ if (paths?.length) args.push("--", ...paths);
1751
+ return args;
1752
+ }
1753
+ async function* readCommitDiffs(cwd, opts = {}) {
1754
+ const args = buildDiffLogArgs(opts);
1755
+ let buffer = "";
1756
+ try {
1757
+ for await (const chunk of gitStream(cwd, args)) {
1758
+ buffer += chunk;
1759
+ const { records, rest } = splitRecords(buffer);
1760
+ buffer = rest;
1761
+ for (const record of records) {
1762
+ const commit = parseCommitDiffRecord(record);
1763
+ if (commit) yield commit;
1764
+ }
1765
+ }
1766
+ } catch (err) {
1767
+ if (err instanceof GitError && EMPTY_HISTORY.test(err.stderr)) return;
1768
+ throw err;
1769
+ }
1770
+ for (const record of splitRecords(buffer, true).records) {
1771
+ const commit = parseCommitDiffRecord(record);
1772
+ if (commit) yield commit;
1773
+ }
1774
+ }
1775
+ function parseCommitDiffRecord(record) {
1776
+ let payload = record;
1777
+ while (payload.startsWith(RECORD_SEP)) payload = payload.slice(RECORD_SEP.length);
1778
+ const parts = payload.split(UNIT_SEP);
1779
+ if (parts.length < FIELD_COUNT2) return null;
1780
+ const sha = parts[0] ?? "";
1781
+ if (!/^[0-9a-f]{7,64}$/i.test(sha)) return null;
1782
+ const patchBlock = parts[parts.length - 1] ?? "";
1783
+ const subject = parts.slice(3, parts.length - 1).join(UNIT_SEP).trim();
1784
+ return {
1785
+ sha,
1786
+ shortSha: parts[1] ?? "",
1787
+ authoredAt: parts[2] ?? "",
1788
+ subject,
1789
+ files: parseFileDiffs(patchBlock)
1790
+ };
1791
+ }
1792
+ var FILE_HEADER = "diff --git ";
1793
+ var HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/;
1794
+ function parseFileDiffs(block) {
1795
+ const files = [];
1796
+ let section = null;
1797
+ const flush = () => {
1798
+ if (!section) return;
1799
+ const parsed = parseFileSection(section);
1800
+ if (parsed) files.push(parsed);
1801
+ section = null;
1802
+ };
1803
+ for (const raw of block.split("\n")) {
1804
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
1805
+ if (line.startsWith(FILE_HEADER)) {
1806
+ flush();
1807
+ section = [line];
1808
+ } else if (section) {
1809
+ section.push(line);
1810
+ }
1811
+ }
1812
+ flush();
1813
+ return files;
1814
+ }
1815
+ function parseFileSection(lines) {
1816
+ let status = "modified";
1817
+ let binary = false;
1818
+ let fromPath = null;
1819
+ let toPath = null;
1820
+ let renamedFrom = null;
1821
+ let hunkStart = -1;
1822
+ for (let i = 0; i < lines.length; i += 1) {
1823
+ const line = lines[i] ?? "";
1824
+ if (HUNK_HEADER.test(line)) {
1825
+ hunkStart = i;
1826
+ break;
1827
+ }
1828
+ if (line.startsWith("new file mode")) status = "added";
1829
+ else if (line.startsWith("deleted file mode")) status = "deleted";
1830
+ else if (line.startsWith("rename from ")) renamedFrom = unquoteGitPath(line.slice("rename from ".length));
1831
+ else if (line.startsWith("rename to ")) status = "renamed";
1832
+ else if (line.startsWith("copy from ")) renamedFrom = unquoteGitPath(line.slice("copy from ".length));
1833
+ else if (line.startsWith("Binary files ") || line.startsWith("GIT binary patch")) binary = true;
1834
+ else if (line.startsWith("--- ")) fromPath = stripDiffPathPrefix(line.slice(4));
1835
+ else if (line.startsWith("+++ ")) toPath = stripDiffPathPrefix(line.slice(4));
1836
+ }
1837
+ const header = parseDiffGitPaths(lines[0] ?? "");
1838
+ const path = toPath ?? header.b ?? fromPath ?? header.a;
1839
+ if (!path) return null;
1840
+ const previousPath = renamedFrom ?? (status === "renamed" ? fromPath ?? header.a ?? void 0 : void 0);
1841
+ if (binary || hunkStart === -1) {
1842
+ return {
1843
+ path,
1844
+ ...previousPath && previousPath !== path ? { previousPath } : {},
1845
+ status,
1846
+ binary,
1847
+ insertions: 0,
1848
+ deletions: 0,
1849
+ hunkCount: 0,
1850
+ patch: ""
1851
+ };
1852
+ }
1853
+ const hunkLines = lines.slice(hunkStart);
1854
+ let insertions = 0;
1855
+ let deletions = 0;
1856
+ let hunkCount = 0;
1857
+ for (const line of hunkLines) {
1858
+ if (HUNK_HEADER.test(line)) hunkCount += 1;
1859
+ else if (line.startsWith("+")) insertions += 1;
1860
+ else if (line.startsWith("-")) deletions += 1;
1861
+ }
1862
+ return {
1863
+ path,
1864
+ ...previousPath && previousPath !== path ? { previousPath } : {},
1865
+ status,
1866
+ binary: false,
1867
+ insertions,
1868
+ deletions,
1869
+ hunkCount,
1870
+ patch: hunkLines.join("\n").trimEnd()
1871
+ };
1872
+ }
1873
+ function stripDiffPathPrefix(raw) {
1874
+ const cleaned = unquoteGitPath(raw.trim());
1875
+ if (cleaned === "/dev/null") return null;
1876
+ return cleaned.replace(/^[ab]\//, "");
1877
+ }
1878
+ function parseDiffGitPaths(headerLine) {
1879
+ const rest = headerLine.slice(FILE_HEADER.length);
1880
+ if (rest.startsWith('"')) {
1881
+ const match = /^("(?:[^"\\]|\\.)*")\s+("(?:[^"\\]|\\.)*"|\S+)$/.exec(rest);
1882
+ if (match) {
1883
+ return { a: stripDiffPathPrefix(match[1] ?? ""), b: stripDiffPathPrefix(match[2] ?? "") };
1884
+ }
1885
+ }
1886
+ const quotedSecond = /^(\S+)\s+("(?:[^"\\]|\\.)*")$/.exec(rest);
1887
+ if (quotedSecond) {
1888
+ return { a: stripDiffPathPrefix(quotedSecond[1] ?? ""), b: stripDiffPathPrefix(quotedSecond[2] ?? "") };
1889
+ }
1890
+ const split = / b\//.exec(rest);
1891
+ if (!split || split.index <= 0) return { a: null, b: null };
1892
+ return {
1893
+ a: stripDiffPathPrefix(rest.slice(0, split.index)),
1894
+ b: stripDiffPathPrefix(rest.slice(split.index + 1))
1895
+ };
1896
+ }
1897
+
1898
+ // src/git/log.ts
1899
+ var EMPTY_HISTORY2 = /does not have any commits yet|unknown revision|bad revision|ambiguous argument/i;
1444
1900
  function buildLogArgs(opts = {}) {
1445
1901
  const { rev = "HEAD", afterCommit, since, maxCount, includeMerges = true, paths } = opts;
1446
1902
  const args = ["log", `--format=${GIT_LOG_FORMAT}`, "--numstat", "--no-color"];
@@ -1465,7 +1921,7 @@ async function* readCommits(cwd, opts = {}) {
1465
1921
  }
1466
1922
  }
1467
1923
  } catch (err) {
1468
- if (err instanceof GitError && EMPTY_HISTORY.test(err.stderr)) return;
1924
+ if (err instanceof GitError && EMPTY_HISTORY2.test(err.stderr)) return;
1469
1925
  throw err;
1470
1926
  }
1471
1927
  for (const record of splitRecords(buffer, true).records) {
@@ -1476,7 +1932,7 @@ async function* readCommits(cwd, opts = {}) {
1476
1932
 
1477
1933
  // src/collectors/git-commits.ts
1478
1934
  var DEFAULTS = { maxFilesPerNode: 40, maxBodyChars: 4e3 };
1479
- var MAX_TITLE_CHARS3 = 200;
1935
+ var MAX_TITLE_CHARS2 = 200;
1480
1936
  var CONVENTIONAL = /^([a-z]+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/i;
1481
1937
  function parseConventionalHeader(subject) {
1482
1938
  const m = CONVENTIONAL.exec(subject.trim());
@@ -1545,7 +2001,7 @@ function toMemoryNode(commit, projectId, opts = {}) {
1545
2001
  projectId,
1546
2002
  ts: commit.authoredAt,
1547
2003
  source: "git",
1548
- title: truncate(commit.subject || `(no subject) ${commit.shortSha}`, MAX_TITLE_CHARS3),
2004
+ title: truncate(commit.subject || `(no subject) ${commit.shortSha}`, MAX_TITLE_CHARS2),
1549
2005
  body: truncate(bodyParts.join("\n"), maxBody),
1550
2006
  files: keptFiles,
1551
2007
  signal: scoreCommit(commit),
@@ -1572,9 +2028,156 @@ async function* collectGitCommits(cwd, projectId, opts = {}) {
1572
2028
  }
1573
2029
  }
1574
2030
 
2031
+ // src/collectors/diffs.ts
2032
+ var DIFF_SOURCE = "diff";
2033
+ var DEFAULTS2 = { maxFilesPerCommit: 20, maxBodyChars: 2e3 };
2034
+ var MAX_TITLE_CHARS3 = 200;
2035
+ var GENERATED_PATHS = [
2036
+ /(^|\/)(node_modules|dist|build|out|coverage|vendor|third_party)\//,
2037
+ /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|composer\.lock|Cargo\.lock|poetry\.lock|Gemfile\.lock|go\.sum)$/,
2038
+ /\.(min\.js|min\.css|map|snap)$/
2039
+ ];
2040
+ function isGeneratedPath(path) {
2041
+ return GENERATED_PATHS.some((re) => re.test(path));
2042
+ }
2043
+ var TEST_PATHS = /(^|\/)(tests?|__tests__|spec|e2e)\/|\.(test|spec)\.[cm]?[jt]sx?$/;
2044
+ function scoreFileDiff(subject, file) {
2045
+ const header = parseConventionalHeader(subject);
2046
+ let score = header.type ? TYPE_WEIGHTS[header.type] ?? 0.5 : 0.5;
2047
+ if (header.breaking) score += 0.1;
2048
+ if (TEST_PATHS.test(file.path)) score -= 0.1;
2049
+ if (file.status === "added") score += 0.05;
2050
+ if (file.status === "deleted") score -= 0.1;
2051
+ const churn = file.insertions + file.deletions;
2052
+ if (churn <= 2) score -= 0.05;
2053
+ if (churn > 400) score *= 0.75;
2054
+ return Number(Math.min(1, Math.max(0.05, score)).toFixed(3));
2055
+ }
2056
+ function byChurnDesc2(a, b) {
2057
+ return b.insertions + b.deletions - (a.insertions + a.deletions);
2058
+ }
2059
+ function indexableFiles(files) {
2060
+ return files.filter((f) => !f.binary && f.hunkCount > 0 && !isGeneratedPath(f.path));
2061
+ }
2062
+ var STATUS_LABEL = {
2063
+ added: "added",
2064
+ deleted: "deleted",
2065
+ renamed: "renamed",
2066
+ modified: "modified"
2067
+ };
2068
+ function fileTitle(shortSha, subject, file) {
2069
+ return truncate(`${file.path} @ ${shortSha} \u2014 ${subject}`, MAX_TITLE_CHARS3);
2070
+ }
2071
+ function toMemoryNodes2(commit, projectId, opts = {}) {
2072
+ const maxFiles = opts.maxFilesPerCommit ?? DEFAULTS2.maxFilesPerCommit;
2073
+ const maxBody = opts.maxBodyChars ?? DEFAULTS2.maxBodyChars;
2074
+ const kept = indexableFiles(commit.files).sort(byChurnDesc2).slice(0, maxFiles);
2075
+ return kept.map((file) => {
2076
+ const churn = `+${file.insertions}/-${file.deletions}`;
2077
+ const renamePart = file.previousPath ? `, renamed from ${file.previousPath}` : "";
2078
+ const head = [
2079
+ `${commit.subject} (${commit.shortSha})`,
2080
+ `${STATUS_LABEL[file.status]} ${file.path} (${churn}, ${file.hunkCount} hunk${file.hunkCount === 1 ? "" : "s"}${renamePart})`,
2081
+ ""
2082
+ ].join("\n");
2083
+ const { text: patch } = redact(file.patch, "high-confidence");
2084
+ return {
2085
+ id: makeNodeId(projectId, "code_diff", `${commit.sha}:${file.path}`),
2086
+ kind: "code_diff",
2087
+ projectId,
2088
+ ts: commit.authoredAt,
2089
+ source: DIFF_SOURCE,
2090
+ title: fileTitle(commit.shortSha, commit.subject, file),
2091
+ body: truncate(head + patch, maxBody),
2092
+ files: [
2093
+ {
2094
+ path: file.path,
2095
+ ...file.previousPath ? { previousPath: file.previousPath } : {},
2096
+ insertions: file.insertions,
2097
+ deletions: file.deletions,
2098
+ binary: false
2099
+ }
2100
+ ],
2101
+ signal: scoreFileDiff(commit.subject, file),
2102
+ meta: {
2103
+ sha: commit.sha,
2104
+ shortSha: commit.shortSha,
2105
+ path: file.path,
2106
+ status: file.status,
2107
+ hunkCount: file.hunkCount,
2108
+ insertions: file.insertions,
2109
+ deletions: file.deletions,
2110
+ subject: commit.subject
2111
+ }
2112
+ };
2113
+ });
2114
+ }
2115
+ async function* collectCommitDiffs(cwd, projectId, opts = {}) {
2116
+ for await (const commit of readCommitDiffs(cwd, opts)) {
2117
+ for (const node of toMemoryNodes2(commit, projectId, opts)) yield node;
2118
+ }
2119
+ }
2120
+
2121
+ // src/collectors/docs.ts
2122
+ var DEFAULT_MAX_BODY_CHARS2 = 2e3;
2123
+ var DEFAULT_MAX_CHUNK_CHARS2 = 1200;
2124
+ var MAX_TITLE_CHARS4 = 200;
2125
+ var EXPLANATION_MARKERS2 = /\b(because|the reason|design decision|trade-?off|instead of|rationale|why)\b/i;
2126
+ function scoreDocSection(path, heading, text) {
2127
+ let score = 0.45;
2128
+ if (EXPLANATION_MARKERS2.test(text)) score += 0.25;
2129
+ if (/(^|\/)readme\.md$/i.test(path)) score += 0.1;
2130
+ if (heading === null) score -= 0.1;
2131
+ if (text.length < 80) score -= 0.15;
2132
+ return Number(Math.min(1, Math.max(0.05, score)).toFixed(3));
2133
+ }
2134
+ function slugify(heading, index) {
2135
+ if (heading === null) return `_preamble-${index}`;
2136
+ const slug = heading.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2137
+ return slug || `_section-${index}`;
2138
+ }
2139
+ function sectionTitle(path, heading, index, count) {
2140
+ if (heading) return truncate(`${path} \u2014 ${heading}`, MAX_TITLE_CHARS4);
2141
+ if (count > 1) return truncate(`${path} (part ${index + 1}/${count})`, MAX_TITLE_CHARS4);
2142
+ return truncate(path, MAX_TITLE_CHARS4);
2143
+ }
2144
+ function toMemoryNodes3(file, projectId, opts = {}) {
2145
+ const maxBody = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS2;
2146
+ const maxChunk = opts.maxChunkChars ?? DEFAULT_MAX_CHUNK_CHARS2;
2147
+ const chunks = chunkAssistantText(file.content, maxChunk);
2148
+ if (chunks.length === 0) return [];
2149
+ const seenSlugs = /* @__PURE__ */ new Map();
2150
+ return chunks.map((chunk, index) => {
2151
+ const baseSlug = slugify(chunk.heading, index);
2152
+ const occurrence = seenSlugs.get(baseSlug) ?? 0;
2153
+ seenSlugs.set(baseSlug, occurrence + 1);
2154
+ const naturalKey = occurrence === 0 ? `${file.path}#${baseSlug}` : `${file.path}#${baseSlug}:${occurrence}`;
2155
+ return {
2156
+ id: makeNodeId(projectId, "doc_section", naturalKey),
2157
+ kind: "doc_section",
2158
+ projectId,
2159
+ ts: file.ts,
2160
+ source: "docs",
2161
+ title: sectionTitle(file.path, chunk.heading, index, chunks.length),
2162
+ body: truncate(chunk.text, maxBody),
2163
+ files: [{ path: file.path, insertions: null, deletions: null, binary: false }],
2164
+ signal: scoreDocSection(file.path, chunk.heading, chunk.text),
2165
+ meta: {
2166
+ path: file.path,
2167
+ heading: chunk.heading,
2168
+ chunkIndex: index,
2169
+ chunkCount: chunks.length
2170
+ }
2171
+ };
2172
+ });
2173
+ }
2174
+ function collectDocFiles(files, projectId, opts = {}) {
2175
+ return files.flatMap((file) => toMemoryNodes3(file, projectId, opts));
2176
+ }
2177
+
1575
2178
  // src/collectors/shell-history.ts
1576
2179
  var DEFAULT_MAX_BODY_CHARS3 = 1e3;
1577
- var MAX_TITLE_CHARS4 = 200;
2180
+ var MAX_TITLE_CHARS5 = 200;
1578
2181
  var NOISE = /^(cd|ls|dir|pwd|clear|cls|exit|history|whoami|date|type|cat|more|less|ll|la)\b/i;
1579
2182
  var BUILD_TEST = /^(npm|pnpm|yarn)\s+(run\s+)?(test|build|lint|typecheck|tsc)\b|^(pytest|go\s+test|cargo\s+(test|build)|mvn\s+test|gradle\s+test|dotnet\s+(test|build))\b/i;
1580
2183
  var INSTALL = /^(npm|pnpm|yarn)\s+(install|add|remove|uninstall|ci)\b|^pip\s+install\b|^(cargo\s+add|go\s+get|composer\s+require)\b/i;
@@ -1615,7 +2218,7 @@ function toMemoryNode2(entry, projectId, opts = {}) {
1615
2218
  projectId,
1616
2219
  ts: entry.ts,
1617
2220
  source: `shell:${entry.shell}`,
1618
- title: truncate(titleLine, MAX_TITLE_CHARS4),
2221
+ title: truncate(titleLine, MAX_TITLE_CHARS5),
1619
2222
  body: renderBody(entry, maxBody),
1620
2223
  files: [],
1621
2224
  signal: scoreShellCommand(entry),
@@ -1634,24 +2237,24 @@ function collectShellHistory(entries, projectId, opts = {}) {
1634
2237
  }
1635
2238
 
1636
2239
  // src/conversation/claude-code-reader.ts
1637
- import { readFile as readFile3 } from "fs/promises";
2240
+ import { readFile as readFile4 } from "fs/promises";
1638
2241
 
1639
2242
  // src/conversation/paths.ts
1640
- import { existsSync as existsSync2 } from "fs";
2243
+ import { existsSync as existsSync3 } from "fs";
1641
2244
  import { readdir } from "fs/promises";
1642
- import { homedir as homedir2 } from "os";
1643
- import { join as join3 } from "path";
2245
+ import { homedir as homedir3 } from "os";
2246
+ import { join as join5 } from "path";
1644
2247
  function claudeProjectSlug(repoRoot) {
1645
2248
  return repoRoot.replace(/[\\/:]/g, "-");
1646
2249
  }
1647
2250
  function claudeProjectTranscriptDir(repoRoot) {
1648
- return join3(homedir2(), ".claude", "projects", claudeProjectSlug(repoRoot));
2251
+ return join5(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
1649
2252
  }
1650
2253
  async function listTranscriptFiles(repoRoot) {
1651
2254
  const dir = claudeProjectTranscriptDir(repoRoot);
1652
- if (!existsSync2(dir)) return [];
2255
+ if (!existsSync3(dir)) return [];
1653
2256
  const entries = await readdir(dir, { withFileTypes: true });
1654
- return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join3(dir, e.name));
2257
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join5(dir, e.name));
1655
2258
  }
1656
2259
 
1657
2260
  // src/conversation/claude-code-reader.ts
@@ -1724,15 +2327,15 @@ async function collectClaudeCodeTranscripts(repoRoot) {
1724
2327
  const files = await listTranscriptFiles(repoRoot);
1725
2328
  const turns = [];
1726
2329
  for (const file of files) {
1727
- const raw = await readFile3(file, "utf8");
2330
+ const raw = await readFile4(file, "utf8");
1728
2331
  turns.push(...parseClaudeCodeTranscript(raw));
1729
2332
  }
1730
2333
  return turns;
1731
2334
  }
1732
2335
 
1733
2336
  // src/docs/read.ts
1734
- import { readFile as readFile4, stat } from "fs/promises";
1735
- import { join as join4 } from "path";
2337
+ import { readFile as readFile5, stat } from "fs/promises";
2338
+ import { join as join6 } from "path";
1736
2339
  var DEFAULT_PATHSPECS = ["*.md"];
1737
2340
  async function listDocFiles(repoRoot, opts = {}) {
1738
2341
  const pathspecs = opts.include ?? DEFAULT_PATHSPECS;
@@ -1745,11 +2348,11 @@ async function readDocFiles(repoRoot, opts = {}) {
1745
2348
  const unreadable = [];
1746
2349
  for (const relPath of paths) {
1747
2350
  const path = relPath.replace(/\\/g, "/");
1748
- const absPath = join4(repoRoot, relPath);
2351
+ const absPath = join6(repoRoot, relPath);
1749
2352
  let content;
1750
2353
  let mtime;
1751
2354
  try {
1752
- [content, { mtime }] = await Promise.all([readFile4(absPath, "utf8"), stat(absPath)]);
2355
+ [content, { mtime }] = await Promise.all([readFile5(absPath, "utf8"), stat(absPath)]);
1753
2356
  } catch {
1754
2357
  unreadable.push(path);
1755
2358
  continue;
@@ -1760,11 +2363,11 @@ async function readDocFiles(repoRoot, opts = {}) {
1760
2363
  }
1761
2364
 
1762
2365
  // src/shell/detect.ts
1763
- import { existsSync as existsSync3 } from "fs";
1764
- import { readFile as readFile6, stat as stat2 } from "fs/promises";
2366
+ import { existsSync as existsSync4 } from "fs";
2367
+ import { readFile as readFile7, stat as stat2 } from "fs/promises";
1765
2368
 
1766
2369
  // src/shell/hook-log.ts
1767
- import { appendFile, mkdir as mkdir3, readFile as readFile5 } from "fs/promises";
2370
+ import { appendFile, mkdir as mkdir4, readFile as readFile6 } from "fs/promises";
1768
2371
  import { dirname as dirname3 } from "path";
1769
2372
  function parseHookLogLine(line) {
1770
2373
  const trimmed = line.trim();
@@ -1789,7 +2392,7 @@ function parseHookLogLine(line) {
1789
2392
  async function readHookLog(path, fromLine) {
1790
2393
  let raw;
1791
2394
  try {
1792
- raw = await readFile5(path, "utf8");
2395
+ raw = await readFile6(path, "utf8");
1793
2396
  } catch {
1794
2397
  return { entries: [], totalLines: fromLine };
1795
2398
  }
@@ -1922,8 +2525,8 @@ function hookEntryToRaw(e) {
1922
2525
  };
1923
2526
  }
1924
2527
  async function tryReadScrapeSource(path, parse, tailLines) {
1925
- if (!existsSync3(path)) return null;
1926
- const [raw, stats] = await Promise.all([readFile6(path, "utf8"), stat2(path)]);
2528
+ if (!existsSync4(path)) return null;
2529
+ const [raw, stats] = await Promise.all([readFile7(path, "utf8"), stat2(path)]);
1927
2530
  return parse(raw, stats.mtimeMs, { tailLines });
1928
2531
  }
1929
2532
  async function collectAvailableShellHistory(opts = {}) {
@@ -1931,7 +2534,7 @@ async function collectAvailableShellHistory(opts = {}) {
1931
2534
  const tailLines = opts.tailLines ?? 300;
1932
2535
  const preferHook = opts.preferHook ?? true;
1933
2536
  const hookPath = hookLogPath();
1934
- const hookExists = existsSync3(hookPath);
2537
+ const hookExists = existsSync4(hookPath);
1935
2538
  if (hookExists) {
1936
2539
  const fromLine = Number(opts.hookCursor ?? "0") || 0;
1937
2540
  const { entries, totalLines } = await readHookLog(hookPath, fromLine);
@@ -1987,25 +2590,25 @@ function addStats(into, from) {
1987
2590
  async function syncGit(store, projectId, opts, repo, config, log) {
1988
2591
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
1989
2592
  if (!repo.head) {
1990
- log(`${pc3.yellow("git")} skipped -- repository has no commits yet`);
2593
+ log(`${pc4.yellow("git")} skipped -- repository has no commits yet`);
1991
2594
  return { totals, seen: 0 };
1992
2595
  }
1993
2596
  if (!config.sources.git.enabled) {
1994
- log(`${pc3.dim("git")} disabled in config`);
2597
+ log(`${pc4.dim("git")} disabled in config`);
1995
2598
  return { totals, seen: 0 };
1996
2599
  }
1997
2600
  let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, GIT_SOURCE);
1998
2601
  if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
1999
- log(`${pc3.yellow("git cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a full walk`);
2602
+ log(`${pc4.yellow("git cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a full walk`);
2000
2603
  cursor = null;
2001
2604
  }
2002
2605
  if (cursor === repo.head) {
2003
- log(`${pc3.green("git up to date")} at ${repo.head.slice(0, 7)}`);
2606
+ log(`${pc4.green("git up to date")} at ${repo.head.slice(0, 7)}`);
2004
2607
  store.setSyncCursor(projectId, GIT_SOURCE, repo.head);
2005
2608
  return { totals, seen: 0 };
2006
2609
  }
2007
2610
  log(
2008
- `${pc3.dim("git syncing")} ${repo.branch ?? "HEAD"} ${cursor ? `${cursor.slice(0, 7)}..${repo.head.slice(0, 7)}` : "(full history)"}`
2611
+ `${pc4.dim("git syncing")} ${repo.branch ?? "HEAD"} ${cursor ? `${cursor.slice(0, 7)}..${repo.head.slice(0, 7)}` : "(full history)"}`
2009
2612
  );
2010
2613
  let batch = [];
2011
2614
  let seen = 0;
@@ -2013,7 +2616,7 @@ async function syncGit(store, projectId, opts, repo, config, log) {
2013
2616
  if (batch.length === 0) return;
2014
2617
  addStats(totals, store.upsertNodes(batch));
2015
2618
  batch = [];
2016
- log(` ${pc3.dim(`${seen} commits read, ${totals.inserted} new`)}`);
2619
+ log(` ${pc4.dim(`${seen} commits read, ${totals.inserted} new`)}`);
2017
2620
  };
2018
2621
  const nodes = collectGitCommits(repo.root, projectId, {
2019
2622
  afterCommit: cursor,
@@ -2031,10 +2634,51 @@ async function syncGit(store, projectId, opts, repo, config, log) {
2031
2634
  store.setSyncCursor(projectId, GIT_SOURCE, repo.head);
2032
2635
  return { totals, seen };
2033
2636
  }
2637
+ async function syncDiffs(store, projectId, opts, repo, config, log) {
2638
+ const totals = { inserted: 0, updated: 0, unchanged: 0 };
2639
+ if (!repo.head) return { totals, seen: 0 };
2640
+ if (!config.sources.diff.enabled) {
2641
+ log(`${pc4.dim("diff")} disabled in config`);
2642
+ return { totals, seen: 0 };
2643
+ }
2644
+ let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, DIFF_SOURCE);
2645
+ if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
2646
+ log(`${pc4.yellow("diff cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a bounded walk`);
2647
+ cursor = null;
2648
+ }
2649
+ if (cursor === repo.head) {
2650
+ store.setSyncCursor(projectId, DIFF_SOURCE, repo.head);
2651
+ return { totals, seen: 0 };
2652
+ }
2653
+ let batch = [];
2654
+ let seen = 0;
2655
+ const flush = () => {
2656
+ if (batch.length === 0) return;
2657
+ addStats(totals, store.upsertNodes(batch));
2658
+ batch = [];
2659
+ };
2660
+ const nodes = collectCommitDiffs(repo.root, projectId, {
2661
+ afterCommit: cursor,
2662
+ since: opts.since ?? config.sources.git.since,
2663
+ maxCount: config.sources.diff.maxCommits,
2664
+ maxFilesPerCommit: config.sources.diff.maxFilesPerCommit,
2665
+ contextLines: config.sources.diff.contextLines,
2666
+ maxBodyChars: config.limits.maxBodyChars
2667
+ });
2668
+ for await (const node of nodes) {
2669
+ batch.push(node);
2670
+ seen += 1;
2671
+ if (batch.length >= BATCH_SIZE) flush();
2672
+ }
2673
+ flush();
2674
+ store.setSyncCursor(projectId, DIFF_SOURCE, repo.head);
2675
+ log(` ${pc4.dim(`${DIFF_SOURCE}: ${seen} file diff(s) read`)}`);
2676
+ return { totals, seen };
2677
+ }
2034
2678
  async function syncShell(store, projectId, opts, repoRoot, config, log) {
2035
2679
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
2036
2680
  if (!config.sources.shell.enabled) {
2037
- log(`${pc3.dim("shell")} disabled in config`);
2681
+ log(`${pc4.dim("shell")} disabled in config`);
2038
2682
  return { totals, seen: 0 };
2039
2683
  }
2040
2684
  const results = await collectAvailableShellHistory({
@@ -2043,7 +2687,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
2043
2687
  hookCursor: store.getSyncCursor(projectId, "shell:pwsh-hook")
2044
2688
  });
2045
2689
  if (results.length === 0) {
2046
- log(`${pc3.dim("shell")} no history source found on this machine`);
2690
+ log(`${pc4.dim("shell")} no history source found on this machine`);
2047
2691
  return { totals, seen: 0 };
2048
2692
  }
2049
2693
  let seen = 0;
@@ -2055,7 +2699,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
2055
2699
  addStats(totals, store.upsertNodes(nodes));
2056
2700
  }
2057
2701
  store.setSyncCursor(projectId, sourceKey, result.cursorAfter ?? `scanned:${result.entries.length}`);
2058
- log(` ${pc3.dim(`${sourceKey}: ${nodes.length} entr${nodes.length === 1 ? "y" : "ies"} read`)}`);
2702
+ log(` ${pc4.dim(`${sourceKey}: ${nodes.length} entr${nodes.length === 1 ? "y" : "ies"} read`)}`);
2059
2703
  }
2060
2704
  return { totals, seen };
2061
2705
  }
@@ -2068,20 +2712,20 @@ async function syncConversation(store, projectId, repoRoot, config, log, forceEn
2068
2712
  }
2069
2713
  const turns = await collectClaudeCodeTranscripts(repoRoot);
2070
2714
  if (turns.length === 0) {
2071
- log(`${pc3.dim("conversation")} no transcripts found`);
2715
+ log(`${pc4.dim("conversation")} no transcripts found`);
2072
2716
  return { totals, seen: 0 };
2073
2717
  }
2074
2718
  const nodes = collectConversationTurns(turns, projectId, { maxBodyChars: config.limits.maxBodyChars });
2075
2719
  if (nodes.length > 0) addStats(totals, store.upsertNodes(nodes));
2076
2720
  store.setSyncCursor(projectId, CONVERSATION_SOURCE, `scanned:${nodes.length}`);
2077
- log(` ${pc3.dim(`${CONVERSATION_SOURCE}: ${nodes.length} of ${turns.length} exchange(s) kept`)}`);
2721
+ log(` ${pc4.dim(`${CONVERSATION_SOURCE}: ${nodes.length} of ${turns.length} exchange(s) kept`)}`);
2078
2722
  return { totals, seen: nodes.length };
2079
2723
  }
2080
2724
  var DOCS_SOURCE = "docs";
2081
2725
  async function syncDocs(store, projectId, repoRoot, config, log) {
2082
2726
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
2083
2727
  if (!config.sources.docs.enabled) {
2084
- log(`${pc3.dim("docs")} disabled in config`);
2728
+ log(`${pc4.dim("docs")} disabled in config`);
2085
2729
  return { totals, seen: 0 };
2086
2730
  }
2087
2731
  const { files, unreadable } = await readDocFiles(repoRoot, { include: config.sources.docs.include });
@@ -2095,11 +2739,11 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
2095
2739
  );
2096
2740
  store.setSyncCursor(projectId, DOCS_SOURCE, `scanned:${nodes.length}`);
2097
2741
  if (files.length === 0 && unreadable.length === 0) {
2098
- log(`${pc3.dim("docs")} no tracked .md files found`);
2742
+ log(`${pc4.dim("docs")} no tracked .md files found`);
2099
2743
  } else {
2100
- const prunedPart = pruned > 0 ? `, ${pc3.yellow(`${pruned} stale removed`)}` : "";
2744
+ const prunedPart = pruned > 0 ? `, ${pc4.yellow(`${pruned} stale removed`)}` : "";
2101
2745
  const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (kept)` : "";
2102
- log(` ${pc3.dim(`${DOCS_SOURCE}: ${nodes.length} section(s) from ${files.length} file(s)`)}${prunedPart}${pc3.dim(skippedPart)}`);
2746
+ log(` ${pc4.dim(`${DOCS_SOURCE}: ${nodes.length} section(s) from ${files.length} file(s)`)}${prunedPart}${pc4.dim(skippedPart)}`);
2103
2747
  }
2104
2748
  return { totals, seen: nodes.length };
2105
2749
  }
@@ -2114,11 +2758,13 @@ async function runSync(opts) {
2114
2758
  const started = Date.now();
2115
2759
  try {
2116
2760
  store.upsertProject({ id: projectId, root: repo.root, originUrl: repo.originUrl });
2761
+ await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
2117
2762
  if (opts.rebuild) {
2118
2763
  const removed = store.clearProject(projectId);
2119
- log(`${pc3.dim("rebuild")} dropped ${removed} existing node(s)`);
2764
+ log(`${pc4.dim("rebuild")} dropped ${removed} existing node(s)`);
2120
2765
  }
2121
2766
  const git2 = await syncGit(store, projectId, opts, repo, config, log);
2767
+ const diffs = await syncDiffs(store, projectId, opts, repo, config, log);
2122
2768
  const shell = await syncShell(store, projectId, opts, repo.root, config, log);
2123
2769
  const conversation = await syncConversation(store, projectId, repo.root, config, log, opts.conversationOverride);
2124
2770
  const docs = await syncDocs(store, projectId, repo.root, config, log);
@@ -2126,15 +2772,16 @@ async function runSync(opts) {
2126
2772
  if (!opts.noEmbed) {
2127
2773
  const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId);
2128
2774
  if (result.embedded > 0) {
2129
- embedLine = ` ${pc3.dim(`vector: ${result.embedded} node(s) embedded`)}${result.skipped > 0 ? pc3.dim(`, ${result.skipped} skipped`) : ""}
2775
+ embedLine = ` ${pc4.dim(`vector: ${result.embedded} node(s) embedded`)}${result.skipped > 0 ? pc4.dim(`, ${result.skipped} skipped`) : ""}
2130
2776
  `;
2131
2777
  } else if (result.providerUnavailable) {
2132
- log(`${pc3.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
2778
+ log(`${pc4.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
2133
2779
  }
2134
2780
  }
2135
2781
  store.markSynced(projectId);
2136
2782
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
2137
2783
  addStats(totals, git2.totals);
2784
+ addStats(totals, diffs.totals);
2138
2785
  addStats(totals, shell.totals);
2139
2786
  addStats(totals, conversation.totals);
2140
2787
  addStats(totals, docs.totals);
@@ -2143,11 +2790,12 @@ async function runSync(opts) {
2143
2790
  const conversationEnabled = opts.conversationOverride ?? config.sources.conversation.enabled;
2144
2791
  const conversationPart = conversationEnabled ? `, ${conversation.seen} conversation exchange(s)` : "";
2145
2792
  const docsPart = config.sources.docs.enabled ? `, ${docs.seen} doc section(s)` : "";
2793
+ const diffPart = config.sources.diff.enabled ? `, ${diffs.seen} file diff(s)` : "";
2146
2794
  out(
2147
2795
  [
2148
- `${pc3.green("synced")} ${git2.seen} commit(s), ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${docsPart} in ${elapsed}s`,
2149
- ` ${pc3.green(`+${totals.inserted} new`)} ${pc3.yellow(`~${totals.updated} updated`)} ${pc3.dim(`=${totals.unchanged} unchanged`)}`,
2150
- ` ${pc3.dim(`${stats.total} node(s) total across ${stats.distinctFiles} file path(s)`)}`,
2796
+ `${pc4.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${docsPart} in ${elapsed}s`,
2797
+ ` ${pc4.green(`+${totals.inserted} new`)} ${pc4.yellow(`~${totals.updated} updated`)} ${pc4.dim(`=${totals.unchanged} unchanged`)}`,
2798
+ ` ${pc4.dim(`${stats.total} node(s) total across ${stats.distinctFiles} file path(s)`)}`,
2151
2799
  ""
2152
2800
  ].join("\n") + embedLine
2153
2801
  );
@@ -2164,20 +2812,39 @@ async function searchMemory(input) {
2164
2812
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
2165
2813
  const budget = input.budget ?? 2e3;
2166
2814
  const candidates = input.candidates ?? 30;
2815
+ const queryOpts = {
2816
+ budget,
2817
+ candidates,
2818
+ embeddingProvider: input.noVector ? null : new OllamaEmbeddingProvider()
2819
+ };
2820
+ if (input.allProjects) {
2821
+ const opened = await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath });
2822
+ try {
2823
+ const { bm25Count, vectorCount, hits, packed } = await runCrossProjectQuery(opened.sources, input.query, queryOpts);
2824
+ return {
2825
+ text: renderContextBlock(input.query, packed),
2826
+ matched: hits.length,
2827
+ bm25Matched: bm25Count,
2828
+ vectorMatched: vectorCount,
2829
+ tokensUsed: packed.tokensUsed,
2830
+ tokensBudget: packed.tokensBudget,
2831
+ projectsSearched: opened.sources.map((s) => s.label)
2832
+ };
2833
+ } finally {
2834
+ opened.close();
2835
+ }
2836
+ }
2167
2837
  const store = MemoryStore.open(ws.dbPath);
2168
2838
  try {
2169
- const { bm25Count, vectorCount, hits, packed } = await runHybridQuery(store, projectId, input.query, {
2170
- budget,
2171
- candidates,
2172
- embeddingProvider: input.noVector ? null : new OllamaEmbeddingProvider()
2173
- });
2839
+ const { bm25Count, vectorCount, hits, packed } = await runHybridQuery(store, projectId, input.query, queryOpts);
2174
2840
  return {
2175
2841
  text: renderContextBlock(input.query, packed),
2176
2842
  matched: hits.length,
2177
2843
  bm25Matched: bm25Count,
2178
2844
  vectorMatched: vectorCount,
2179
2845
  tokensUsed: packed.tokensUsed,
2180
- tokensBudget: packed.tokensBudget
2846
+ tokensBudget: packed.tokensBudget,
2847
+ projectsSearched: [basename2(repo.root) || repo.root]
2181
2848
  };
2182
2849
  } finally {
2183
2850
  store.close();
@@ -2220,15 +2887,18 @@ function createServer() {
2220
2887
  "search_memory",
2221
2888
  {
2222
2889
  title: "Search remembered project history",
2223
- description: "Search a NexusMem-tracked repository's remembered history: git commits, shell commands, tracked markdown docs, and (if enabled) conversation transcripts. Returns a token-budgeted, ranked context block -- not raw search results.",
2890
+ description: "Search a NexusMem-tracked repository's remembered history: git commits, code diffs (the patch of each changed file), shell commands, tracked markdown docs, and (if enabled) conversation transcripts. Returns a token-budgeted, ranked context block -- not raw search results.",
2224
2891
  inputSchema: {
2225
- projectRoot: z2.string().describe("Absolute path to the repository root"),
2226
- query: z2.string().describe("Free-text question or search terms"),
2227
- budget: z2.number().int().positive().optional().describe("Max tokens in the returned context block. Default 2000.")
2892
+ projectRoot: z3.string().describe("Absolute path to the repository root"),
2893
+ query: z3.string().describe("Free-text question or search terms"),
2894
+ budget: z3.number().int().positive().optional().describe("Max tokens in the returned context block. Default 2000."),
2895
+ allProjects: z3.boolean().optional().describe(
2896
+ "Search every repository NexusMem has been run in on this machine, not just projectRoot. Use when the answer may live in a different project (a pattern solved elsewhere, a tool that failed the same way before). Each result is tagged with its repository."
2897
+ )
2228
2898
  }
2229
2899
  },
2230
- async ({ projectRoot, query, budget }) => {
2231
- const result = await searchMemory({ projectRoot, query, budget });
2900
+ async ({ projectRoot, query, budget, allProjects }) => {
2901
+ const result = await searchMemory({ projectRoot, query, budget, allProjects });
2232
2902
  return {
2233
2903
  content: [{ type: "text", text: result.text }],
2234
2904
  structuredContent: {
@@ -2237,7 +2907,8 @@ function createServer() {
2237
2907
  bm25Matched: result.bm25Matched,
2238
2908
  vectorMatched: result.vectorMatched,
2239
2909
  tokensUsed: result.tokensUsed,
2240
- tokensBudget: result.tokensBudget
2910
+ tokensBudget: result.tokensBudget,
2911
+ projectsSearched: result.projectsSearched
2241
2912
  }
2242
2913
  };
2243
2914
  }
@@ -2246,9 +2917,9 @@ function createServer() {
2246
2917
  "sync_project",
2247
2918
  {
2248
2919
  title: "Sync remembered history",
2249
- description: "Ingest new git, shell, docs and (if enabled) conversation history for a NexusMem-tracked repository into its local database.",
2920
+ description: "Ingest new git, diff, shell, docs and (if enabled) conversation history for a NexusMem-tracked repository into its local database.",
2250
2921
  inputSchema: {
2251
- projectRoot: z2.string().describe("Absolute path to the repository root")
2922
+ projectRoot: z3.string().describe("Absolute path to the repository root")
2252
2923
  }
2253
2924
  },
2254
2925
  async ({ projectRoot }) => {
@@ -2262,7 +2933,7 @@ function createServer() {
2262
2933
  title: "Show what is remembered",
2263
2934
  description: "Report how many nodes NexusMem currently remembers for a repository, broken down by kind and source.",
2264
2935
  inputSchema: {
2265
- projectRoot: z2.string().describe("Absolute path to the repository root")
2936
+ projectRoot: z3.string().describe("Absolute path to the repository root")
2266
2937
  }
2267
2938
  },
2268
2939
  async ({ projectRoot }) => {
@@ -2282,17 +2953,41 @@ async function runMcpServer() {
2282
2953
  }
2283
2954
 
2284
2955
  // src/cli/commands/query.ts
2285
- import pc4 from "picocolors";
2956
+ import pc5 from "picocolors";
2286
2957
  async function runQuery(opts) {
2287
- const { ws, projectId } = await loadContext(opts.cwd);
2288
- const store = MemoryStore.open(ws.dbPath);
2958
+ const { repo, ws, projectId } = await loadContext(opts.cwd);
2959
+ const opened = opts.allProjects ? await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath }) : null;
2960
+ let store = null;
2289
2961
  try {
2290
- const { bm25Count, vectorCount, hits, packed } = await runHybridQuery(store, projectId, opts.query, {
2962
+ const queryOpts = {
2291
2963
  budget: opts.budget,
2292
2964
  candidates: opts.candidates,
2293
2965
  halfLifeDays: opts.halfLifeDays,
2294
2966
  embeddingProvider: opts.noVector ? null : new OllamaEmbeddingProvider()
2295
- });
2967
+ };
2968
+ let result;
2969
+ if (opened) {
2970
+ result = await runCrossProjectQuery(opened.sources, opts.query, queryOpts);
2971
+ } else {
2972
+ store = MemoryStore.open(ws.dbPath);
2973
+ result = await runHybridQuery(store, projectId, opts.query, queryOpts);
2974
+ }
2975
+ const { bm25Count, vectorCount, hits, packed } = result;
2976
+ if (opened && !opts.json) {
2977
+ const searched = opened.sources.map((s) => s.label).join(", ");
2978
+ process.stderr.write(`${pc5.dim("scope ")} ${opened.sources.length} project(s): ${searched}
2979
+ `);
2980
+ for (const { entry } of opened.unreadable) {
2981
+ process.stderr.write(`${pc5.yellow("unreadable")} ${entry.root} -- skipped
2982
+ `);
2983
+ }
2984
+ if (opened.missing.length > 0) {
2985
+ process.stderr.write(
2986
+ `${pc5.dim("skipped")} ${opened.missing.length} registered project(s) whose database is not on disk ${pc5.dim("(nexusmem projects --prune to forget them)")}
2987
+ `
2988
+ );
2989
+ }
2990
+ }
2296
2991
  const matched = hits.length;
2297
2992
  const rawTokens = hits.reduce((n, h) => n + approxTokens(h.body), 0);
2298
2993
  const packerEfficiency = rawTokens > 0 ? 1 - packed.tokensUsed / rawTokens : 0;
@@ -2317,15 +3012,15 @@ async function runQuery(opts) {
2317
3012
  return 0;
2318
3013
  }
2319
3014
  if (matched === 0) {
2320
- process.stderr.write(`${pc4.yellow("no matches")} for "${opts.query}"
3015
+ process.stderr.write(`${pc5.yellow("no matches")} for "${opts.query}"
2321
3016
  `);
2322
3017
  return 0;
2323
3018
  }
2324
3019
  process.stderr.write(
2325
3020
  [
2326
- `${pc4.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc4.bold(String(packed.nodes.length))} into budget`,
2327
- `${pc4.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc4.dim(` (${packed.droppedForBudget} dropped for budget)`) : ""),
2328
- rawTokens > 0 ? `${pc4.dim("vs raw ")} ${rawTokens} tokens if these same matches were sent unpacked ${packerEfficiency >= 0 ? pc4.green(`(${(packerEfficiency * 100).toFixed(0)}% packer efficiency)`) : pc4.yellow(`(${(-packerEfficiency * 100).toFixed(0)}% larger -- overhead dominates on small result sets)`)}` : "",
3021
+ `${pc5.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc5.bold(String(packed.nodes.length))} into budget`,
3022
+ `${pc5.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc5.dim(` (${packed.droppedForBudget} dropped for budget)`) : ""),
3023
+ 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)`)}` : "",
2329
3024
  ""
2330
3025
  ].filter(Boolean).join("\n")
2331
3026
  );
@@ -2333,28 +3028,30 @@ async function runQuery(opts) {
2333
3028
  `);
2334
3029
  return 0;
2335
3030
  } finally {
2336
- store.close();
3031
+ opened?.close();
3032
+ store?.close();
2337
3033
  }
2338
3034
  }
2339
3035
 
2340
3036
  // src/cli/commands/scan-conversation.ts
2341
- import pc6 from "picocolors";
3037
+ import pc7 from "picocolors";
2342
3038
 
2343
3039
  // src/cli/format.ts
2344
- import pc5 from "picocolors";
3040
+ import pc6 from "picocolors";
2345
3041
  var GIT_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
2346
3042
  var SHELL_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
2347
3043
  var CONVERSATION_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
2348
3044
  var DOCS_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
3045
+ var DIFF_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
2349
3046
  function signalBand(signal, bands) {
2350
3047
  if (signal >= bands.high) return "high";
2351
3048
  if (signal >= bands.medium) return "medium";
2352
3049
  return "low";
2353
3050
  }
2354
3051
  var BAND_COLOR = {
2355
- high: pc5.green,
2356
- medium: pc5.yellow,
2357
- low: pc5.dim
3052
+ high: pc6.green,
3053
+ medium: pc6.yellow,
3054
+ low: pc6.dim
2358
3055
  };
2359
3056
  function formatSignal(signal, bands) {
2360
3057
  return BAND_COLOR[signalBand(signal, bands)](signal.toFixed(2));
@@ -2367,9 +3064,9 @@ async function runScanConversation(opts) {
2367
3064
  const files = await listTranscriptFiles(repo.root);
2368
3065
  if (!opts.json) {
2369
3066
  process.stderr.write(
2370
- files.length ? `${pc6.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
3067
+ files.length ? `${pc7.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
2371
3068
 
2372
- ` : `${pc6.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
3069
+ ` : `${pc7.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
2373
3070
  `
2374
3071
  );
2375
3072
  }
@@ -2386,7 +3083,7 @@ async function runScanConversation(opts) {
2386
3083
  const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
2387
3084
  process.stderr.write(
2388
3085
  `
2389
- ${pc6.bold(String(nodes.length))} of ${turns.length} exchange(s) above threshold ${pc6.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}` + (redactedTotal > 0 ? ` ${pc6.yellow(`${redactedTotal} secret-like value(s) redacted`)}` : "") + "\n"
3086
+ ${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"
2390
3087
  );
2391
3088
  return 0;
2392
3089
  }
@@ -2394,44 +3091,8 @@ function formatNode(node) {
2394
3091
  return [formatSignal(node.signal, CONVERSATION_SIGNAL_BANDS), node.ts.slice(0, 16).replace("T", " "), node.title].join(" ");
2395
3092
  }
2396
3093
 
2397
- // src/cli/commands/scan-docs.ts
2398
- import pc7 from "picocolors";
2399
- async function runScanDocs(opts) {
2400
- const repo = await readRepoInfo(opts.cwd);
2401
- const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
2402
- const { files, unreadable } = await readDocFiles(repo.root);
2403
- if (!opts.json) {
2404
- process.stderr.write(
2405
- files.length ? `${pc7.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
2406
-
2407
- ` : `${pc7.yellow("no tracked .md files found")}
2408
- `
2409
- );
2410
- if (unreadable.length > 0) {
2411
- process.stderr.write(`${pc7.yellow("unreadable")} ${unreadable.join(", ")}
2412
-
2413
- `);
2414
- }
2415
- }
2416
- const nodes = collectDocFiles(files, projectId).filter((n) => n.signal >= opts.minSignal);
2417
- if (opts.json) {
2418
- process.stdout.write(`${JSON.stringify(nodes, null, 2)}
2419
- `);
2420
- return 0;
2421
- }
2422
- for (const node of nodes) process.stdout.write(`${formatNode2(node)}
2423
- `);
2424
- const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
2425
- process.stderr.write(
2426
- `
2427
- ${pc7.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc7.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
2428
- `
2429
- );
2430
- return 0;
2431
- }
2432
- function formatNode2(node) {
2433
- return [formatSignal(node.signal, DOCS_SIGNAL_BANDS), node.title].join(" ");
2434
- }
3094
+ // src/cli/commands/scan-diff.ts
3095
+ import pc9 from "picocolors";
2435
3096
 
2436
3097
  // src/cli/commands/scan-git.ts
2437
3098
  import pc8 from "picocolors";
@@ -2458,7 +3119,7 @@ async function runScanGit(opts) {
2458
3119
  for await (const node of collectGitCommits(repo.root, projectId, collectOpts)) {
2459
3120
  if (node.signal < opts.minSignal) continue;
2460
3121
  nodes.push(node);
2461
- if (!opts.json) process.stdout.write(`${formatNode3(node)}
3122
+ if (!opts.json) process.stdout.write(`${formatNode2(node)}
2462
3123
  `);
2463
3124
  }
2464
3125
  if (opts.json) {
@@ -2471,7 +3132,7 @@ ${summarize2(nodes)}
2471
3132
  `);
2472
3133
  return 0;
2473
3134
  }
2474
- function formatNode3(node) {
3135
+ function formatNode2(node) {
2475
3136
  const sha = String(node.meta.shortSha ?? "").padEnd(9);
2476
3137
  const date = node.ts.slice(0, 10);
2477
3138
  const files = Number(node.meta.filesChanged ?? 0);
@@ -2502,17 +3163,103 @@ ${hottest.join("\n")}` : ""
2502
3163
  ].filter(Boolean).join("\n");
2503
3164
  }
2504
3165
 
3166
+ // src/cli/commands/scan-diff.ts
3167
+ var DEFAULT_SCAN_COMMITS = 50;
3168
+ async function runScanDiff(opts) {
3169
+ const repo = await readRepoInfo(opts.cwd);
3170
+ const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
3171
+ if (!opts.json) {
3172
+ process.stderr.write(
3173
+ [
3174
+ `${pc9.dim("repo ")} ${repo.root}`,
3175
+ `${pc9.dim("branch ")} ${repo.branch ?? pc9.yellow("(detached)")}`,
3176
+ `${pc9.dim("project")} ${pc9.cyan(projectId)}`,
3177
+ ""
3178
+ ].join("\n")
3179
+ );
3180
+ }
3181
+ const nodes = [];
3182
+ for await (const node of collectCommitDiffs(repo.root, projectId, {
3183
+ since: opts.since ?? null,
3184
+ maxCount: opts.limit ?? DEFAULT_SCAN_COMMITS
3185
+ })) {
3186
+ if (node.signal < opts.minSignal) continue;
3187
+ nodes.push(node);
3188
+ if (!opts.json) process.stdout.write(`${formatNode3(node)}
3189
+ `);
3190
+ }
3191
+ if (opts.json) {
3192
+ process.stdout.write(`${JSON.stringify(nodes, null, 2)}
3193
+ `);
3194
+ return 0;
3195
+ }
3196
+ process.stderr.write(`
3197
+ ${summarize2(nodes)}
3198
+ `);
3199
+ return 0;
3200
+ }
3201
+ function formatNode3(node) {
3202
+ const sha = String(node.meta.shortSha ?? "").padEnd(9);
3203
+ const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
3204
+ return [
3205
+ formatSignal(node.signal, DIFF_SIGNAL_BANDS),
3206
+ pc9.dim(node.ts.slice(0, 10)),
3207
+ pc9.magenta(sha),
3208
+ String(node.meta.path ?? ""),
3209
+ pc9.dim(`(${churn}, ${node.meta.hunkCount ?? 0} hunk(s))`)
3210
+ ].join(" ");
3211
+ }
3212
+
3213
+ // src/cli/commands/scan-docs.ts
3214
+ import pc10 from "picocolors";
3215
+ async function runScanDocs(opts) {
3216
+ const repo = await readRepoInfo(opts.cwd);
3217
+ const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
3218
+ const { files, unreadable } = await readDocFiles(repo.root);
3219
+ if (!opts.json) {
3220
+ process.stderr.write(
3221
+ files.length ? `${pc10.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
3222
+
3223
+ ` : `${pc10.yellow("no tracked .md files found")}
3224
+ `
3225
+ );
3226
+ if (unreadable.length > 0) {
3227
+ process.stderr.write(`${pc10.yellow("unreadable")} ${unreadable.join(", ")}
3228
+
3229
+ `);
3230
+ }
3231
+ }
3232
+ const nodes = collectDocFiles(files, projectId).filter((n) => n.signal >= opts.minSignal);
3233
+ if (opts.json) {
3234
+ process.stdout.write(`${JSON.stringify(nodes, null, 2)}
3235
+ `);
3236
+ return 0;
3237
+ }
3238
+ for (const node of nodes) process.stdout.write(`${formatNode4(node)}
3239
+ `);
3240
+ const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
3241
+ process.stderr.write(
3242
+ `
3243
+ ${pc10.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc10.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
3244
+ `
3245
+ );
3246
+ return 0;
3247
+ }
3248
+ function formatNode4(node) {
3249
+ return [formatSignal(node.signal, DOCS_SIGNAL_BANDS), node.title].join(" ");
3250
+ }
3251
+
2505
3252
  // src/cli/commands/scan-shell.ts
2506
- import pc9 from "picocolors";
3253
+ import pc11 from "picocolors";
2507
3254
  async function runScanShell(opts) {
2508
3255
  const repo = await readRepoInfo(opts.cwd);
2509
3256
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
2510
3257
  const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
2511
3258
  if (!opts.json) {
2512
3259
  process.stderr.write(
2513
- results.length ? `${pc9.dim("sources found")} ${results.map((r) => r.name).join(", ")}
3260
+ results.length ? `${pc11.dim("sources found")} ${results.map((r) => r.name).join(", ")}
2514
3261
 
2515
- ` : `${pc9.yellow("no shell history source found on this machine")}
3262
+ ` : `${pc11.yellow("no shell history source found on this machine")}
2516
3263
  `
2517
3264
  );
2518
3265
  }
@@ -2521,9 +3268,9 @@ async function runScanShell(opts) {
2521
3268
  const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
2522
3269
  allNodes.push(...nodes);
2523
3270
  if (!opts.json) {
2524
- process.stdout.write(`${pc9.bold(`shell:${result.name}`)} ${pc9.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
3271
+ process.stdout.write(`${pc11.bold(`shell:${result.name}`)} ${pc11.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
2525
3272
  `);
2526
- for (const node of nodes) process.stdout.write(`${formatNode4(node)}
3273
+ for (const node of nodes) process.stdout.write(`${formatNode5(node)}
2527
3274
  `);
2528
3275
  process.stdout.write("\n");
2529
3276
  }
@@ -2534,20 +3281,20 @@ async function runScanShell(opts) {
2534
3281
  return 0;
2535
3282
  }
2536
3283
  const approxTotal = allNodes.reduce((n, x) => n + approxTokens(x.body), 0);
2537
- process.stderr.write(`${pc9.bold(String(allNodes.length))} node(s) total ${pc9.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
3284
+ process.stderr.write(`${pc11.bold(String(allNodes.length))} node(s) total ${pc11.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
2538
3285
  `);
2539
3286
  return 0;
2540
3287
  }
2541
- function formatNode4(node) {
2542
- const approx = node.meta.tsApprox ? pc9.dim("~") : " ";
3288
+ function formatNode5(node) {
3289
+ const approx = node.meta.tsApprox ? pc11.dim("~") : " ";
2543
3290
  const exit = node.meta.exitCode;
2544
- const exitLabel = typeof exit === "number" && exit !== 0 ? pc9.red(`exit ${exit}`) : "";
3291
+ const exitLabel = typeof exit === "number" && exit !== 0 ? pc11.red(`exit ${exit}`) : "";
2545
3292
  return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
2546
3293
  }
2547
3294
 
2548
3295
  // src/cli/commands/status.ts
2549
3296
  import { statSync } from "fs";
2550
- import pc10 from "picocolors";
3297
+ import pc12 from "picocolors";
2551
3298
  function humanBytes(bytes) {
2552
3299
  if (bytes < 1024) return `${bytes} B`;
2553
3300
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -2572,23 +3319,23 @@ async function runStatus(opts) {
2572
3319
  const kinds = Object.entries(stats.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
2573
3320
  process.stdout.write(
2574
3321
  [
2575
- `${pc10.dim("repo ")} ${repo.root}`,
2576
- `${pc10.dim("branch ")} ${repo.branch ?? pc10.yellow("(detached)")}`,
2577
- `${pc10.dim("project ")} ${pc10.cyan(projectId)}`,
2578
- `${pc10.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc10.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
2579
- `${pc10.dim("database")} ${ws.dbPath} ${pc10.dim(`(${humanBytes(dbBytes)})`)}`,
3322
+ `${pc12.dim("repo ")} ${repo.root}`,
3323
+ `${pc12.dim("branch ")} ${repo.branch ?? pc12.yellow("(detached)")}`,
3324
+ `${pc12.dim("project ")} ${pc12.cyan(projectId)}`,
3325
+ `${pc12.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc12.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
3326
+ `${pc12.dim("database")} ${ws.dbPath} ${pc12.dim(`(${humanBytes(dbBytes)})`)}`,
2580
3327
  "",
2581
- `${pc10.bold(String(stats.total))} node(s)${stats.total ? ` ${pc10.dim(`${stats.oldest?.slice(0, 10)} .. ${stats.newest?.slice(0, 10)}`)}` : ""}`,
3328
+ `${pc12.bold(String(stats.total))} node(s)${stats.total ? ` ${pc12.dim(`${stats.oldest?.slice(0, 10)} .. ${stats.newest?.slice(0, 10)}`)}` : ""}`,
2582
3329
  ...kinds,
2583
- stats.total ? ` ${pc10.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
3330
+ stats.total ? ` ${pc12.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
2584
3331
  "",
2585
- sources.length ? pc10.dim("sources") : pc10.yellow("no sources synced yet"),
3332
+ sources.length ? pc12.dim("sources") : pc12.yellow("no sources synced yet"),
2586
3333
  ...sources.map((s) => {
2587
3334
  const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
2588
3335
  const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
2589
- return ` ${s.source.padEnd(14)} ${pc10.dim(`last run ${when}`)} ${pc10.dim(`cursor ${cursorLabel}`)}`;
3336
+ return ` ${s.source.padEnd(14)} ${pc12.dim(`last run ${when}`)} ${pc12.dim(`cursor ${cursorLabel}`)}`;
2590
3337
  }),
2591
- gitCursor && gitCursor !== repo.head ? `${pc10.yellow("git behind HEAD")} \u2014 run ${pc10.bold("nexusmem sync")}` : "",
3338
+ gitCursor && gitCursor !== repo.head ? `${pc12.yellow("git behind HEAD")} \u2014 run ${pc12.bold("nexusmem sync")}` : "",
2592
3339
  ""
2593
3340
  ].filter((line) => line !== "").join("\n").concat("\n")
2594
3341
  );
@@ -2612,7 +3359,7 @@ function guard(run) {
2612
3359
  process.exitCode = await run();
2613
3360
  } catch (err) {
2614
3361
  if (isExpected(err)) {
2615
- process.stderr.write(`${pc11.red("error")} ${err.message}
3362
+ process.stderr.write(`${pc13.red("error")} ${err.message}
2616
3363
  `);
2617
3364
  process.exitCode = 1;
2618
3365
  return;
@@ -2650,7 +3397,7 @@ program.command("hook").description("Manage the opt-in PowerShell hook that logs
2650
3397
  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 }))())
2651
3398
  );
2652
3399
  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 }))());
2653
- 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("--json", "emit the packed result as JSON on stdout", false).action(
3400
+ 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(
2654
3401
  (text, options) => guard(
2655
3402
  () => runQuery({
2656
3403
  cwd: options.cwd,
@@ -2659,10 +3406,12 @@ program.command("query").description("Search remembered history and print a toke
2659
3406
  candidates: options.candidates,
2660
3407
  halfLifeDays: options.halfLife,
2661
3408
  noVector: !options.vector,
3409
+ allProjects: options.allProjects,
2662
3410
  json: options.json
2663
3411
  })
2664
3412
  )()
2665
3413
  );
3414
+ program.command("projects").description("List the repositories `query --all-projects` would search").option("--prune", "forget registered projects whose database is no longer on disk", false).option("--json", "emit the registry as JSON on stdout", false).action((options) => guard(() => runProjects({ prune: options.prune, json: options.json }))());
2666
3415
  program.command("scan-git").description("Preview the MemoryNodes git history would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--since <date>", "only commits newer than this git date expression, e.g. 90.days.ago").option("-n, --limit <count>", "stop after N commits", (v) => Number.parseInt(v, 10)).option("--no-merges", "skip merge commits").option("--min-signal <score>", "drop nodes below this signal", (v) => Number.parseFloat(v), 0).option("--json", "emit MemoryNodes as JSON on stdout", false).action(
2667
3416
  (options) => guard(
2668
3417
  () => runScanGit({
@@ -2675,6 +3424,17 @@ program.command("scan-git").description("Preview the MemoryNodes git history wou
2675
3424
  })
2676
3425
  )()
2677
3426
  );
3427
+ program.command("scan-diff").description("Preview the MemoryNodes commit patches would produce, one per changed file (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--since <date>", "only commits newer than this git date expression, e.g. 90.days.ago").option("-n, --limit <count>", "stop after N commits (not N nodes)", (v) => Number.parseInt(v, 10)).option("--min-signal <score>", "drop nodes below this signal", (v) => Number.parseFloat(v), 0).option("--json", "emit MemoryNodes as JSON on stdout", false).action(
3428
+ (options) => guard(
3429
+ () => runScanDiff({
3430
+ cwd: options.cwd,
3431
+ since: options.since,
3432
+ limit: options.limit,
3433
+ json: options.json,
3434
+ minSignal: options.minSignal
3435
+ })
3436
+ )()
3437
+ );
2678
3438
  program.command("scan-shell").description("Preview the MemoryNodes shell history would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("-n, --tail-lines <count>", "lines kept from each scrape-based source", (v) => Number.parseInt(v, 10), 300).option("--min-signal <score>", "drop nodes below this signal", (v) => Number.parseFloat(v), 0).option("--json", "emit MemoryNodes as JSON on stdout", false).action(
2679
3439
  (options) => guard(
2680
3440
  () => runScanShell({ cwd: options.cwd, tailLines: options.tailLines, minSignal: options.minSignal, json: options.json })
@@ -2687,7 +3447,7 @@ program.command("scan-docs").description("Preview the MemoryNodes tracked .md fi
2687
3447
  program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
2688
3448
  program.parseAsync(process.argv).catch((err) => {
2689
3449
  const message = err instanceof Error ? err.message : String(err);
2690
- process.stderr.write(`${pc11.red("error")} ${message}
3450
+ process.stderr.write(`${pc13.red("error")} ${message}
2691
3451
  `);
2692
3452
  process.exitCode = 1;
2693
3453
  });