nexusmem 0.1.1 → 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),
@@ -103,6 +119,14 @@ async function writeWorkspaceGitignore(ws) {
103
119
  await writeFile(join(ws.dir, ".gitignore"), "# Machine-local derived data.\n*\n", "utf8");
104
120
  }
105
121
 
122
+ // src/core/version.ts
123
+ import { readFileSync } from "fs";
124
+ import { fileURLToPath } from "url";
125
+ function readOwnVersion() {
126
+ const pkgPath = fileURLToPath(new URL("../../package.json", import.meta.url));
127
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version;
128
+ }
129
+
106
130
  // src/git/exec.ts
107
131
  import { spawn } from "child_process";
108
132
  var GitError = class extends Error {
@@ -306,25 +330,31 @@ import { dirname } from "path";
306
330
 
307
331
  // src/shell/paths.ts
308
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
309
338
  import { homedir } from "os";
310
339
  import { join as join2 } from "path";
311
- import { promisify } from "util";
340
+ function globalWorkspaceDir() {
341
+ return process.env.NEXUSMEM_HOME ?? join2(homedir(), ".nexusmem");
342
+ }
343
+
344
+ // src/shell/paths.ts
312
345
  var execFileAsync = promisify(execFile);
313
346
  function psReadLineHistoryPath() {
314
- const appData = process.env.APPDATA ?? join2(homedir(), "AppData", "Roaming");
315
- 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");
316
349
  }
317
350
  function bashHistoryPath() {
318
- return process.env.HISTFILE_BASH ?? join2(homedir(), ".bash_history");
351
+ return process.env.HISTFILE_BASH ?? join3(homedir2(), ".bash_history");
319
352
  }
320
353
  function zshHistoryPath() {
321
- return process.env.HISTFILE ?? join2(homedir(), ".zsh_history");
322
- }
323
- function globalWorkspaceDir() {
324
- return join2(homedir(), ".nexusmem");
354
+ return process.env.HISTFILE ?? join3(homedir2(), ".zsh_history");
325
355
  }
326
356
  function hookLogPath() {
327
- return join2(globalWorkspaceDir(), "shell-history.jsonl");
357
+ return join3(globalWorkspaceDir(), "shell-history.jsonl");
328
358
  }
329
359
  async function resolvePowerShellProfilePath(exe = "powershell") {
330
360
  try {
@@ -478,6 +508,74 @@ async function runHookStatus(opts) {
478
508
  import { relative } from "path";
479
509
  import pc2 from "picocolors";
480
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
+
481
579
  // src/core/ids.ts
482
580
  import { createHash } from "crypto";
483
581
  var KEY_SEP = "\0";
@@ -897,6 +995,7 @@ async function runInit(opts) {
897
995
  } finally {
898
996
  store.close();
899
997
  }
998
+ await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
900
999
  const lines = [
901
1000
  `${pc2.green("initialized")} ${ws.dir}`,
902
1001
  ` project ${pc2.cyan(projectId)}`,
@@ -931,10 +1030,68 @@ async function runInit(opts) {
931
1030
  return 0;
932
1031
  }
933
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
+
934
1088
  // src/mcp/server.ts
935
1089
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
936
1090
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
937
- 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";
938
1095
 
939
1096
  // src/core/text.ts
940
1097
  function truncate(s, max) {
@@ -948,22 +1105,126 @@ function approxTokens(text) {
948
1105
  var DEFAULT_SUMMARY_CHARS = 320;
949
1106
  var NODE_OVERHEAD_TOKENS = 8;
950
1107
  var CONVERSATION_ANSWER_MARKER = "\n\nA: ";
951
- 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) {
952
1203
  const answerIdx = hit.body.indexOf(CONVERSATION_ANSWER_MARKER);
953
1204
  if (answerIdx !== -1) {
954
1205
  const answer = hit.body.slice(answerIdx + CONVERSATION_ANSWER_MARKER.length).trim();
955
1206
  if (answer) return truncate(answer, maxChars);
956
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
+ }
957
1217
  const rest = hit.body.startsWith(hit.title) ? hit.body.slice(hit.title.length).trim() : hit.body;
958
1218
  return truncate(rest || hit.title, maxChars);
959
1219
  }
960
1220
  function packContext(ranked, tokensBudget, opts = {}) {
961
1221
  const summaryChars = opts.summaryChars ?? DEFAULT_SUMMARY_CHARS;
1222
+ const query = opts.query ?? "";
962
1223
  const nodes = [];
963
1224
  let tokensUsed = 0;
964
1225
  let droppedForBudget = 0;
965
1226
  for (const hit of ranked) {
966
- const summary = summarize(hit, summaryChars);
1227
+ const summary = summarize(hit, summaryChars, query);
967
1228
  const tokens = approxTokens(hit.title) + approxTokens(summary) + NODE_OVERHEAD_TOKENS;
968
1229
  if (tokensUsed + tokens > tokensBudget) {
969
1230
  droppedForBudget += 1;
@@ -977,7 +1238,8 @@ function packContext(ranked, tokensBudget, opts = {}) {
977
1238
  signal: hit.signal,
978
1239
  score: hit.score,
979
1240
  summary,
980
- tokens
1241
+ tokens,
1242
+ ...hit.project ? { project: hit.project } : {}
981
1243
  });
982
1244
  tokensUsed += tokens;
983
1245
  }
@@ -987,9 +1249,14 @@ function renderContextBlock(query, result) {
987
1249
  if (result.nodes.length === 0) return `No remembered context matched "${query}".`;
988
1250
  const lines = [`Relevant history for: ${query}`, ""];
989
1251
  for (const node of result.nodes) {
990
- 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}`);
991
1254
  if (node.summary && node.summary !== node.title) {
992
- 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
+ }
993
1260
  }
994
1261
  }
995
1262
  return lines.join("\n");
@@ -1068,6 +1335,31 @@ function rankHits(hits, opts = {}) {
1068
1335
  }
1069
1336
 
1070
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
+ }
1071
1363
  async function runHybridQuery(store, projectId, query, opts) {
1072
1364
  const bm25Hits = store.search(projectId, query, opts.candidates);
1073
1365
  let vectorHits = [];
@@ -1078,10 +1370,55 @@ async function runHybridQuery(store, projectId, query, opts) {
1078
1370
  const hits = vectorHits.length > 0 ? mergeSearchAndVectorHits(bm25Hits, vectorHits) : bm25Hits;
1079
1371
  const relevanceScores = vectorHits.length > 0 ? reciprocalRankFusion([bm25Hits, vectorHits]) : void 0;
1080
1372
  const ranked = rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores });
1081
- const packed = packContext(ranked, opts.budget);
1373
+ const packed = packContext(ranked, opts.budget, { query });
1082
1374
  return { bm25Count: bm25Hits.length, vectorCount: vectorHits.length, hits, packed };
1083
1375
  }
1084
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
+
1085
1422
  // src/vector/embed.ts
1086
1423
  var DEFAULT_BASE_URL = "http://127.0.0.1:11434";
1087
1424
  var DEFAULT_MODEL = "nomic-embed-text";
@@ -1121,7 +1458,7 @@ var OllamaEmbeddingProvider = class {
1121
1458
  };
1122
1459
 
1123
1460
  // src/cli/commands/sync.ts
1124
- import pc3 from "picocolors";
1461
+ import pc4 from "picocolors";
1125
1462
 
1126
1463
  // src/conversation/chunk.ts
1127
1464
  var HEADING_LINE = /^#{1,6}\s+(.+)$/;
@@ -1161,21 +1498,31 @@ function chunkAssistantText(text, maxChars) {
1161
1498
 
1162
1499
  // src/conversation/redact.ts
1163
1500
  var RULES = [
1164
- { name: "private-key-block", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
1165
- { name: "aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
1166
- { name: "github-token", pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
1167
- { name: "slack-token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
1168
- { 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
+ },
1169
1514
  // key/token/secret/password = "value" or : value, in code, JSON, env-file or prose form.
1170
1515
  {
1171
1516
  name: "key-value-secret",
1172
- 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
1173
1519
  }
1174
1520
  ];
1175
- function redact(text) {
1521
+ function redact(text, profile = "all") {
1176
1522
  let redactedCount = 0;
1177
1523
  let out = text;
1178
1524
  for (const rule of RULES) {
1525
+ if (profile === "high-confidence" && !rule.highConfidence) continue;
1179
1526
  out = out.replace(rule.pattern, (_match, ...rest) => {
1180
1527
  redactedCount += 1;
1181
1528
  const key = typeof rest[0] === "string" ? rest[0] : null;
@@ -1190,8 +1537,8 @@ var DEFAULT_MAX_BODY_CHARS = 2500;
1190
1537
  var DEFAULT_MAX_CHUNK_CHARS = 900;
1191
1538
  var MAX_TITLE_CHARS = 200;
1192
1539
  var MAX_FILES_PER_NODE = 20;
1193
- var EXPLANATION_MARKERS = /\b(because|the reason|design decision|trade-?off|instead of|rationale|so that)\b|เพราะ|ทำไม|เหตุผล/i;
1194
- 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;
1195
1542
  function scoreConversationTurn(userText, replyText) {
1196
1543
  const text = `${userText}
1197
1544
  ${replyText}`;
@@ -1265,63 +1612,6 @@ function collectConversationTurns(turns, projectId, opts = {}) {
1265
1612
  return turns.filter((t) => t.assistantText.length > 0).flatMap((turn) => toMemoryNodes(turn, projectId, opts));
1266
1613
  }
1267
1614
 
1268
- // src/collectors/docs.ts
1269
- var DEFAULT_MAX_BODY_CHARS2 = 2e3;
1270
- var DEFAULT_MAX_CHUNK_CHARS2 = 1200;
1271
- var MAX_TITLE_CHARS2 = 200;
1272
- var EXPLANATION_MARKERS2 = /\b(because|the reason|design decision|trade-?off|instead of|rationale|why)\b/i;
1273
- function scoreDocSection(path, heading, text) {
1274
- let score = 0.45;
1275
- if (EXPLANATION_MARKERS2.test(text)) score += 0.25;
1276
- if (/(^|\/)readme\.md$/i.test(path)) score += 0.1;
1277
- if (heading === null) score -= 0.1;
1278
- if (text.length < 80) score -= 0.15;
1279
- return Number(Math.min(1, Math.max(0.05, score)).toFixed(3));
1280
- }
1281
- function slugify(heading, index) {
1282
- if (heading === null) return `_preamble-${index}`;
1283
- const slug = heading.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1284
- return slug || `_section-${index}`;
1285
- }
1286
- function sectionTitle(path, heading, index, count) {
1287
- if (heading) return truncate(`${path} \u2014 ${heading}`, MAX_TITLE_CHARS2);
1288
- if (count > 1) return truncate(`${path} (part ${index + 1}/${count})`, MAX_TITLE_CHARS2);
1289
- return truncate(path, MAX_TITLE_CHARS2);
1290
- }
1291
- function toMemoryNodes2(file, projectId, opts = {}) {
1292
- const maxBody = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS2;
1293
- const maxChunk = opts.maxChunkChars ?? DEFAULT_MAX_CHUNK_CHARS2;
1294
- const chunks = chunkAssistantText(file.content, maxChunk);
1295
- if (chunks.length === 0) return [];
1296
- const seenSlugs = /* @__PURE__ */ new Map();
1297
- return chunks.map((chunk, index) => {
1298
- const baseSlug = slugify(chunk.heading, index);
1299
- const occurrence = seenSlugs.get(baseSlug) ?? 0;
1300
- seenSlugs.set(baseSlug, occurrence + 1);
1301
- const naturalKey = occurrence === 0 ? `${file.path}#${baseSlug}` : `${file.path}#${baseSlug}:${occurrence}`;
1302
- return {
1303
- id: makeNodeId(projectId, "doc_section", naturalKey),
1304
- kind: "doc_section",
1305
- projectId,
1306
- ts: file.ts,
1307
- source: "docs",
1308
- title: sectionTitle(file.path, chunk.heading, index, chunks.length),
1309
- body: truncate(chunk.text, maxBody),
1310
- files: [{ path: file.path, insertions: null, deletions: null, binary: false }],
1311
- signal: scoreDocSection(file.path, chunk.heading, chunk.text),
1312
- meta: {
1313
- path: file.path,
1314
- heading: chunk.heading,
1315
- chunkIndex: index,
1316
- chunkCount: chunks.length
1317
- }
1318
- };
1319
- });
1320
- }
1321
- function collectDocFiles(files, projectId, opts = {}) {
1322
- return files.flatMap((file) => toMemoryNodes2(file, projectId, opts));
1323
- }
1324
-
1325
1615
  // src/git/parse.ts
1326
1616
  var RECORD_SEP = "";
1327
1617
  var UNIT_SEP = "";
@@ -1431,8 +1721,182 @@ function unquoteGitPath(p) {
1431
1721
  return Buffer.from(bytes).toString("utf8");
1432
1722
  }
1433
1723
 
1434
- // 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;
1435
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;
1436
1900
  function buildLogArgs(opts = {}) {
1437
1901
  const { rev = "HEAD", afterCommit, since, maxCount, includeMerges = true, paths } = opts;
1438
1902
  const args = ["log", `--format=${GIT_LOG_FORMAT}`, "--numstat", "--no-color"];
@@ -1457,7 +1921,7 @@ async function* readCommits(cwd, opts = {}) {
1457
1921
  }
1458
1922
  }
1459
1923
  } catch (err) {
1460
- if (err instanceof GitError && EMPTY_HISTORY.test(err.stderr)) return;
1924
+ if (err instanceof GitError && EMPTY_HISTORY2.test(err.stderr)) return;
1461
1925
  throw err;
1462
1926
  }
1463
1927
  for (const record of splitRecords(buffer, true).records) {
@@ -1468,7 +1932,7 @@ async function* readCommits(cwd, opts = {}) {
1468
1932
 
1469
1933
  // src/collectors/git-commits.ts
1470
1934
  var DEFAULTS = { maxFilesPerNode: 40, maxBodyChars: 4e3 };
1471
- var MAX_TITLE_CHARS3 = 200;
1935
+ var MAX_TITLE_CHARS2 = 200;
1472
1936
  var CONVENTIONAL = /^([a-z]+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/i;
1473
1937
  function parseConventionalHeader(subject) {
1474
1938
  const m = CONVENTIONAL.exec(subject.trim());
@@ -1537,7 +2001,7 @@ function toMemoryNode(commit, projectId, opts = {}) {
1537
2001
  projectId,
1538
2002
  ts: commit.authoredAt,
1539
2003
  source: "git",
1540
- title: truncate(commit.subject || `(no subject) ${commit.shortSha}`, MAX_TITLE_CHARS3),
2004
+ title: truncate(commit.subject || `(no subject) ${commit.shortSha}`, MAX_TITLE_CHARS2),
1541
2005
  body: truncate(bodyParts.join("\n"), maxBody),
1542
2006
  files: keptFiles,
1543
2007
  signal: scoreCommit(commit),
@@ -1564,9 +2028,156 @@ async function* collectGitCommits(cwd, projectId, opts = {}) {
1564
2028
  }
1565
2029
  }
1566
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
+
1567
2178
  // src/collectors/shell-history.ts
1568
2179
  var DEFAULT_MAX_BODY_CHARS3 = 1e3;
1569
- var MAX_TITLE_CHARS4 = 200;
2180
+ var MAX_TITLE_CHARS5 = 200;
1570
2181
  var NOISE = /^(cd|ls|dir|pwd|clear|cls|exit|history|whoami|date|type|cat|more|less|ll|la)\b/i;
1571
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;
1572
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;
@@ -1607,7 +2218,7 @@ function toMemoryNode2(entry, projectId, opts = {}) {
1607
2218
  projectId,
1608
2219
  ts: entry.ts,
1609
2220
  source: `shell:${entry.shell}`,
1610
- title: truncate(titleLine, MAX_TITLE_CHARS4),
2221
+ title: truncate(titleLine, MAX_TITLE_CHARS5),
1611
2222
  body: renderBody(entry, maxBody),
1612
2223
  files: [],
1613
2224
  signal: scoreShellCommand(entry),
@@ -1626,24 +2237,24 @@ function collectShellHistory(entries, projectId, opts = {}) {
1626
2237
  }
1627
2238
 
1628
2239
  // src/conversation/claude-code-reader.ts
1629
- import { readFile as readFile3 } from "fs/promises";
2240
+ import { readFile as readFile4 } from "fs/promises";
1630
2241
 
1631
2242
  // src/conversation/paths.ts
1632
- import { existsSync as existsSync2 } from "fs";
2243
+ import { existsSync as existsSync3 } from "fs";
1633
2244
  import { readdir } from "fs/promises";
1634
- import { homedir as homedir2 } from "os";
1635
- import { join as join3 } from "path";
2245
+ import { homedir as homedir3 } from "os";
2246
+ import { join as join5 } from "path";
1636
2247
  function claudeProjectSlug(repoRoot) {
1637
2248
  return repoRoot.replace(/[\\/:]/g, "-");
1638
2249
  }
1639
2250
  function claudeProjectTranscriptDir(repoRoot) {
1640
- return join3(homedir2(), ".claude", "projects", claudeProjectSlug(repoRoot));
2251
+ return join5(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
1641
2252
  }
1642
2253
  async function listTranscriptFiles(repoRoot) {
1643
2254
  const dir = claudeProjectTranscriptDir(repoRoot);
1644
- if (!existsSync2(dir)) return [];
2255
+ if (!existsSync3(dir)) return [];
1645
2256
  const entries = await readdir(dir, { withFileTypes: true });
1646
- 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));
1647
2258
  }
1648
2259
 
1649
2260
  // src/conversation/claude-code-reader.ts
@@ -1716,15 +2327,15 @@ async function collectClaudeCodeTranscripts(repoRoot) {
1716
2327
  const files = await listTranscriptFiles(repoRoot);
1717
2328
  const turns = [];
1718
2329
  for (const file of files) {
1719
- const raw = await readFile3(file, "utf8");
2330
+ const raw = await readFile4(file, "utf8");
1720
2331
  turns.push(...parseClaudeCodeTranscript(raw));
1721
2332
  }
1722
2333
  return turns;
1723
2334
  }
1724
2335
 
1725
2336
  // src/docs/read.ts
1726
- import { readFile as readFile4, stat } from "fs/promises";
1727
- import { join as join4 } from "path";
2337
+ import { readFile as readFile5, stat } from "fs/promises";
2338
+ import { join as join6 } from "path";
1728
2339
  var DEFAULT_PATHSPECS = ["*.md"];
1729
2340
  async function listDocFiles(repoRoot, opts = {}) {
1730
2341
  const pathspecs = opts.include ?? DEFAULT_PATHSPECS;
@@ -1737,11 +2348,11 @@ async function readDocFiles(repoRoot, opts = {}) {
1737
2348
  const unreadable = [];
1738
2349
  for (const relPath of paths) {
1739
2350
  const path = relPath.replace(/\\/g, "/");
1740
- const absPath = join4(repoRoot, relPath);
2351
+ const absPath = join6(repoRoot, relPath);
1741
2352
  let content;
1742
2353
  let mtime;
1743
2354
  try {
1744
- [content, { mtime }] = await Promise.all([readFile4(absPath, "utf8"), stat(absPath)]);
2355
+ [content, { mtime }] = await Promise.all([readFile5(absPath, "utf8"), stat(absPath)]);
1745
2356
  } catch {
1746
2357
  unreadable.push(path);
1747
2358
  continue;
@@ -1752,11 +2363,11 @@ async function readDocFiles(repoRoot, opts = {}) {
1752
2363
  }
1753
2364
 
1754
2365
  // src/shell/detect.ts
1755
- import { existsSync as existsSync3 } from "fs";
1756
- 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";
1757
2368
 
1758
2369
  // src/shell/hook-log.ts
1759
- import { appendFile, mkdir as mkdir3, readFile as readFile5 } from "fs/promises";
2370
+ import { appendFile, mkdir as mkdir4, readFile as readFile6 } from "fs/promises";
1760
2371
  import { dirname as dirname3 } from "path";
1761
2372
  function parseHookLogLine(line) {
1762
2373
  const trimmed = line.trim();
@@ -1781,7 +2392,7 @@ function parseHookLogLine(line) {
1781
2392
  async function readHookLog(path, fromLine) {
1782
2393
  let raw;
1783
2394
  try {
1784
- raw = await readFile5(path, "utf8");
2395
+ raw = await readFile6(path, "utf8");
1785
2396
  } catch {
1786
2397
  return { entries: [], totalLines: fromLine };
1787
2398
  }
@@ -1914,8 +2525,8 @@ function hookEntryToRaw(e) {
1914
2525
  };
1915
2526
  }
1916
2527
  async function tryReadScrapeSource(path, parse, tailLines) {
1917
- if (!existsSync3(path)) return null;
1918
- 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)]);
1919
2530
  return parse(raw, stats.mtimeMs, { tailLines });
1920
2531
  }
1921
2532
  async function collectAvailableShellHistory(opts = {}) {
@@ -1923,7 +2534,7 @@ async function collectAvailableShellHistory(opts = {}) {
1923
2534
  const tailLines = opts.tailLines ?? 300;
1924
2535
  const preferHook = opts.preferHook ?? true;
1925
2536
  const hookPath = hookLogPath();
1926
- const hookExists = existsSync3(hookPath);
2537
+ const hookExists = existsSync4(hookPath);
1927
2538
  if (hookExists) {
1928
2539
  const fromLine = Number(opts.hookCursor ?? "0") || 0;
1929
2540
  const { entries, totalLines } = await readHookLog(hookPath, fromLine);
@@ -1979,25 +2590,25 @@ function addStats(into, from) {
1979
2590
  async function syncGit(store, projectId, opts, repo, config, log) {
1980
2591
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
1981
2592
  if (!repo.head) {
1982
- log(`${pc3.yellow("git")} skipped -- repository has no commits yet`);
2593
+ log(`${pc4.yellow("git")} skipped -- repository has no commits yet`);
1983
2594
  return { totals, seen: 0 };
1984
2595
  }
1985
2596
  if (!config.sources.git.enabled) {
1986
- log(`${pc3.dim("git")} disabled in config`);
2597
+ log(`${pc4.dim("git")} disabled in config`);
1987
2598
  return { totals, seen: 0 };
1988
2599
  }
1989
2600
  let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, GIT_SOURCE);
1990
2601
  if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
1991
- 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`);
1992
2603
  cursor = null;
1993
2604
  }
1994
2605
  if (cursor === repo.head) {
1995
- 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)}`);
1996
2607
  store.setSyncCursor(projectId, GIT_SOURCE, repo.head);
1997
2608
  return { totals, seen: 0 };
1998
2609
  }
1999
2610
  log(
2000
- `${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)"}`
2001
2612
  );
2002
2613
  let batch = [];
2003
2614
  let seen = 0;
@@ -2005,7 +2616,7 @@ async function syncGit(store, projectId, opts, repo, config, log) {
2005
2616
  if (batch.length === 0) return;
2006
2617
  addStats(totals, store.upsertNodes(batch));
2007
2618
  batch = [];
2008
- log(` ${pc3.dim(`${seen} commits read, ${totals.inserted} new`)}`);
2619
+ log(` ${pc4.dim(`${seen} commits read, ${totals.inserted} new`)}`);
2009
2620
  };
2010
2621
  const nodes = collectGitCommits(repo.root, projectId, {
2011
2622
  afterCommit: cursor,
@@ -2023,10 +2634,51 @@ async function syncGit(store, projectId, opts, repo, config, log) {
2023
2634
  store.setSyncCursor(projectId, GIT_SOURCE, repo.head);
2024
2635
  return { totals, seen };
2025
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
+ }
2026
2678
  async function syncShell(store, projectId, opts, repoRoot, config, log) {
2027
2679
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
2028
2680
  if (!config.sources.shell.enabled) {
2029
- log(`${pc3.dim("shell")} disabled in config`);
2681
+ log(`${pc4.dim("shell")} disabled in config`);
2030
2682
  return { totals, seen: 0 };
2031
2683
  }
2032
2684
  const results = await collectAvailableShellHistory({
@@ -2035,7 +2687,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
2035
2687
  hookCursor: store.getSyncCursor(projectId, "shell:pwsh-hook")
2036
2688
  });
2037
2689
  if (results.length === 0) {
2038
- log(`${pc3.dim("shell")} no history source found on this machine`);
2690
+ log(`${pc4.dim("shell")} no history source found on this machine`);
2039
2691
  return { totals, seen: 0 };
2040
2692
  }
2041
2693
  let seen = 0;
@@ -2047,7 +2699,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
2047
2699
  addStats(totals, store.upsertNodes(nodes));
2048
2700
  }
2049
2701
  store.setSyncCursor(projectId, sourceKey, result.cursorAfter ?? `scanned:${result.entries.length}`);
2050
- 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`)}`);
2051
2703
  }
2052
2704
  return { totals, seen };
2053
2705
  }
@@ -2060,20 +2712,20 @@ async function syncConversation(store, projectId, repoRoot, config, log, forceEn
2060
2712
  }
2061
2713
  const turns = await collectClaudeCodeTranscripts(repoRoot);
2062
2714
  if (turns.length === 0) {
2063
- log(`${pc3.dim("conversation")} no transcripts found`);
2715
+ log(`${pc4.dim("conversation")} no transcripts found`);
2064
2716
  return { totals, seen: 0 };
2065
2717
  }
2066
2718
  const nodes = collectConversationTurns(turns, projectId, { maxBodyChars: config.limits.maxBodyChars });
2067
2719
  if (nodes.length > 0) addStats(totals, store.upsertNodes(nodes));
2068
2720
  store.setSyncCursor(projectId, CONVERSATION_SOURCE, `scanned:${nodes.length}`);
2069
- 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`)}`);
2070
2722
  return { totals, seen: nodes.length };
2071
2723
  }
2072
2724
  var DOCS_SOURCE = "docs";
2073
2725
  async function syncDocs(store, projectId, repoRoot, config, log) {
2074
2726
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
2075
2727
  if (!config.sources.docs.enabled) {
2076
- log(`${pc3.dim("docs")} disabled in config`);
2728
+ log(`${pc4.dim("docs")} disabled in config`);
2077
2729
  return { totals, seen: 0 };
2078
2730
  }
2079
2731
  const { files, unreadable } = await readDocFiles(repoRoot, { include: config.sources.docs.include });
@@ -2087,11 +2739,11 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
2087
2739
  );
2088
2740
  store.setSyncCursor(projectId, DOCS_SOURCE, `scanned:${nodes.length}`);
2089
2741
  if (files.length === 0 && unreadable.length === 0) {
2090
- log(`${pc3.dim("docs")} no tracked .md files found`);
2742
+ log(`${pc4.dim("docs")} no tracked .md files found`);
2091
2743
  } else {
2092
- const prunedPart = pruned > 0 ? `, ${pc3.yellow(`${pruned} stale removed`)}` : "";
2744
+ const prunedPart = pruned > 0 ? `, ${pc4.yellow(`${pruned} stale removed`)}` : "";
2093
2745
  const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (kept)` : "";
2094
- 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)}`);
2095
2747
  }
2096
2748
  return { totals, seen: nodes.length };
2097
2749
  }
@@ -2106,11 +2758,13 @@ async function runSync(opts) {
2106
2758
  const started = Date.now();
2107
2759
  try {
2108
2760
  store.upsertProject({ id: projectId, root: repo.root, originUrl: repo.originUrl });
2761
+ await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
2109
2762
  if (opts.rebuild) {
2110
2763
  const removed = store.clearProject(projectId);
2111
- log(`${pc3.dim("rebuild")} dropped ${removed} existing node(s)`);
2764
+ log(`${pc4.dim("rebuild")} dropped ${removed} existing node(s)`);
2112
2765
  }
2113
2766
  const git2 = await syncGit(store, projectId, opts, repo, config, log);
2767
+ const diffs = await syncDiffs(store, projectId, opts, repo, config, log);
2114
2768
  const shell = await syncShell(store, projectId, opts, repo.root, config, log);
2115
2769
  const conversation = await syncConversation(store, projectId, repo.root, config, log, opts.conversationOverride);
2116
2770
  const docs = await syncDocs(store, projectId, repo.root, config, log);
@@ -2118,15 +2772,16 @@ async function runSync(opts) {
2118
2772
  if (!opts.noEmbed) {
2119
2773
  const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId);
2120
2774
  if (result.embedded > 0) {
2121
- 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`) : ""}
2122
2776
  `;
2123
2777
  } else if (result.providerUnavailable) {
2124
- 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`);
2125
2779
  }
2126
2780
  }
2127
2781
  store.markSynced(projectId);
2128
2782
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
2129
2783
  addStats(totals, git2.totals);
2784
+ addStats(totals, diffs.totals);
2130
2785
  addStats(totals, shell.totals);
2131
2786
  addStats(totals, conversation.totals);
2132
2787
  addStats(totals, docs.totals);
@@ -2135,11 +2790,12 @@ async function runSync(opts) {
2135
2790
  const conversationEnabled = opts.conversationOverride ?? config.sources.conversation.enabled;
2136
2791
  const conversationPart = conversationEnabled ? `, ${conversation.seen} conversation exchange(s)` : "";
2137
2792
  const docsPart = config.sources.docs.enabled ? `, ${docs.seen} doc section(s)` : "";
2793
+ const diffPart = config.sources.diff.enabled ? `, ${diffs.seen} file diff(s)` : "";
2138
2794
  out(
2139
2795
  [
2140
- `${pc3.green("synced")} ${git2.seen} commit(s), ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${docsPart} in ${elapsed}s`,
2141
- ` ${pc3.green(`+${totals.inserted} new`)} ${pc3.yellow(`~${totals.updated} updated`)} ${pc3.dim(`=${totals.unchanged} unchanged`)}`,
2142
- ` ${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)`)}`,
2143
2799
  ""
2144
2800
  ].join("\n") + embedLine
2145
2801
  );
@@ -2156,20 +2812,39 @@ async function searchMemory(input) {
2156
2812
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
2157
2813
  const budget = input.budget ?? 2e3;
2158
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
+ }
2159
2837
  const store = MemoryStore.open(ws.dbPath);
2160
2838
  try {
2161
- const { bm25Count, vectorCount, hits, packed } = await runHybridQuery(store, projectId, input.query, {
2162
- budget,
2163
- candidates,
2164
- embeddingProvider: input.noVector ? null : new OllamaEmbeddingProvider()
2165
- });
2839
+ const { bm25Count, vectorCount, hits, packed } = await runHybridQuery(store, projectId, input.query, queryOpts);
2166
2840
  return {
2167
2841
  text: renderContextBlock(input.query, packed),
2168
2842
  matched: hits.length,
2169
2843
  bm25Matched: bm25Count,
2170
2844
  vectorMatched: vectorCount,
2171
2845
  tokensUsed: packed.tokensUsed,
2172
- tokensBudget: packed.tokensBudget
2846
+ tokensBudget: packed.tokensBudget,
2847
+ projectsSearched: [basename2(repo.root) || repo.root]
2173
2848
  };
2174
2849
  } finally {
2175
2850
  store.close();
@@ -2207,20 +2882,23 @@ async function getStatus(input) {
2207
2882
 
2208
2883
  // src/mcp/server.ts
2209
2884
  function createServer() {
2210
- const server = new McpServer({ name: "nexusmem", version: "0.1.0" });
2885
+ const server = new McpServer({ name: "nexusmem", version: readOwnVersion() });
2211
2886
  server.registerTool(
2212
2887
  "search_memory",
2213
2888
  {
2214
2889
  title: "Search remembered project history",
2215
- 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.",
2216
2891
  inputSchema: {
2217
- projectRoot: z2.string().describe("Absolute path to the repository root"),
2218
- query: z2.string().describe("Free-text question or search terms"),
2219
- 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
+ )
2220
2898
  }
2221
2899
  },
2222
- async ({ projectRoot, query, budget }) => {
2223
- const result = await searchMemory({ projectRoot, query, budget });
2900
+ async ({ projectRoot, query, budget, allProjects }) => {
2901
+ const result = await searchMemory({ projectRoot, query, budget, allProjects });
2224
2902
  return {
2225
2903
  content: [{ type: "text", text: result.text }],
2226
2904
  structuredContent: {
@@ -2229,7 +2907,8 @@ function createServer() {
2229
2907
  bm25Matched: result.bm25Matched,
2230
2908
  vectorMatched: result.vectorMatched,
2231
2909
  tokensUsed: result.tokensUsed,
2232
- tokensBudget: result.tokensBudget
2910
+ tokensBudget: result.tokensBudget,
2911
+ projectsSearched: result.projectsSearched
2233
2912
  }
2234
2913
  };
2235
2914
  }
@@ -2238,9 +2917,9 @@ function createServer() {
2238
2917
  "sync_project",
2239
2918
  {
2240
2919
  title: "Sync remembered history",
2241
- 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.",
2242
2921
  inputSchema: {
2243
- projectRoot: z2.string().describe("Absolute path to the repository root")
2922
+ projectRoot: z3.string().describe("Absolute path to the repository root")
2244
2923
  }
2245
2924
  },
2246
2925
  async ({ projectRoot }) => {
@@ -2254,7 +2933,7 @@ function createServer() {
2254
2933
  title: "Show what is remembered",
2255
2934
  description: "Report how many nodes NexusMem currently remembers for a repository, broken down by kind and source.",
2256
2935
  inputSchema: {
2257
- projectRoot: z2.string().describe("Absolute path to the repository root")
2936
+ projectRoot: z3.string().describe("Absolute path to the repository root")
2258
2937
  }
2259
2938
  },
2260
2939
  async ({ projectRoot }) => {
@@ -2274,17 +2953,41 @@ async function runMcpServer() {
2274
2953
  }
2275
2954
 
2276
2955
  // src/cli/commands/query.ts
2277
- import pc4 from "picocolors";
2956
+ import pc5 from "picocolors";
2278
2957
  async function runQuery(opts) {
2279
- const { ws, projectId } = await loadContext(opts.cwd);
2280
- 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;
2281
2961
  try {
2282
- const { bm25Count, vectorCount, hits, packed } = await runHybridQuery(store, projectId, opts.query, {
2962
+ const queryOpts = {
2283
2963
  budget: opts.budget,
2284
2964
  candidates: opts.candidates,
2285
2965
  halfLifeDays: opts.halfLifeDays,
2286
2966
  embeddingProvider: opts.noVector ? null : new OllamaEmbeddingProvider()
2287
- });
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
+ }
2288
2991
  const matched = hits.length;
2289
2992
  const rawTokens = hits.reduce((n, h) => n + approxTokens(h.body), 0);
2290
2993
  const packerEfficiency = rawTokens > 0 ? 1 - packed.tokensUsed / rawTokens : 0;
@@ -2309,15 +3012,15 @@ async function runQuery(opts) {
2309
3012
  return 0;
2310
3013
  }
2311
3014
  if (matched === 0) {
2312
- process.stderr.write(`${pc4.yellow("no matches")} for "${opts.query}"
3015
+ process.stderr.write(`${pc5.yellow("no matches")} for "${opts.query}"
2313
3016
  `);
2314
3017
  return 0;
2315
3018
  }
2316
3019
  process.stderr.write(
2317
3020
  [
2318
- `${pc4.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc4.bold(String(packed.nodes.length))} into budget`,
2319
- `${pc4.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc4.dim(` (${packed.droppedForBudget} dropped for budget)`) : ""),
2320
- 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)`)}` : "",
2321
3024
  ""
2322
3025
  ].filter(Boolean).join("\n")
2323
3026
  );
@@ -2325,28 +3028,30 @@ async function runQuery(opts) {
2325
3028
  `);
2326
3029
  return 0;
2327
3030
  } finally {
2328
- store.close();
3031
+ opened?.close();
3032
+ store?.close();
2329
3033
  }
2330
3034
  }
2331
3035
 
2332
3036
  // src/cli/commands/scan-conversation.ts
2333
- import pc6 from "picocolors";
3037
+ import pc7 from "picocolors";
2334
3038
 
2335
3039
  // src/cli/format.ts
2336
- import pc5 from "picocolors";
3040
+ import pc6 from "picocolors";
2337
3041
  var GIT_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
2338
3042
  var SHELL_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
2339
3043
  var CONVERSATION_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
2340
3044
  var DOCS_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
3045
+ var DIFF_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
2341
3046
  function signalBand(signal, bands) {
2342
3047
  if (signal >= bands.high) return "high";
2343
3048
  if (signal >= bands.medium) return "medium";
2344
3049
  return "low";
2345
3050
  }
2346
3051
  var BAND_COLOR = {
2347
- high: pc5.green,
2348
- medium: pc5.yellow,
2349
- low: pc5.dim
3052
+ high: pc6.green,
3053
+ medium: pc6.yellow,
3054
+ low: pc6.dim
2350
3055
  };
2351
3056
  function formatSignal(signal, bands) {
2352
3057
  return BAND_COLOR[signalBand(signal, bands)](signal.toFixed(2));
@@ -2359,9 +3064,9 @@ async function runScanConversation(opts) {
2359
3064
  const files = await listTranscriptFiles(repo.root);
2360
3065
  if (!opts.json) {
2361
3066
  process.stderr.write(
2362
- 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)}
2363
3068
 
2364
- ` : `${pc6.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
3069
+ ` : `${pc7.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
2365
3070
  `
2366
3071
  );
2367
3072
  }
@@ -2378,7 +3083,7 @@ async function runScanConversation(opts) {
2378
3083
  const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
2379
3084
  process.stderr.write(
2380
3085
  `
2381
- ${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"
2382
3087
  );
2383
3088
  return 0;
2384
3089
  }
@@ -2386,44 +3091,8 @@ function formatNode(node) {
2386
3091
  return [formatSignal(node.signal, CONVERSATION_SIGNAL_BANDS), node.ts.slice(0, 16).replace("T", " "), node.title].join(" ");
2387
3092
  }
2388
3093
 
2389
- // src/cli/commands/scan-docs.ts
2390
- import pc7 from "picocolors";
2391
- async function runScanDocs(opts) {
2392
- const repo = await readRepoInfo(opts.cwd);
2393
- const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
2394
- const { files, unreadable } = await readDocFiles(repo.root);
2395
- if (!opts.json) {
2396
- process.stderr.write(
2397
- files.length ? `${pc7.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
2398
-
2399
- ` : `${pc7.yellow("no tracked .md files found")}
2400
- `
2401
- );
2402
- if (unreadable.length > 0) {
2403
- process.stderr.write(`${pc7.yellow("unreadable")} ${unreadable.join(", ")}
2404
-
2405
- `);
2406
- }
2407
- }
2408
- const nodes = collectDocFiles(files, projectId).filter((n) => n.signal >= opts.minSignal);
2409
- if (opts.json) {
2410
- process.stdout.write(`${JSON.stringify(nodes, null, 2)}
2411
- `);
2412
- return 0;
2413
- }
2414
- for (const node of nodes) process.stdout.write(`${formatNode2(node)}
2415
- `);
2416
- const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
2417
- process.stderr.write(
2418
- `
2419
- ${pc7.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc7.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
2420
- `
2421
- );
2422
- return 0;
2423
- }
2424
- function formatNode2(node) {
2425
- return [formatSignal(node.signal, DOCS_SIGNAL_BANDS), node.title].join(" ");
2426
- }
3094
+ // src/cli/commands/scan-diff.ts
3095
+ import pc9 from "picocolors";
2427
3096
 
2428
3097
  // src/cli/commands/scan-git.ts
2429
3098
  import pc8 from "picocolors";
@@ -2450,7 +3119,7 @@ async function runScanGit(opts) {
2450
3119
  for await (const node of collectGitCommits(repo.root, projectId, collectOpts)) {
2451
3120
  if (node.signal < opts.minSignal) continue;
2452
3121
  nodes.push(node);
2453
- if (!opts.json) process.stdout.write(`${formatNode3(node)}
3122
+ if (!opts.json) process.stdout.write(`${formatNode2(node)}
2454
3123
  `);
2455
3124
  }
2456
3125
  if (opts.json) {
@@ -2463,7 +3132,7 @@ ${summarize2(nodes)}
2463
3132
  `);
2464
3133
  return 0;
2465
3134
  }
2466
- function formatNode3(node) {
3135
+ function formatNode2(node) {
2467
3136
  const sha = String(node.meta.shortSha ?? "").padEnd(9);
2468
3137
  const date = node.ts.slice(0, 10);
2469
3138
  const files = Number(node.meta.filesChanged ?? 0);
@@ -2494,17 +3163,103 @@ ${hottest.join("\n")}` : ""
2494
3163
  ].filter(Boolean).join("\n");
2495
3164
  }
2496
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
+
2497
3252
  // src/cli/commands/scan-shell.ts
2498
- import pc9 from "picocolors";
3253
+ import pc11 from "picocolors";
2499
3254
  async function runScanShell(opts) {
2500
3255
  const repo = await readRepoInfo(opts.cwd);
2501
3256
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
2502
3257
  const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
2503
3258
  if (!opts.json) {
2504
3259
  process.stderr.write(
2505
- 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(", ")}
2506
3261
 
2507
- ` : `${pc9.yellow("no shell history source found on this machine")}
3262
+ ` : `${pc11.yellow("no shell history source found on this machine")}
2508
3263
  `
2509
3264
  );
2510
3265
  }
@@ -2513,9 +3268,9 @@ async function runScanShell(opts) {
2513
3268
  const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
2514
3269
  allNodes.push(...nodes);
2515
3270
  if (!opts.json) {
2516
- 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)`)}
2517
3272
  `);
2518
- for (const node of nodes) process.stdout.write(`${formatNode4(node)}
3273
+ for (const node of nodes) process.stdout.write(`${formatNode5(node)}
2519
3274
  `);
2520
3275
  process.stdout.write("\n");
2521
3276
  }
@@ -2526,20 +3281,20 @@ async function runScanShell(opts) {
2526
3281
  return 0;
2527
3282
  }
2528
3283
  const approxTotal = allNodes.reduce((n, x) => n + approxTokens(x.body), 0);
2529
- 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`)}
2530
3285
  `);
2531
3286
  return 0;
2532
3287
  }
2533
- function formatNode4(node) {
2534
- const approx = node.meta.tsApprox ? pc9.dim("~") : " ";
3288
+ function formatNode5(node) {
3289
+ const approx = node.meta.tsApprox ? pc11.dim("~") : " ";
2535
3290
  const exit = node.meta.exitCode;
2536
- const exitLabel = typeof exit === "number" && exit !== 0 ? pc9.red(`exit ${exit}`) : "";
3291
+ const exitLabel = typeof exit === "number" && exit !== 0 ? pc11.red(`exit ${exit}`) : "";
2537
3292
  return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
2538
3293
  }
2539
3294
 
2540
3295
  // src/cli/commands/status.ts
2541
3296
  import { statSync } from "fs";
2542
- import pc10 from "picocolors";
3297
+ import pc12 from "picocolors";
2543
3298
  function humanBytes(bytes) {
2544
3299
  if (bytes < 1024) return `${bytes} B`;
2545
3300
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -2564,23 +3319,23 @@ async function runStatus(opts) {
2564
3319
  const kinds = Object.entries(stats.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
2565
3320
  process.stdout.write(
2566
3321
  [
2567
- `${pc10.dim("repo ")} ${repo.root}`,
2568
- `${pc10.dim("branch ")} ${repo.branch ?? pc10.yellow("(detached)")}`,
2569
- `${pc10.dim("project ")} ${pc10.cyan(projectId)}`,
2570
- `${pc10.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc10.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
2571
- `${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)})`)}`,
2572
3327
  "",
2573
- `${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)}`)}` : ""}`,
2574
3329
  ...kinds,
2575
- stats.total ? ` ${pc10.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
3330
+ stats.total ? ` ${pc12.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
2576
3331
  "",
2577
- sources.length ? pc10.dim("sources") : pc10.yellow("no sources synced yet"),
3332
+ sources.length ? pc12.dim("sources") : pc12.yellow("no sources synced yet"),
2578
3333
  ...sources.map((s) => {
2579
3334
  const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
2580
3335
  const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
2581
- 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}`)}`;
2582
3337
  }),
2583
- 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")}` : "",
2584
3339
  ""
2585
3340
  ].filter((line) => line !== "").join("\n").concat("\n")
2586
3341
  );
@@ -2604,7 +3359,7 @@ function guard(run) {
2604
3359
  process.exitCode = await run();
2605
3360
  } catch (err) {
2606
3361
  if (isExpected(err)) {
2607
- process.stderr.write(`${pc11.red("error")} ${err.message}
3362
+ process.stderr.write(`${pc13.red("error")} ${err.message}
2608
3363
  `);
2609
3364
  process.exitCode = 1;
2610
3365
  return;
@@ -2614,7 +3369,7 @@ function guard(run) {
2614
3369
  };
2615
3370
  }
2616
3371
  var program = new Command();
2617
- program.name("nexusmem").description("NexusMem \u2014 local-first persistent memory for AI coding agents").version("0.1.0");
3372
+ program.name("nexusmem").description("NexusMem \u2014 local-first persistent memory for AI coding agents").version(readOwnVersion());
2618
3373
  program.command("init").description("Create the .nexusmem workspace and database for this repository").option("-C, --cwd <path>", "repository path", process.cwd()).option("--force", "overwrite an existing config (the database is kept)", false).option("--hook", "also install the opt-in PowerShell hook (cwd + exit code + timestamp)", false).option("--enable-conversation", "opt in to the conversation-transcript source (off by default -- see docs/phase-2-spec.md)", false).action(
2619
3374
  (options) => guard(
2620
3375
  () => runInit({ cwd: options.cwd, force: options.force, hook: options.hook, enableConversation: options.enableConversation })
@@ -2642,7 +3397,7 @@ program.command("hook").description("Manage the opt-in PowerShell hook that logs
2642
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 }))())
2643
3398
  );
2644
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 }))());
2645
- 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(
2646
3401
  (text, options) => guard(
2647
3402
  () => runQuery({
2648
3403
  cwd: options.cwd,
@@ -2651,10 +3406,12 @@ program.command("query").description("Search remembered history and print a toke
2651
3406
  candidates: options.candidates,
2652
3407
  halfLifeDays: options.halfLife,
2653
3408
  noVector: !options.vector,
3409
+ allProjects: options.allProjects,
2654
3410
  json: options.json
2655
3411
  })
2656
3412
  )()
2657
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 }))());
2658
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(
2659
3416
  (options) => guard(
2660
3417
  () => runScanGit({
@@ -2667,6 +3424,17 @@ program.command("scan-git").description("Preview the MemoryNodes git history wou
2667
3424
  })
2668
3425
  )()
2669
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
+ );
2670
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(
2671
3439
  (options) => guard(
2672
3440
  () => runScanShell({ cwd: options.cwd, tailLines: options.tailLines, minSignal: options.minSignal, json: options.json })
@@ -2679,7 +3447,7 @@ program.command("scan-docs").description("Preview the MemoryNodes tracked .md fi
2679
3447
  program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
2680
3448
  program.parseAsync(process.argv).catch((err) => {
2681
3449
  const message = err instanceof Error ? err.message : String(err);
2682
- process.stderr.write(`${pc11.red("error")} ${message}
3450
+ process.stderr.write(`${pc13.red("error")} ${message}
2683
3451
  `);
2684
3452
  process.exitCode = 1;
2685
3453
  });