nexusmem 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli/index.ts
4
4
  import { Command } from "commander";
5
- import pc18 from "picocolors";
5
+ import pc19 from "picocolors";
6
6
 
7
7
  // src/config/workspace.ts
8
8
  import { existsSync } from "fs";
@@ -764,31 +764,6 @@ import { mkdirSync } from "fs";
764
764
  import { dirname as dirname3 } from "path";
765
765
  import * as sqliteVec from "sqlite-vec";
766
766
 
767
- // src/core/ids.ts
768
- import { createHash } from "crypto";
769
- var KEY_SEP = "\0";
770
- function sha256Hex(input) {
771
- return createHash("sha256").update(input, "utf8").digest("hex");
772
- }
773
- function makeNodeId(projectId, kind, naturalKey) {
774
- return sha256Hex([projectId, kind, naturalKey].join(KEY_SEP)).slice(0, 24);
775
- }
776
-
777
- // src/store/fts.ts
778
- var FTS_SYNTAX = /["'()*:^{}[\]-]/g;
779
- var LOW_SIGNAL_TOKENS = /* @__PURE__ */ new Set(["id"]);
780
- function significantTokens(input) {
781
- const tokens = input.replace(FTS_SYNTAX, " ").split(/\s+/).map((t) => t.trim()).filter((t) => t.length > 0);
782
- if (tokens.length === 0) return [];
783
- const signal = tokens.filter((t) => !LOW_SIGNAL_TOKENS.has(t.toLowerCase()));
784
- return signal.length > 0 ? signal : tokens;
785
- }
786
- function toMatchQuery(input) {
787
- const kept = significantTokens(input);
788
- if (kept.length === 0) return null;
789
- return kept.map((t) => `"${t}"*`).join(" OR ");
790
- }
791
-
792
767
  // src/store/schema.ts
793
768
  var V1 = `
794
769
  CREATE TABLE meta (
@@ -961,12 +936,21 @@ CREATE TABLE tombstones (
961
936
  );
962
937
  CREATE INDEX idx_tombstones_project ON tombstones (project_id, removed_at DESC);
963
938
  `;
939
+ var V6 = `
940
+ ALTER TABLE nodes ADD COLUMN provenance TEXT NOT NULL DEFAULT 'inferred';
941
+ ALTER TABLE nodes ADD COLUMN supersedes TEXT;
942
+
943
+ UPDATE nodes SET provenance = 'observed' WHERE kind IN ('git_commit', 'code_diff', 'shell_command');
944
+
945
+ CREATE INDEX idx_nodes_supersedes ON nodes (supersedes) WHERE supersedes IS NOT NULL;
946
+ `;
964
947
  var MIGRATIONS = [
965
948
  { version: 1, up: (db) => db.exec(V1) },
966
949
  { version: 2, up: (db) => db.exec(V2) },
967
950
  { version: 3, up: (db) => db.exec(V3) },
968
951
  { version: 4, up: (db) => db.exec(V4) },
969
- { version: 5, up: (db) => db.exec(V5) }
952
+ { version: 5, up: (db) => db.exec(V5) },
953
+ { version: 6, up: (db) => db.exec(V6) }
970
954
  ];
971
955
  var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
972
956
  function currentSchemaVersion(db) {
@@ -984,7 +968,227 @@ function migrate(db) {
984
968
  return { from, to: currentSchemaVersion(db) };
985
969
  }
986
970
 
987
- // src/store/store.ts
971
+ // src/store/projects.ts
972
+ function upsertProject(db, project) {
973
+ db.prepare(
974
+ `INSERT INTO projects (id, root, origin_url, created_at)
975
+ VALUES (@id, @root, @originUrl, @now)
976
+ ON CONFLICT(id) DO UPDATE SET root = excluded.root, origin_url = excluded.origin_url`
977
+ ).run({ ...project, now: Date.now() });
978
+ }
979
+ function markSynced(db, projectId) {
980
+ db.prepare("UPDATE projects SET last_synced_at = ? WHERE id = ?").run(Date.now(), projectId);
981
+ }
982
+ function listOtherProjectIds(db, currentProjectId) {
983
+ return db.prepare("SELECT id FROM projects WHERE id != ?").all(currentProjectId).map(
984
+ (r) => r.id
985
+ );
986
+ }
987
+ function countProjectNodes(db, projectIds) {
988
+ if (projectIds.length === 0) return 0;
989
+ const placeholders = projectIds.map(() => "?").join(", ");
990
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE project_id IN (${placeholders})`).get(...projectIds);
991
+ return row.n;
992
+ }
993
+ function getSyncCursor(db, projectId, source) {
994
+ const row = db.prepare("SELECT cursor FROM sync_state WHERE project_id = ? AND source = ?").get(projectId, source);
995
+ return row?.cursor ?? null;
996
+ }
997
+ function setSyncCursor(db, projectId, source, cursor) {
998
+ db.prepare(
999
+ `INSERT INTO sync_state (project_id, source, cursor, last_run_at)
1000
+ VALUES (?, ?, ?, ?)
1001
+ ON CONFLICT(project_id, source) DO UPDATE SET cursor = excluded.cursor, last_run_at = excluded.last_run_at`
1002
+ ).run(projectId, source, cursor, Date.now());
1003
+ }
1004
+ function listSyncState(db, projectId) {
1005
+ return db.prepare("SELECT source, cursor, last_run_at AS lastRunAt FROM sync_state WHERE project_id = ? ORDER BY last_run_at DESC").all(projectId);
1006
+ }
1007
+
1008
+ // src/core/types.ts
1009
+ function defaultProvenanceForKind(kind) {
1010
+ switch (kind) {
1011
+ case "git_commit":
1012
+ case "code_diff":
1013
+ case "shell_command":
1014
+ return "observed";
1015
+ case "conversation_turn":
1016
+ case "session_summary":
1017
+ case "doc_section":
1018
+ case "note":
1019
+ return "inferred";
1020
+ }
1021
+ }
1022
+
1023
+ // src/store/nodes.ts
1024
+ function epochOf(ts) {
1025
+ const parsed = Date.parse(ts);
1026
+ return Number.isNaN(parsed) ? Date.now() : parsed;
1027
+ }
1028
+ function upsertNodes(db, nodes) {
1029
+ const exists = db.prepare("SELECT body, signal, title FROM nodes WHERE id = ?");
1030
+ const dropStaleEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
1031
+ const insertNode = db.prepare(
1032
+ `INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, provenance, supersedes, created_at)
1033
+ VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @provenance, @supersedes, @now)
1034
+ ON CONFLICT(id) DO UPDATE SET
1035
+ ts = excluded.ts, ts_epoch = excluded.ts_epoch, source = excluded.source,
1036
+ title = excluded.title, body = excluded.body, signal = excluded.signal, meta = excluded.meta,
1037
+ provenance = excluded.provenance`
1038
+ );
1039
+ const clearFiles = db.prepare("DELETE FROM node_files WHERE node_id = ?");
1040
+ const insertFile = db.prepare(
1041
+ `INSERT INTO node_files (node_id, path, previous_path, insertions, deletions, is_binary)
1042
+ VALUES (@nodeId, @path, @previousPath, @insertions, @deletions, @isBinary)
1043
+ ON CONFLICT(node_id, path) DO UPDATE SET
1044
+ previous_path = excluded.previous_path, insertions = excluded.insertions,
1045
+ deletions = excluded.deletions, is_binary = excluded.is_binary`
1046
+ );
1047
+ const stats2 = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
1048
+ const denyEntriesByProject = /* @__PURE__ */ new Map();
1049
+ const run = db.transaction((batch) => {
1050
+ const now = Date.now();
1051
+ for (const node of batch) {
1052
+ let denyEntries = denyEntriesByProject.get(node.projectId);
1053
+ if (!denyEntries) {
1054
+ denyEntries = listDenyListEntries(db, node.projectId);
1055
+ denyEntriesByProject.set(node.projectId, denyEntries);
1056
+ }
1057
+ if (firstMatchingEntry(denyEntries, node)) {
1058
+ stats2.denied += 1;
1059
+ continue;
1060
+ }
1061
+ const prior = exists.get(node.id);
1062
+ if (prior) {
1063
+ if (prior.body === node.body && prior.signal === node.signal && prior.title === node.title) {
1064
+ stats2.unchanged += 1;
1065
+ continue;
1066
+ }
1067
+ stats2.updated += 1;
1068
+ dropStaleEmbedding.run(node.id);
1069
+ } else {
1070
+ stats2.inserted += 1;
1071
+ }
1072
+ insertNode.run({
1073
+ id: node.id,
1074
+ kind: node.kind,
1075
+ projectId: node.projectId,
1076
+ ts: node.ts,
1077
+ tsEpoch: epochOf(node.ts),
1078
+ source: node.source,
1079
+ title: node.title,
1080
+ body: node.body,
1081
+ signal: node.signal,
1082
+ meta: JSON.stringify(node.meta),
1083
+ provenance: node.provenance ?? defaultProvenanceForKind(node.kind),
1084
+ supersedes: node.supersedes ?? null,
1085
+ now
1086
+ });
1087
+ clearFiles.run(node.id);
1088
+ for (const file of node.files) {
1089
+ insertFile.run({
1090
+ nodeId: node.id,
1091
+ path: file.path,
1092
+ previousPath: file.previousPath ?? null,
1093
+ insertions: file.insertions,
1094
+ deletions: file.deletions,
1095
+ isBinary: file.binary ? 1 : 0
1096
+ });
1097
+ }
1098
+ }
1099
+ });
1100
+ run(nodes);
1101
+ return stats2;
1102
+ }
1103
+ function getNodeMeta(db, id) {
1104
+ const row = db.prepare("SELECT meta FROM nodes WHERE id = ?").get(id);
1105
+ if (!row) return null;
1106
+ try {
1107
+ return JSON.parse(row.meta);
1108
+ } catch {
1109
+ return null;
1110
+ }
1111
+ }
1112
+ function clearProject(db, projectId) {
1113
+ db.prepare("DELETE FROM nodes_vec WHERE rowid IN (SELECT rowid FROM nodes WHERE project_id = ?)").run(projectId);
1114
+ const info = db.prepare("DELETE FROM nodes WHERE project_id = ?").run(projectId);
1115
+ db.prepare("DELETE FROM sync_state WHERE project_id = ?").run(projectId);
1116
+ return info.changes;
1117
+ }
1118
+ function getNodesByIds(db, ids) {
1119
+ if (ids.length === 0) return [];
1120
+ return db.prepare(
1121
+ `SELECT id, kind, project_id AS projectId, ts, title, body, signal, provenance
1122
+ FROM nodes WHERE id IN (SELECT value FROM json_each(?))`
1123
+ ).all(JSON.stringify(ids));
1124
+ }
1125
+ function listRecentNodes(db, projectId, limit = 20) {
1126
+ return db.prepare(
1127
+ `SELECT id, kind, ts, source, title, signal, provenance
1128
+ FROM nodes
1129
+ WHERE project_id = ?
1130
+ ORDER BY ts_epoch DESC
1131
+ LIMIT ?`
1132
+ ).all(projectId, limit);
1133
+ }
1134
+ function countSourceNodes(db, projectId, source) {
1135
+ const row = db.prepare("SELECT COUNT(*) AS count FROM nodes WHERE project_id = ? AND source = ?").get(
1136
+ projectId,
1137
+ source
1138
+ );
1139
+ return row.count;
1140
+ }
1141
+ function pruneSourceNodes(db, projectId, source, keepIds, opts = {}) {
1142
+ const scope = `project_id = @projectId AND source = @source
1143
+ AND id NOT IN (SELECT value FROM json_each(@keepIds))
1144
+ AND id NOT IN (SELECT node_id FROM node_files WHERE path IN (SELECT value FROM json_each(@keepPaths)))`;
1145
+ const params = {
1146
+ projectId,
1147
+ source,
1148
+ keepIds: JSON.stringify(keepIds),
1149
+ keepPaths: JSON.stringify(opts.keepPaths ?? [])
1150
+ };
1151
+ return db.transaction(() => {
1152
+ db.prepare(`DELETE FROM nodes_vec WHERE rowid IN (SELECT rowid FROM nodes WHERE ${scope})`).run(params);
1153
+ return db.prepare(`DELETE FROM nodes WHERE ${scope}`).run(params).changes;
1154
+ })();
1155
+ }
1156
+ function getNodeProjectId(db, id) {
1157
+ const row = db.prepare("SELECT project_id FROM nodes WHERE id = ?").get(id);
1158
+ return row?.project_id ?? null;
1159
+ }
1160
+ function getSupersededIds(db, projectId) {
1161
+ const rows = db.prepare("SELECT DISTINCT supersedes AS id FROM nodes WHERE project_id = ? AND supersedes IS NOT NULL").all(projectId);
1162
+ return new Set(rows.map((r) => r.id));
1163
+ }
1164
+ function setSupersedes(db, newNodeId, staleNodeId) {
1165
+ db.prepare("UPDATE nodes SET supersedes = ? WHERE id = ?").run(staleNodeId, newNodeId);
1166
+ }
1167
+
1168
+ // src/store/links.ts
1169
+ function linkNodes(db, fromNodeId, toNodeId, relation) {
1170
+ db.prepare("INSERT OR IGNORE INTO node_links (from_node_id, to_node_id, relation, created_at) VALUES (?, ?, ?, ?)").run(
1171
+ fromNodeId,
1172
+ toNodeId,
1173
+ relation,
1174
+ Date.now()
1175
+ );
1176
+ }
1177
+ function getLinkedNodeIds(db, fromNodeId, relation) {
1178
+ return db.prepare("SELECT to_node_id FROM node_links WHERE from_node_id = ? AND relation = ? ORDER BY created_at DESC").all(fromNodeId, relation).map((row) => row.to_node_id);
1179
+ }
1180
+
1181
+ // src/core/ids.ts
1182
+ import { createHash } from "crypto";
1183
+ var KEY_SEP = "\0";
1184
+ function sha256Hex(input) {
1185
+ return createHash("sha256").update(input, "utf8").digest("hex");
1186
+ }
1187
+ function makeNodeId(projectId, kind, naturalKey) {
1188
+ return sha256Hex([projectId, kind, naturalKey].join(KEY_SEP)).slice(0, 24);
1189
+ }
1190
+
1191
+ // src/store/forget.ts
988
1192
  function parseMeta(raw) {
989
1193
  try {
990
1194
  return JSON.parse(raw);
@@ -992,10 +1196,228 @@ function parseMeta(raw) {
992
1196
  return {};
993
1197
  }
994
1198
  }
995
- function epochOf(ts) {
996
- const parsed = Date.parse(ts);
997
- return Number.isNaN(parsed) ? Date.now() : parsed;
1199
+ function previewForget(db, projectId, otherProjectIds, input) {
1200
+ validatePattern(input);
1201
+ const probeEntry = { id: -1, projectId, createdAt: 0, ...input };
1202
+ const select = db.prepare("SELECT project_id, source, title, body, meta FROM nodes WHERE project_id = ?");
1203
+ const counts = /* @__PURE__ */ new Map();
1204
+ for (const scopeId of [projectId, ...otherProjectIds]) {
1205
+ const rows = select.all(scopeId);
1206
+ for (const row of rows) {
1207
+ if (!firstMatchingEntry([probeEntry], { title: row.title, body: row.body, meta: parseMeta(row.meta) })) continue;
1208
+ const key = `${row.project_id} ${row.source}`;
1209
+ const existing = counts.get(key);
1210
+ if (existing) existing.count += 1;
1211
+ else counts.set(key, { projectId: row.project_id, source: row.source, count: 1 });
1212
+ }
1213
+ }
1214
+ return [...counts.values()];
1215
+ }
1216
+ function forget(db, projectId, otherProjectIds, input) {
1217
+ return db.transaction(() => {
1218
+ const entry = insertDenyListEntry(db, { ...input, projectId });
1219
+ const startedAt = Date.now();
1220
+ const auditId = Number(
1221
+ db.prepare(
1222
+ `INSERT INTO mutation_audit (action, project_id, detail, affected_count, succeeded, error, started_at, finished_at)
1223
+ VALUES ('forget', @projectId, @detail, 0, 1, NULL, @startedAt, @startedAt)`
1224
+ ).run({
1225
+ projectId,
1226
+ detail: JSON.stringify({
1227
+ pattern: input.pattern,
1228
+ matchType: input.matchType,
1229
+ ignoreCase: input.ignoreCase,
1230
+ reason: input.reason,
1231
+ scopeProjectIds: [projectId, ...otherProjectIds]
1232
+ }),
1233
+ startedAt
1234
+ }).lastInsertRowid
1235
+ );
1236
+ const select = db.prepare(
1237
+ "SELECT id, kind, project_id, source, ts, signal, title, body, meta FROM nodes WHERE project_id = ?"
1238
+ );
1239
+ const dropEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
1240
+ const deleteNode = db.prepare("DELETE FROM nodes WHERE id = ?");
1241
+ const insertTombstone = db.prepare(
1242
+ `INSERT INTO tombstones
1243
+ (node_id, project_id, kind, source, ts, signal, body_sha256, title_sha256, body_length, deny_list_id, mutation_audit_id, removed_at)
1244
+ VALUES (@nodeId, @projectId, @kind, @source, @ts, @signal, @bodySha256, @titleSha256, @bodyLength, @denyListId, @mutationAuditId, @removedAt)`
1245
+ );
1246
+ let removed = 0;
1247
+ for (const scopeId of [projectId, ...otherProjectIds]) {
1248
+ const rows = select.all(scopeId);
1249
+ for (const row of rows) {
1250
+ if (!firstMatchingEntry([entry], { title: row.title, body: row.body, meta: parseMeta(row.meta) })) continue;
1251
+ dropEmbedding.run(row.id);
1252
+ insertTombstone.run({
1253
+ nodeId: row.id,
1254
+ projectId: row.project_id,
1255
+ kind: row.kind,
1256
+ source: row.source,
1257
+ ts: row.ts,
1258
+ signal: row.signal,
1259
+ bodySha256: sha256Hex(row.body),
1260
+ titleSha256: sha256Hex(row.title),
1261
+ bodyLength: row.body.length,
1262
+ denyListId: entry.id,
1263
+ mutationAuditId: auditId,
1264
+ removedAt: Date.now()
1265
+ });
1266
+ deleteNode.run(row.id);
1267
+ removed += 1;
1268
+ }
1269
+ }
1270
+ db.prepare("UPDATE mutation_audit SET affected_count = ?, finished_at = ? WHERE id = ?").run(removed, Date.now(), auditId);
1271
+ return { removed, entryId: entry.id, auditId };
1272
+ })();
1273
+ }
1274
+ function listDenyList(db, projectId) {
1275
+ return listDenyListEntries(db, projectId);
1276
+ }
1277
+ function previewImportDenyList(db, projectId, otherProjectIds, entries) {
1278
+ return entries.map((input) => {
1279
+ validatePattern(input);
1280
+ if (denyListEntryExists(db, projectId, input)) {
1281
+ return { matchType: input.matchType, pattern: input.pattern, alreadyPresent: true, wouldRemove: 0 };
1282
+ }
1283
+ const preview = previewForget(db, projectId, otherProjectIds, input);
1284
+ return {
1285
+ matchType: input.matchType,
1286
+ pattern: input.pattern,
1287
+ alreadyPresent: false,
1288
+ wouldRemove: preview.reduce((sum, p) => sum + p.count, 0)
1289
+ };
1290
+ });
1291
+ }
1292
+ function importDenyList(db, projectId, otherProjectIds, entries) {
1293
+ let imported = 0;
1294
+ let skipped = 0;
1295
+ let removedNodes = 0;
1296
+ for (const input of entries) {
1297
+ validatePattern(input);
1298
+ if (denyListEntryExists(db, projectId, input)) {
1299
+ skipped += 1;
1300
+ continue;
1301
+ }
1302
+ const result = forget(db, projectId, otherProjectIds, input);
1303
+ imported += 1;
1304
+ removedNodes += result.removed;
1305
+ }
1306
+ return { imported, skipped, removedNodes };
1307
+ }
1308
+
1309
+ // src/store/file-edges.ts
1310
+ function replaceFileEdges(db, projectId, edges) {
1311
+ db.transaction(() => {
1312
+ db.prepare("DELETE FROM file_edges WHERE project_id = ?").run(projectId);
1313
+ const insert = db.prepare("INSERT OR IGNORE INTO file_edges (project_id, from_path, to_path, kind) VALUES (?, ?, ?, ?)");
1314
+ for (const edge of edges) insert.run(projectId, edge.fromPath, edge.toPath, edge.kind);
1315
+ })();
1316
+ }
1317
+ function fileEdgeStats(db, projectId) {
1318
+ const edges = db.prepare("SELECT COUNT(*) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
1319
+ const files = db.prepare("SELECT COUNT(DISTINCT from_path) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
1320
+ return { edges, files };
1321
+ }
1322
+
1323
+ // src/store/embeddings.ts
1324
+ function findNodesNeedingEmbedding(db, projectId, limit = 200, afterRowid = 0) {
1325
+ return db.prepare(
1326
+ `SELECT n.rowid AS rowid, n.id AS id, n.title AS title, n.body AS body
1327
+ FROM nodes n
1328
+ LEFT JOIN nodes_vec v ON v.rowid = n.rowid
1329
+ WHERE n.project_id = ? AND v.rowid IS NULL AND n.rowid > ?
1330
+ ORDER BY n.rowid
1331
+ LIMIT ?`
1332
+ ).all(projectId, afterRowid, limit);
1333
+ }
1334
+ function countNodesNeedingEmbedding(db, projectId) {
1335
+ const row = db.prepare(
1336
+ `SELECT COUNT(*) AS n
1337
+ FROM nodes n
1338
+ LEFT JOIN nodes_vec v ON v.rowid = n.rowid
1339
+ WHERE n.project_id = ? AND v.rowid IS NULL`
1340
+ ).get(projectId);
1341
+ return row.n;
1342
+ }
1343
+ function upsertEmbedding(db, rowid, embedding) {
1344
+ db.prepare("INSERT OR REPLACE INTO nodes_vec (rowid, embedding) VALUES (?, ?)").run(BigInt(rowid), embedding);
1345
+ }
1346
+ function dropAllEmbeddings(db) {
1347
+ return db.prepare("DELETE FROM nodes_vec").run().changes;
1348
+ }
1349
+ function vectorSearch(db, projectId, embedding, limit = 20) {
1350
+ const overfetch = Math.max(limit * 8, 50);
1351
+ return db.prepare(
1352
+ `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance, v.distance AS distance
1353
+ FROM nodes_vec v
1354
+ JOIN nodes n ON n.rowid = v.rowid
1355
+ WHERE v.embedding MATCH ? AND k = ? AND n.project_id = ?
1356
+ ORDER BY v.distance
1357
+ LIMIT ?`
1358
+ ).all(embedding, overfetch, projectId, limit);
1359
+ }
1360
+
1361
+ // src/store/fts.ts
1362
+ var FTS_SYNTAX = /["'()*:^{}[\]-]/g;
1363
+ var LOW_SIGNAL_TOKENS = /* @__PURE__ */ new Set(["id"]);
1364
+ function significantTokens(input) {
1365
+ const tokens = input.replace(FTS_SYNTAX, " ").split(/\s+/).map((t) => t.trim()).filter((t) => t.length > 0);
1366
+ if (tokens.length === 0) return [];
1367
+ const signal = tokens.filter((t) => !LOW_SIGNAL_TOKENS.has(t.toLowerCase()));
1368
+ return signal.length > 0 ? signal : tokens;
1369
+ }
1370
+ function toMatchQuery(input) {
1371
+ const kept = significantTokens(input);
1372
+ if (kept.length === 0) return null;
1373
+ return kept.map((t) => `"${t}"*`).join(" OR ");
998
1374
  }
1375
+
1376
+ // src/store/search.ts
1377
+ function search(db, projectId, query, limit = 20) {
1378
+ const match = toMatchQuery(query);
1379
+ if (!match) return [];
1380
+ const rows = db.prepare(
1381
+ `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance,
1382
+ bm25(nodes_fts, 10.0, 1.0) AS rank
1383
+ FROM nodes_fts
1384
+ JOIN nodes n ON n.rowid = nodes_fts.rowid
1385
+ WHERE nodes_fts MATCH ? AND n.project_id = ?
1386
+ ORDER BY rank
1387
+ LIMIT ?`
1388
+ ).all(match, projectId, limit);
1389
+ return rows;
1390
+ }
1391
+ function stats(db, projectId) {
1392
+ const kinds = db.prepare("SELECT kind, COUNT(*) AS n FROM nodes WHERE project_id = ? GROUP BY kind").all(projectId);
1393
+ const range = db.prepare("SELECT MIN(ts) AS oldest, MAX(ts) AS newest FROM nodes WHERE project_id = ?").get(projectId);
1394
+ const files = db.prepare(
1395
+ `SELECT COUNT(DISTINCT f.path) AS n
1396
+ FROM node_files f JOIN nodes n ON n.id = f.node_id
1397
+ WHERE n.project_id = ?`
1398
+ ).get(projectId);
1399
+ return {
1400
+ total: kinds.reduce((sum, k) => sum + k.n, 0),
1401
+ byKind: Object.fromEntries(kinds.map((k) => [k.kind, k.n])),
1402
+ oldest: range.oldest,
1403
+ newest: range.newest,
1404
+ distinctFiles: files.n
1405
+ };
1406
+ }
1407
+
1408
+ // src/store/meta.ts
1409
+ function getMeta(db, key) {
1410
+ const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
1411
+ return row?.value ?? null;
1412
+ }
1413
+ function setMeta(db, key, value) {
1414
+ db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(
1415
+ key,
1416
+ value
1417
+ );
1418
+ }
1419
+
1420
+ // src/store/store.ts
999
1421
  var MemoryStore = class _MemoryStore {
1000
1422
  constructor(db) {
1001
1423
  this.db = db;
@@ -1015,35 +1437,17 @@ var MemoryStore = class _MemoryStore {
1015
1437
  this.db.close();
1016
1438
  }
1017
1439
  upsertProject(project) {
1018
- this.db.prepare(
1019
- `INSERT INTO projects (id, root, origin_url, created_at)
1020
- VALUES (@id, @root, @originUrl, @now)
1021
- ON CONFLICT(id) DO UPDATE SET root = excluded.root, origin_url = excluded.origin_url`
1022
- ).run({ ...project, now: Date.now() });
1440
+ upsertProject(this.db, project);
1023
1441
  }
1024
1442
  markSynced(projectId) {
1025
- this.db.prepare("UPDATE projects SET last_synced_at = ? WHERE id = ?").run(Date.now(), projectId);
1443
+ markSynced(this.db, projectId);
1026
1444
  }
1027
- /**
1028
- * Every other project id ever recorded in THIS repo's own database.
1029
- *
1030
- * A repo's `.nexusmem/memory.db` is never shared with another repo (each
1031
- * gets its own, gitignored), so any id here besides `currentProjectId` is
1032
- * evidence of a prior identity for this same repo -- typically its git
1033
- * remote URL changed since the last sync. See `reconcileProjectId` in
1034
- * `store/reconcile.ts`.
1035
- */
1036
1445
  listOtherProjectIds(currentProjectId) {
1037
- return this.db.prepare("SELECT id FROM projects WHERE id != ?").all(currentProjectId).map(
1038
- (r) => r.id
1039
- );
1446
+ return listOtherProjectIds(this.db, currentProjectId);
1040
1447
  }
1041
1448
  /** Total nodes held under the given project identities. */
1042
1449
  countProjectNodes(projectIds) {
1043
- if (projectIds.length === 0) return 0;
1044
- const placeholders = projectIds.map(() => "?").join(", ");
1045
- const row = this.db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE project_id IN (${placeholders})`).get(...projectIds);
1046
- return row.n;
1450
+ return countProjectNodes(this.db, projectIds);
1047
1451
  }
1048
1452
  /**
1049
1453
  * Write a batch of nodes in one transaction.
@@ -1053,78 +1457,7 @@ var MemoryStore = class _MemoryStore {
1053
1457
  * happens when scoring or body composition is improved between releases).
1054
1458
  */
1055
1459
  upsertNodes(nodes) {
1056
- const exists = this.db.prepare("SELECT body, signal, title FROM nodes WHERE id = ?");
1057
- const dropStaleEmbedding = this.db.prepare(
1058
- "DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)"
1059
- );
1060
- const insertNode = this.db.prepare(
1061
- `INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, created_at)
1062
- VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @now)
1063
- ON CONFLICT(id) DO UPDATE SET
1064
- ts = excluded.ts, ts_epoch = excluded.ts_epoch, source = excluded.source,
1065
- title = excluded.title, body = excluded.body, signal = excluded.signal, meta = excluded.meta`
1066
- );
1067
- const clearFiles = this.db.prepare("DELETE FROM node_files WHERE node_id = ?");
1068
- const insertFile = this.db.prepare(
1069
- `INSERT INTO node_files (node_id, path, previous_path, insertions, deletions, is_binary)
1070
- VALUES (@nodeId, @path, @previousPath, @insertions, @deletions, @isBinary)
1071
- ON CONFLICT(node_id, path) DO UPDATE SET
1072
- previous_path = excluded.previous_path, insertions = excluded.insertions,
1073
- deletions = excluded.deletions, is_binary = excluded.is_binary`
1074
- );
1075
- const stats = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
1076
- const denyEntriesByProject = /* @__PURE__ */ new Map();
1077
- const run = this.db.transaction((batch) => {
1078
- const now = Date.now();
1079
- for (const node of batch) {
1080
- let denyEntries = denyEntriesByProject.get(node.projectId);
1081
- if (!denyEntries) {
1082
- denyEntries = listDenyListEntries(this.db, node.projectId);
1083
- denyEntriesByProject.set(node.projectId, denyEntries);
1084
- }
1085
- if (firstMatchingEntry(denyEntries, node)) {
1086
- stats.denied += 1;
1087
- continue;
1088
- }
1089
- const prior = exists.get(node.id);
1090
- if (prior) {
1091
- if (prior.body === node.body && prior.signal === node.signal && prior.title === node.title) {
1092
- stats.unchanged += 1;
1093
- continue;
1094
- }
1095
- stats.updated += 1;
1096
- dropStaleEmbedding.run(node.id);
1097
- } else {
1098
- stats.inserted += 1;
1099
- }
1100
- insertNode.run({
1101
- id: node.id,
1102
- kind: node.kind,
1103
- projectId: node.projectId,
1104
- ts: node.ts,
1105
- tsEpoch: epochOf(node.ts),
1106
- source: node.source,
1107
- title: node.title,
1108
- body: node.body,
1109
- signal: node.signal,
1110
- meta: JSON.stringify(node.meta),
1111
- now
1112
- });
1113
- clearFiles.run(node.id);
1114
- for (const file of node.files) {
1115
- insertFile.run({
1116
- nodeId: node.id,
1117
- path: file.path,
1118
- previousPath: file.previousPath ?? null,
1119
- insertions: file.insertions,
1120
- deletions: file.deletions,
1121
- isBinary: file.binary ? 1 : 0
1122
- });
1123
- }
1124
- }
1125
- });
1126
- run(nodes);
1127
- return stats;
1460
+ return upsertNodes(this.db, nodes);
1128
1461
  }
1129
1462
  /**
1130
1463
  * The stored `meta` blob for one node, or null if it has never been
@@ -1132,53 +1465,28 @@ var MemoryStore = class _MemoryStore {
1132
1465
  * already done without re-reading the node's whole body.
1133
1466
  */
1134
1467
  getNodeMeta(id) {
1135
- const row = this.db.prepare("SELECT meta FROM nodes WHERE id = ?").get(id);
1136
- if (!row) return null;
1137
- try {
1138
- return JSON.parse(row.meta);
1139
- } catch {
1140
- return null;
1141
- }
1468
+ return getNodeMeta(this.db, id);
1142
1469
  }
1143
1470
  getSyncCursor(projectId, source) {
1144
- const row = this.db.prepare("SELECT cursor FROM sync_state WHERE project_id = ? AND source = ?").get(projectId, source);
1145
- return row?.cursor ?? null;
1471
+ return getSyncCursor(this.db, projectId, source);
1146
1472
  }
1147
1473
  setSyncCursor(projectId, source, cursor) {
1148
- this.db.prepare(
1149
- `INSERT INTO sync_state (project_id, source, cursor, last_run_at)
1150
- VALUES (?, ?, ?, ?)
1151
- ON CONFLICT(project_id, source) DO UPDATE SET cursor = excluded.cursor, last_run_at = excluded.last_run_at`
1152
- ).run(projectId, source, cursor, Date.now());
1474
+ setSyncCursor(this.db, projectId, source, cursor);
1153
1475
  }
1154
1476
  /** Every source that has ever synced for this project, most recently run first. */
1155
1477
  listSyncState(projectId) {
1156
- return this.db.prepare("SELECT source, cursor, last_run_at AS lastRunAt FROM sync_state WHERE project_id = ? ORDER BY last_run_at DESC").all(projectId);
1478
+ return listSyncState(this.db, projectId);
1157
1479
  }
1158
1480
  /** Drop every node for a project. Used by `sync --rebuild`. */
1159
1481
  clearProject(projectId) {
1160
- this.db.prepare("DELETE FROM nodes_vec WHERE rowid IN (SELECT rowid FROM nodes WHERE project_id = ?)").run(projectId);
1161
- const info = this.db.prepare("DELETE FROM nodes WHERE project_id = ?").run(projectId);
1162
- this.db.prepare("DELETE FROM sync_state WHERE project_id = ?").run(projectId);
1163
- return info.changes;
1482
+ return clearProject(this.db, projectId);
1164
1483
  }
1165
- /**
1166
- * Record a directed relationship between two existing nodes -- e.g. a
1167
- * failed `shell_command` and whatever node later resolved it
1168
- * (`relation = 'resolved_by'`). A relation, not a new content node: the
1169
- * correlation *is* the relationship, and duplicating either side's content
1170
- * into a third node would just be another independently-ranked candidate.
1171
- *
1172
- * Idempotent by design (`INSERT OR IGNORE` against the table's own primary
1173
- * key) so re-running a correlation pass over already-linked nodes is a
1174
- * no-op, not a duplicate-row error.
1175
- */
1176
1484
  linkNodes(fromNodeId, toNodeId, relation) {
1177
- this.db.prepare("INSERT OR IGNORE INTO node_links (from_node_id, to_node_id, relation, created_at) VALUES (?, ?, ?, ?)").run(fromNodeId, toNodeId, relation, Date.now());
1485
+ linkNodes(this.db, fromNodeId, toNodeId, relation);
1178
1486
  }
1179
1487
  /** Ids linked from `fromNodeId` under one relation, most recently linked first. Empty if none exist. */
1180
1488
  getLinkedNodeIds(fromNodeId, relation) {
1181
- return this.db.prepare("SELECT to_node_id FROM node_links WHERE from_node_id = ? AND relation = ? ORDER BY created_at DESC").all(fromNodeId, relation).map((row) => row.to_node_id);
1489
+ return getLinkedNodeIds(this.db, fromNodeId, relation);
1182
1490
  }
1183
1491
  /**
1184
1492
  * Hydrate full content for a set of node ids, e.g. to pack a linked
@@ -1192,11 +1500,7 @@ var MemoryStore = class _MemoryStore {
1192
1500
  * that gap is not addressed here.
1193
1501
  */
1194
1502
  getNodesByIds(ids) {
1195
- if (ids.length === 0) return [];
1196
- return this.db.prepare(
1197
- `SELECT id, kind, project_id AS projectId, ts, title, body, signal
1198
- FROM nodes WHERE id IN (SELECT value FROM json_each(?))`
1199
- ).all(JSON.stringify(ids));
1503
+ return getNodesByIds(this.db, ids);
1200
1504
  }
1201
1505
  /**
1202
1506
  * The most recently-remembered nodes for a project, newest event first --
@@ -1205,18 +1509,11 @@ var MemoryStore = class _MemoryStore {
1205
1509
  * `idx_nodes_project_ts` already exists for exactly this access pattern.
1206
1510
  */
1207
1511
  listRecentNodes(projectId, limit = 20) {
1208
- return this.db.prepare(
1209
- `SELECT id, kind, ts, source, title, signal
1210
- FROM nodes
1211
- WHERE project_id = ?
1212
- ORDER BY ts_epoch DESC
1213
- LIMIT ?`
1214
- ).all(projectId, limit);
1512
+ return listRecentNodes(this.db, projectId, limit);
1215
1513
  }
1216
1514
  /** How many nodes of one source exist for a project. Used to preview a `pruneSourceNodes` wipe before running it. */
1217
1515
  countSourceNodes(projectId, source) {
1218
- const row = this.db.prepare("SELECT COUNT(*) AS count FROM nodes WHERE project_id = ? AND source = ?").get(projectId, source);
1219
- return row.count;
1516
+ return countSourceNodes(this.db, projectId, source);
1220
1517
  }
1221
1518
  /**
1222
1519
  * Delete the nodes of one source that its latest full scan did not produce.
@@ -1244,300 +1541,70 @@ var MemoryStore = class _MemoryStore {
1244
1541
  * real history.
1245
1542
  */
1246
1543
  pruneSourceNodes(projectId, source, keepIds, opts = {}) {
1247
- const scope = `project_id = @projectId AND source = @source
1248
- AND id NOT IN (SELECT value FROM json_each(@keepIds))
1249
- AND id NOT IN (SELECT node_id FROM node_files WHERE path IN (SELECT value FROM json_each(@keepPaths)))`;
1250
- const params = {
1251
- projectId,
1252
- source,
1253
- keepIds: JSON.stringify(keepIds),
1254
- keepPaths: JSON.stringify(opts.keepPaths ?? [])
1255
- };
1256
- return this.db.transaction(() => {
1257
- this.db.prepare(`DELETE FROM nodes_vec WHERE rowid IN (SELECT rowid FROM nodes WHERE ${scope})`).run(params);
1258
- return this.db.prepare(`DELETE FROM nodes WHERE ${scope}`).run(params).changes;
1259
- })();
1544
+ return pruneSourceNodes(this.db, projectId, source, keepIds, opts);
1260
1545
  }
1261
- /**
1262
- * What `forget(projectId, otherProjectIds, input)` with these same
1263
- * arguments would remove, without writing anything -- the `forget` CLI
1264
- * command's dry-run default.
1265
- */
1266
1546
  previewForget(projectId, otherProjectIds, input) {
1267
- validatePattern(input);
1268
- const probeEntry = { id: -1, projectId, createdAt: 0, ...input };
1269
- const select = this.db.prepare("SELECT project_id, source, title, body, meta FROM nodes WHERE project_id = ?");
1270
- const counts = /* @__PURE__ */ new Map();
1271
- for (const scopeId of [projectId, ...otherProjectIds]) {
1272
- const rows = select.all(scopeId);
1273
- for (const row of rows) {
1274
- if (!firstMatchingEntry([probeEntry], { title: row.title, body: row.body, meta: parseMeta(row.meta) })) continue;
1275
- const key = `${row.project_id} ${row.source}`;
1276
- const existing = counts.get(key);
1277
- if (existing) existing.count += 1;
1278
- else counts.set(key, { projectId: row.project_id, source: row.source, count: 1 });
1279
- }
1280
- }
1281
- return [...counts.values()];
1547
+ return previewForget(this.db, projectId, otherProjectIds, input);
1282
1548
  }
1283
- /**
1284
- * Permanently deny-list a value and delete every node it currently matches
1285
- * across `projectId` + `otherProjectIds` (the same sweep `pruneSourceNodes`
1286
- * uses for a repo's stale prior identities).
1287
- *
1288
- * Unlike `pruneSourceNodes`, this doesn't just delete: the deny-list entry
1289
- * written here is consulted by `upsertNodes` and `reconcile.ts` on every
1290
- * future write, so a value forgotten today cannot be re-derived from an
1291
- * append-only source (the shell-hook log, a full transcript re-read) on a
1292
- * later `sync --rebuild`. Each removed node leaves a hash-only tombstone
1293
- * (never the content itself) and the whole operation writes one
1294
- * `mutation_audit` row, whether or not anything matched -- pre-emptively
1295
- * blocking a value that hasn't appeared yet is a valid, auditable call.
1296
- */
1297
1549
  forget(projectId, otherProjectIds, input) {
1298
- return this.db.transaction(() => {
1299
- const entry = insertDenyListEntry(this.db, { ...input, projectId });
1300
- const startedAt = Date.now();
1301
- const auditId = Number(
1302
- this.db.prepare(
1303
- `INSERT INTO mutation_audit (action, project_id, detail, affected_count, succeeded, error, started_at, finished_at)
1304
- VALUES ('forget', @projectId, @detail, 0, 1, NULL, @startedAt, @startedAt)`
1305
- ).run({
1306
- projectId,
1307
- detail: JSON.stringify({
1308
- pattern: input.pattern,
1309
- matchType: input.matchType,
1310
- ignoreCase: input.ignoreCase,
1311
- reason: input.reason,
1312
- scopeProjectIds: [projectId, ...otherProjectIds]
1313
- }),
1314
- startedAt
1315
- }).lastInsertRowid
1316
- );
1317
- const select = this.db.prepare(
1318
- "SELECT id, kind, project_id, source, ts, signal, title, body, meta FROM nodes WHERE project_id = ?"
1319
- );
1320
- const dropEmbedding = this.db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
1321
- const deleteNode = this.db.prepare("DELETE FROM nodes WHERE id = ?");
1322
- const insertTombstone = this.db.prepare(
1323
- `INSERT INTO tombstones
1324
- (node_id, project_id, kind, source, ts, signal, body_sha256, title_sha256, body_length, deny_list_id, mutation_audit_id, removed_at)
1325
- VALUES (@nodeId, @projectId, @kind, @source, @ts, @signal, @bodySha256, @titleSha256, @bodyLength, @denyListId, @mutationAuditId, @removedAt)`
1326
- );
1327
- let removed = 0;
1328
- for (const scopeId of [projectId, ...otherProjectIds]) {
1329
- const rows = select.all(scopeId);
1330
- for (const row of rows) {
1331
- if (!firstMatchingEntry([entry], { title: row.title, body: row.body, meta: parseMeta(row.meta) })) continue;
1332
- dropEmbedding.run(row.id);
1333
- insertTombstone.run({
1334
- nodeId: row.id,
1335
- projectId: row.project_id,
1336
- kind: row.kind,
1337
- source: row.source,
1338
- ts: row.ts,
1339
- signal: row.signal,
1340
- bodySha256: sha256Hex(row.body),
1341
- titleSha256: sha256Hex(row.title),
1342
- bodyLength: row.body.length,
1343
- denyListId: entry.id,
1344
- mutationAuditId: auditId,
1345
- removedAt: Date.now()
1346
- });
1347
- deleteNode.run(row.id);
1348
- removed += 1;
1349
- }
1350
- }
1351
- this.db.prepare("UPDATE mutation_audit SET affected_count = ?, finished_at = ? WHERE id = ?").run(removed, Date.now(), auditId);
1352
- return { removed, entryId: entry.id, auditId };
1353
- })();
1550
+ return forget(this.db, projectId, otherProjectIds, input);
1354
1551
  }
1355
1552
  /** Active deny-list entries for one project, oldest first. */
1356
1553
  listDenyList(projectId) {
1357
- return listDenyListEntries(this.db, projectId);
1554
+ return listDenyList(this.db, projectId);
1358
1555
  }
1359
- /**
1360
- * What `importDenyList` with these same entries would do, without writing
1361
- * anything -- `forget --import`'s dry-run default, same convention as
1362
- * `previewForget`.
1363
- */
1364
1556
  previewImportDenyList(projectId, otherProjectIds, entries) {
1365
- return entries.map((input) => {
1366
- validatePattern(input);
1367
- if (denyListEntryExists(this.db, projectId, input)) {
1368
- return { matchType: input.matchType, pattern: input.pattern, alreadyPresent: true, wouldRemove: 0 };
1369
- }
1370
- const preview = this.previewForget(projectId, otherProjectIds, input);
1371
- return {
1372
- matchType: input.matchType,
1373
- pattern: input.pattern,
1374
- alreadyPresent: false,
1375
- wouldRemove: preview.reduce((sum, p) => sum + p.count, 0)
1376
- };
1377
- });
1557
+ return previewImportDenyList(this.db, projectId, otherProjectIds, entries);
1378
1558
  }
1379
- /**
1380
- * Re-apply a previously-exported deny-list against this project.
1381
- *
1382
- * This is the fix for `forget`'s per-checkout gap: `deny_list` lives in
1383
- * `.nexusmem/memory.db`, which is gitignored and never travels with `git
1384
- * clone`/`git push`, while the things a fresh `sync` re-derives from --
1385
- * git history and the user-home shell-hook log -- both travel or persist
1386
- * independently of any one checkout. A fresh clone or a restored backup
1387
- * starts with an empty deny_list and no memory of what was forgotten. See
1388
- * docs/forget-mechanism.md.
1389
- *
1390
- * Entries already active (same matchType+pattern+ignoreCase) are left
1391
- * untouched. Every new one goes through `forget` itself, so an imported
1392
- * value is deleted from this checkout's nodes too, not just blocked going
1393
- * forward -- exactly what running `nexusmem forget <value>` fresh in this
1394
- * checkout would have done.
1395
- */
1396
1559
  importDenyList(projectId, otherProjectIds, entries) {
1397
- let imported = 0;
1398
- let skipped = 0;
1399
- let removedNodes = 0;
1400
- for (const input of entries) {
1401
- validatePattern(input);
1402
- if (denyListEntryExists(this.db, projectId, input)) {
1403
- skipped += 1;
1404
- continue;
1405
- }
1406
- const result = this.forget(projectId, otherProjectIds, input);
1407
- imported += 1;
1408
- removedNodes += result.removed;
1409
- }
1410
- return { imported, skipped, removedNodes };
1560
+ return importDenyList(this.db, projectId, otherProjectIds, entries);
1411
1561
  }
1412
- /**
1413
- * Replace this project's entire `file_edges` snapshot in one transaction.
1414
- *
1415
- * Edges describe the current working tree, not history -- unlike
1416
- * `pruneSourceNodes`'s incremental diff-against-a-scan, there is no cursor
1417
- * to walk, so every `scan-structure`/sync run is a full rescan and this is
1418
- * always a delete-then-insert of the whole set, never a partial update.
1419
- */
1420
1562
  replaceFileEdges(projectId, edges) {
1421
- this.db.transaction(() => {
1422
- this.db.prepare("DELETE FROM file_edges WHERE project_id = ?").run(projectId);
1423
- const insert = this.db.prepare(
1424
- "INSERT OR IGNORE INTO file_edges (project_id, from_path, to_path, kind) VALUES (?, ?, ?, ?)"
1425
- );
1426
- for (const edge of edges) insert.run(projectId, edge.fromPath, edge.toPath, edge.kind);
1427
- })();
1563
+ replaceFileEdges(this.db, projectId, edges);
1428
1564
  }
1429
1565
  /** Edge count + distinct source-file count, for `nexusmem status`'s `structure` line. */
1430
1566
  fileEdgeStats(projectId) {
1431
- const edges = this.db.prepare("SELECT COUNT(*) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
1432
- const files = this.db.prepare("SELECT COUNT(DISTINCT from_path) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
1433
- return { edges, files };
1567
+ return fileEdgeStats(this.db, projectId);
1434
1568
  }
1435
- /**
1436
- * Nodes for this project that have no embedding yet (new, or invalidated
1437
- * by a content change).
1438
- *
1439
- * `afterRowid` makes paging monotonic: the pass walks rowids strictly
1440
- * upward instead of re-reading "the first N still pending". That matters
1441
- * because a node the provider *failed* on stays pending -- an offset-free
1442
- * loop would fetch the same failures forever, which is exactly the shape
1443
- * of an infinite sync.
1444
- */
1445
1569
  findNodesNeedingEmbedding(projectId, limit = 200, afterRowid = 0) {
1446
- return this.db.prepare(
1447
- `SELECT n.rowid AS rowid, n.id AS id, n.title AS title, n.body AS body
1448
- FROM nodes n
1449
- LEFT JOIN nodes_vec v ON v.rowid = n.rowid
1450
- WHERE n.project_id = ? AND v.rowid IS NULL AND n.rowid > ?
1451
- ORDER BY n.rowid
1452
- LIMIT ?`
1453
- ).all(projectId, afterRowid, limit);
1570
+ return findNodesNeedingEmbedding(this.db, projectId, limit, afterRowid);
1454
1571
  }
1455
1572
  /** How many of this project's nodes still need a vector. For progress reporting. */
1456
1573
  countNodesNeedingEmbedding(projectId) {
1457
- const row = this.db.prepare(
1458
- `SELECT COUNT(*) AS n
1459
- FROM nodes n
1460
- LEFT JOIN nodes_vec v ON v.rowid = n.rowid
1461
- WHERE n.project_id = ? AND v.rowid IS NULL`
1462
- ).get(projectId);
1463
- return row.n;
1574
+ return countNodesNeedingEmbedding(this.db, projectId);
1464
1575
  }
1465
1576
  upsertEmbedding(rowid, embedding) {
1466
- this.db.prepare("INSERT OR REPLACE INTO nodes_vec (rowid, embedding) VALUES (?, ?)").run(BigInt(rowid), embedding);
1577
+ upsertEmbedding(this.db, rowid, embedding);
1467
1578
  }
1468
- /**
1469
- * Drop every vector in this database, across all projects.
1470
- *
1471
- * Whole-database on purpose: `nodes_vec` is shared and holds no
1472
- * provenance, so once the vectors in it stopped being comparable there is
1473
- * no subset that is still trustworthy. Nodes are untouched, so the next
1474
- * embedding pass simply rebuilds them.
1475
- */
1476
1579
  dropAllEmbeddings() {
1477
- return this.db.prepare("DELETE FROM nodes_vec").run().changes;
1580
+ return dropAllEmbeddings(this.db);
1478
1581
  }
1479
1582
  getMeta(key) {
1480
- const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
1481
- return row?.value ?? null;
1583
+ return getMeta(this.db, key);
1482
1584
  }
1483
1585
  setMeta(key, value) {
1484
- this.db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
1586
+ setMeta(this.db, key, value);
1485
1587
  }
1486
- /**
1487
- * Nearest-neighbour search over the corpus.
1488
- *
1489
- * `nodes_vec` has no `project_id` column of its own (embeddings are
1490
- * generic; project scoping lives on `nodes`), so this over-fetches `k`
1491
- * before joining and filtering, then caps to `limit`. Simple and correct;
1492
- * not the efficient way to do this at a scale this project isn't at yet.
1493
- */
1494
1588
  vectorSearch(projectId, embedding, limit = 20) {
1495
- const overfetch = Math.max(limit * 8, 50);
1496
- return this.db.prepare(
1497
- `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, v.distance AS distance
1498
- FROM nodes_vec v
1499
- JOIN nodes n ON n.rowid = v.rowid
1500
- WHERE v.embedding MATCH ? AND k = ? AND n.project_id = ?
1501
- ORDER BY v.distance
1502
- LIMIT ?`
1503
- ).all(embedding, overfetch, projectId, limit);
1589
+ return vectorSearch(this.db, projectId, embedding, limit);
1504
1590
  }
1505
1591
  stats(projectId) {
1506
- const kinds = this.db.prepare("SELECT kind, COUNT(*) AS n FROM nodes WHERE project_id = ? GROUP BY kind").all(projectId);
1507
- const range = this.db.prepare("SELECT MIN(ts) AS oldest, MAX(ts) AS newest FROM nodes WHERE project_id = ?").get(projectId);
1508
- const files = this.db.prepare(
1509
- `SELECT COUNT(DISTINCT f.path) AS n
1510
- FROM node_files f JOIN nodes n ON n.id = f.node_id
1511
- WHERE n.project_id = ?`
1512
- ).get(projectId);
1513
- return {
1514
- total: kinds.reduce((sum, k) => sum + k.n, 0),
1515
- byKind: Object.fromEntries(kinds.map((k) => [k.kind, k.n])),
1516
- oldest: range.oldest,
1517
- newest: range.newest,
1518
- distinctFiles: files.n
1519
- };
1592
+ return stats(this.db, projectId);
1520
1593
  }
1521
- /**
1522
- * Lexical search over the corpus.
1523
- *
1524
- * Title is weighted 10x body: a commit subject that names the thing you asked
1525
- * about is far stronger evidence than the same word buried in a file list.
1526
- * Ranking by `relevance x signal` happens a layer up, in retrieval.
1527
- */
1528
1594
  search(projectId, query, limit = 20) {
1529
- const match = toMatchQuery(query);
1530
- if (!match) return [];
1531
- const rows = this.db.prepare(
1532
- `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal,
1533
- bm25(nodes_fts, 10.0, 1.0) AS rank
1534
- FROM nodes_fts
1535
- JOIN nodes n ON n.rowid = nodes_fts.rowid
1536
- WHERE nodes_fts MATCH ? AND n.project_id = ?
1537
- ORDER BY rank
1538
- LIMIT ?`
1539
- ).all(match, projectId, limit);
1540
- return rows;
1595
+ return search(this.db, projectId, query, limit);
1596
+ }
1597
+ /** The project a node belongs to, or null if no node has this id. Used by `mark-stale` to validate both ids. */
1598
+ getNodeProjectId(id) {
1599
+ return getNodeProjectId(this.db, id);
1600
+ }
1601
+ /** Every node id some other node's `supersedes` points at, for one project -- what the ranker should down-weight. */
1602
+ getSupersededIds(projectId) {
1603
+ return getSupersededIds(this.db, projectId);
1604
+ }
1605
+ /** Record that `newNodeId` supersedes `staleNodeId` -- the write behind `nexusmem mark-stale`. Caller validates both ids first. */
1606
+ setSupersedes(newNodeId, staleNodeId) {
1607
+ setSupersedes(this.db, newNodeId, staleNodeId);
1541
1608
  }
1542
1609
  /** Escape hatch for tests and future modules. */
1543
1610
  get raw() {
@@ -1930,8 +1997,47 @@ async function runInit(opts) {
1930
1997
  return 0;
1931
1998
  }
1932
1999
 
1933
- // src/cli/commands/projects.ts
2000
+ // src/cli/commands/mark-stale.ts
1934
2001
  import pc5 from "picocolors";
2002
+ var MarkStaleError = class extends Error {
2003
+ constructor(message) {
2004
+ super(message);
2005
+ this.name = "MarkStaleError";
2006
+ }
2007
+ };
2008
+ async function runMarkStale(opts) {
2009
+ const { projectId, ws } = await loadContext(opts.cwd);
2010
+ const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
2011
+ if (opts.nodeId === opts.supersedesId) {
2012
+ throw new MarkStaleError("a node cannot supersede itself");
2013
+ }
2014
+ const store = MemoryStore.open(ws.dbPath);
2015
+ try {
2016
+ const staleProject = store.getNodeProjectId(opts.nodeId);
2017
+ if (staleProject === null) {
2018
+ throw new MarkStaleError(`no node found with id ${opts.nodeId}`);
2019
+ }
2020
+ const supersedingProject = store.getNodeProjectId(opts.supersedesId);
2021
+ if (supersedingProject === null) {
2022
+ throw new MarkStaleError(`no node found with id ${opts.supersedesId}`);
2023
+ }
2024
+ if (staleProject !== projectId || supersedingProject !== projectId) {
2025
+ throw new MarkStaleError("both nodes must belong to the current project");
2026
+ }
2027
+ store.setSupersedes(opts.supersedesId, opts.nodeId);
2028
+ out(
2029
+ `${pc5.green("marked stale")} ${opts.nodeId}
2030
+ ${pc5.dim("superseded by")} ${opts.supersedesId} -- the old node stays queryable, just ranked lower
2031
+ `
2032
+ );
2033
+ return 0;
2034
+ } finally {
2035
+ store.close();
2036
+ }
2037
+ }
2038
+
2039
+ // src/cli/commands/projects.ts
2040
+ import pc6 from "picocolors";
1935
2041
  async function runProjects(opts) {
1936
2042
  const { entries, missing } = await readLiveRegistry();
1937
2043
  const rows = entries.map((entry) => {
@@ -1950,7 +2056,7 @@ async function runProjects(opts) {
1950
2056
  });
1951
2057
  if (opts.prune) {
1952
2058
  const removed = await forgetProjects(missing.map((entry) => entry.projectId));
1953
- process.stderr.write(`${pc5.yellow("pruned")} ${removed} project(s) whose database is gone
2059
+ process.stderr.write(`${pc6.yellow("pruned")} ${removed} project(s) whose database is gone
1954
2060
  `);
1955
2061
  }
1956
2062
  if (opts.json) {
@@ -1958,28 +2064,28 @@ async function runProjects(opts) {
1958
2064
  `);
1959
2065
  return 0;
1960
2066
  }
1961
- process.stderr.write(`${pc5.dim("registry")} ${registryPath()}
2067
+ process.stderr.write(`${pc6.dim("registry")} ${registryPath()}
1962
2068
 
1963
2069
  `);
1964
2070
  if (rows.length === 0) {
1965
- process.stderr.write(`${pc5.yellow("no projects registered")} -- run ${pc5.bold("nexusmem sync")} in a repository
2071
+ process.stderr.write(`${pc6.yellow("no projects registered")} -- run ${pc6.bold("nexusmem sync")} in a repository
1966
2072
  `);
1967
2073
  return 0;
1968
2074
  }
1969
2075
  for (const row of rows) {
1970
2076
  const seen = new Date(row.lastSeenAt).toISOString().slice(0, 16).replace("T", " ");
1971
- const count = row.nodes === null ? pc5.yellow("unreadable") : `${row.nodes} node(s)`;
1972
- process.stdout.write(`${pc5.cyan(row.projectId.slice(0, 8))} ${row.root}
1973
- ${pc5.dim(`${count}, last seen ${seen}`)}
2077
+ const count = row.nodes === null ? pc6.yellow("unreadable") : `${row.nodes} node(s)`;
2078
+ process.stdout.write(`${pc6.cyan(row.projectId.slice(0, 8))} ${row.root}
2079
+ ${pc6.dim(`${count}, last seen ${seen}`)}
1974
2080
  `);
1975
2081
  }
1976
2082
  if (!opts.prune && missing.length > 0) {
1977
2083
  process.stderr.write(
1978
2084
  `
1979
- ${pc5.yellow(`${missing.length} registered project(s) have no database on disk`)} ${pc5.dim("-- run with --prune to forget them")}
2085
+ ${pc6.yellow(`${missing.length} registered project(s) have no database on disk`)} ${pc6.dim("-- run with --prune to forget them")}
1980
2086
  `
1981
2087
  );
1982
- for (const entry of missing) process.stderr.write(` ${pc5.dim(entry.root)}
2088
+ for (const entry of missing) process.stderr.write(` ${pc6.dim(entry.root)}
1983
2089
  `);
1984
2090
  }
1985
2091
  return 0;
@@ -2148,6 +2254,7 @@ function packContext(ranked, tokensBudget, opts = {}) {
2148
2254
  score: hit.score,
2149
2255
  summary,
2150
2256
  tokens,
2257
+ provenance: hit.provenance,
2151
2258
  ...hit.project ? { project: hit.project } : {}
2152
2259
  });
2153
2260
  tokensUsed += tokens;
@@ -2160,7 +2267,8 @@ function renderContextBlock(query, result) {
2160
2267
  const lines = [`Relevant history for: ${query}`, ""];
2161
2268
  for (const node of result.nodes) {
2162
2269
  const project = node.project ? `[${node.project}] ` : "";
2163
- lines.push(`- ${node.ts.slice(0, 10)} ${project}${node.title}`);
2270
+ const provenance = `[${node.provenance}] `;
2271
+ lines.push(`- ${node.ts.slice(0, 10)} ${provenance}${project}${node.title}`);
2164
2272
  if (node.summary && node.summary !== node.title) {
2165
2273
  if (node.kind === "code_diff") {
2166
2274
  for (const line of node.summary.split("\n")) lines.push(` ${line}`);
@@ -2290,7 +2398,16 @@ function mergeSearchAndVectorHits(bm25Hits, vectorHits) {
2290
2398
  for (const hit of bm25Hits) byId.set(hit.id, hit);
2291
2399
  for (const hit of vectorHits) {
2292
2400
  if (byId.has(hit.id)) continue;
2293
- byId.set(hit.id, { id: hit.id, kind: hit.kind, ts: hit.ts, title: hit.title, body: hit.body, signal: hit.signal, rank: 0 });
2401
+ byId.set(hit.id, {
2402
+ id: hit.id,
2403
+ kind: hit.kind,
2404
+ ts: hit.ts,
2405
+ title: hit.title,
2406
+ body: hit.body,
2407
+ signal: hit.signal,
2408
+ provenance: hit.provenance,
2409
+ rank: 0
2410
+ });
2294
2411
  }
2295
2412
  return [...byId.values()];
2296
2413
  }
@@ -2301,6 +2418,7 @@ var SIGNAL_FLOOR = 0.2;
2301
2418
  var RECENCY_FLOOR = 0.3;
2302
2419
  var DEFAULT_HALF_LIFE_DAYS = 30;
2303
2420
  var MS_PER_DAY = 864e5;
2421
+ var SUPERSEDED_PENALTY = 0.5;
2304
2422
  var MAX_PRIOR_OVERTURN = 2;
2305
2423
  var PRIOR_COUNT = 2;
2306
2424
  var PER_PRIOR_OVERTURN = MAX_PRIOR_OVERTURN ** (1 / PRIOR_COUNT);
@@ -2341,7 +2459,8 @@ function rankHits(hits, opts = {}) {
2341
2459
  const signalWeight = SIGNAL_FLOOR + (1 - SIGNAL_FLOOR) * hit.signal;
2342
2460
  const ageDays = ageDaysOf(hit.ts, now);
2343
2461
  const recencyFactor = RECENCY_FLOOR + (1 - RECENCY_FLOOR) * 2 ** (-ageDays / halfLife);
2344
- const score = relevance * signalWeight ** SIGNAL_EXPONENT * recencyFactor ** RECENCY_EXPONENT;
2462
+ const rawScore = relevance * signalWeight ** SIGNAL_EXPONENT * recencyFactor ** RECENCY_EXPONENT;
2463
+ const score = opts.supersededIds?.has(hit.id) ? rawScore * SUPERSEDED_PENALTY : rawScore;
2345
2464
  return { ...hit, relevance, signalWeight, ageDays, recencyFactor, score };
2346
2465
  });
2347
2466
  return ranked.sort((a, b) => b.score - a.score);
@@ -2370,6 +2489,7 @@ function pullLinkedResolutions(resolveStore, ranked) {
2370
2489
  title: resolution.title,
2371
2490
  body: resolution.body,
2372
2491
  signal: resolution.signal,
2492
+ provenance: resolution.provenance,
2373
2493
  rank: 0,
2374
2494
  // no bm25/vector rank of its own -- never read again past this point
2375
2495
  relevance: hit.relevance,
@@ -2391,6 +2511,7 @@ async function runCrossProjectQuery(sources, query, opts) {
2391
2511
  const perProject = [];
2392
2512
  let bm25Count = 0;
2393
2513
  let vectorCount = 0;
2514
+ const supersededIds = /* @__PURE__ */ new Set();
2394
2515
  for (const source of sources) {
2395
2516
  const label = (hit) => ({ ...hit, project: source.label });
2396
2517
  const bm25Hits = source.store.search(source.projectId, query, opts.candidates).map(label);
@@ -2403,12 +2524,13 @@ async function runCrossProjectQuery(sources, query, opts) {
2403
2524
  lists.push(vectorHits.map((hit) => ({ ...hit, rank: 0, project: source.label })));
2404
2525
  }
2405
2526
  hits.push(...mergeSearchAndVectorHits(bm25Hits, vectorHits).map(label));
2527
+ for (const id of source.store.getSupersededIds(source.projectId)) supersededIds.add(id);
2406
2528
  }
2407
2529
  const storeByLabel = new Map(sources.map((source) => [source.label, source.store]));
2408
2530
  const relevanceScores = reciprocalRankFusion(lists);
2409
2531
  const ranked = pullLinkedResolutions(
2410
2532
  (hit) => hit.project ? storeByLabel.get(hit.project) : void 0,
2411
- rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores })
2533
+ rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores, supersededIds })
2412
2534
  );
2413
2535
  const packed = packContext(ranked, opts.budget, { query });
2414
2536
  return { bm25Count, vectorCount, hits, packed, perProject };
@@ -2422,9 +2544,10 @@ async function runHybridQuery(store, projectId, query, opts) {
2422
2544
  }
2423
2545
  const hits = vectorHits.length > 0 ? mergeSearchAndVectorHits(bm25Hits, vectorHits) : bm25Hits;
2424
2546
  const relevanceScores = vectorHits.length > 0 ? reciprocalRankFusion([bm25Hits, vectorHits]) : void 0;
2547
+ const supersededIds = store.getSupersededIds(projectId);
2425
2548
  const ranked = pullLinkedResolutions(
2426
2549
  () => store,
2427
- rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores })
2550
+ rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores, supersededIds })
2428
2551
  );
2429
2552
  const packed = packContext(ranked, opts.budget, { query });
2430
2553
  return { bm25Count: bm25Hits.length, vectorCount: vectorHits.length, hits, packed };
@@ -2529,7 +2652,7 @@ var OllamaEmbeddingProvider = class {
2529
2652
  };
2530
2653
 
2531
2654
  // src/cli/commands/sync.ts
2532
- import pc6 from "picocolors";
2655
+ import pc7 from "picocolors";
2533
2656
 
2534
2657
  // src/conversation/chunk.ts
2535
2658
  var HEADING_LINE = /^#{1,6}\s+(.+)$/;
@@ -2664,6 +2787,8 @@ function toMemoryNodes(turn, projectId, opts = {}) {
2664
2787
  files: extractMentionedFiles(`${userRedacted.text}
2665
2788
  ${chunk2.text}`),
2666
2789
  signal: scoreConversationTurn(userRedacted.text, chunk2.text),
2790
+ provenance: "inferred",
2791
+ // discourse about what happened, not the event itself
2667
2792
  meta: {
2668
2793
  cwd: turn.cwd,
2669
2794
  source: turn.source,
@@ -3076,6 +3201,7 @@ function toMemoryNode(commit, projectId, opts = {}) {
3076
3201
  body: truncate(bodyParts.join("\n"), maxBody),
3077
3202
  files: keptFiles,
3078
3203
  signal: scoreCommit(commit),
3204
+ provenance: "observed",
3079
3205
  meta: {
3080
3206
  sha: commit.sha,
3081
3207
  shortSha: commit.shortSha,
@@ -3170,6 +3296,7 @@ function toMemoryNodes2(commit, projectId, opts = {}) {
3170
3296
  }
3171
3297
  ],
3172
3298
  signal: scoreFileDiff(commit.subject, file),
3299
+ provenance: "observed",
3173
3300
  meta: {
3174
3301
  sha: commit.sha,
3175
3302
  shortSha: commit.shortSha,
@@ -3233,6 +3360,8 @@ function toMemoryNodes3(file, projectId, opts = {}) {
3233
3360
  body: truncate(chunk2.text, maxBody),
3234
3361
  files: [{ path: file.path, insertions: null, deletions: null, binary: false }],
3235
3362
  signal: scoreDocSection(file.path, chunk2.heading, chunk2.text),
3363
+ provenance: "inferred",
3364
+ // a written claim, and the kind of content most likely to go stale
3236
3365
  meta: {
3237
3366
  path: file.path,
3238
3367
  heading: chunk2.heading,
@@ -3394,6 +3523,8 @@ ${summary.body}`;
3394
3523
  files: extractMentionedFiles(session.turns.map((t) => `${t.userText}
3395
3524
  ${t.assistantText}`).join("\n")),
3396
3525
  signal: scoreSession(session.turns.length),
3526
+ provenance: "inferred",
3527
+ // a model's distillation, not a directly observed event
3397
3528
  meta: {
3398
3529
  sessionKey: session.sessionKey,
3399
3530
  source: session.source,
@@ -3502,6 +3633,7 @@ function toMemoryNode2(entry, projectId, opts = {}) {
3502
3633
  body: renderBody(entry, maxBody),
3503
3634
  files: [],
3504
3635
  signal: scoreShellCommand(entry),
3636
+ provenance: "observed",
3505
3637
  meta: {
3506
3638
  command: entry.command,
3507
3639
  cwd: entry.cwd,
@@ -3809,8 +3941,8 @@ function hookEntryToRaw(e) {
3809
3941
  }
3810
3942
  async function tryReadScrapeSource(path, parse, tailLines) {
3811
3943
  if (!existsSync4(path)) return null;
3812
- const [raw, stats] = await Promise.all([readFile9(path, "utf8"), stat2(path)]);
3813
- return parse(raw, stats.mtimeMs, { tailLines });
3944
+ const [raw, stats2] = await Promise.all([readFile9(path, "utf8"), stat2(path)]);
3945
+ return parse(raw, stats2.mtimeMs, { tailLines });
3814
3946
  }
3815
3947
  async function collectAvailableShellHistory(opts = {}) {
3816
3948
  const results = [];
@@ -4144,25 +4276,25 @@ function addStats(into, from) {
4144
4276
  async function syncGit(store, projectId, opts, repo, config, log) {
4145
4277
  const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
4146
4278
  if (!repo.head) {
4147
- log(`${pc6.yellow("git")} skipped -- repository has no commits yet`);
4279
+ log(`${pc7.yellow("git")} skipped -- repository has no commits yet`);
4148
4280
  return { totals, seen: 0 };
4149
4281
  }
4150
4282
  if (!config.sources.git.enabled) {
4151
- log(`${pc6.dim("git")} disabled in config`);
4283
+ log(`${pc7.dim("git")} disabled in config`);
4152
4284
  return { totals, seen: 0 };
4153
4285
  }
4154
4286
  let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, GIT_SOURCE);
4155
4287
  if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
4156
- log(`${pc6.yellow("git cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a full walk`);
4288
+ log(`${pc7.yellow("git cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a full walk`);
4157
4289
  cursor = null;
4158
4290
  }
4159
4291
  if (cursor === repo.head) {
4160
- log(`${pc6.green("git up to date")} at ${repo.head.slice(0, 7)}`);
4292
+ log(`${pc7.green("git up to date")} at ${repo.head.slice(0, 7)}`);
4161
4293
  store.setSyncCursor(projectId, GIT_SOURCE, repo.head);
4162
4294
  return { totals, seen: 0 };
4163
4295
  }
4164
4296
  log(
4165
- `${pc6.dim("git syncing")} ${repo.branch ?? "HEAD"} ${cursor ? `${cursor.slice(0, 7)}..${repo.head.slice(0, 7)}` : "(full history)"}`
4297
+ `${pc7.dim("git syncing")} ${repo.branch ?? "HEAD"} ${cursor ? `${cursor.slice(0, 7)}..${repo.head.slice(0, 7)}` : "(full history)"}`
4166
4298
  );
4167
4299
  let batch = [];
4168
4300
  let seen = 0;
@@ -4170,7 +4302,7 @@ async function syncGit(store, projectId, opts, repo, config, log) {
4170
4302
  if (batch.length === 0) return;
4171
4303
  addStats(totals, store.upsertNodes(batch));
4172
4304
  batch = [];
4173
- log(` ${pc6.dim(`${seen} commits read, ${totals.inserted} new`)}`);
4305
+ log(` ${pc7.dim(`${seen} commits read, ${totals.inserted} new`)}`);
4174
4306
  };
4175
4307
  const nodes = collectGitCommits(repo.root, projectId, {
4176
4308
  afterCommit: cursor,
@@ -4192,12 +4324,12 @@ async function syncDiffs(store, projectId, opts, repo, config, log) {
4192
4324
  const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
4193
4325
  if (!repo.head) return { totals, seen: 0 };
4194
4326
  if (!config.sources.diff.enabled) {
4195
- log(`${pc6.dim("diff")} disabled in config`);
4327
+ log(`${pc7.dim("diff")} disabled in config`);
4196
4328
  return { totals, seen: 0 };
4197
4329
  }
4198
4330
  let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, DIFF_SOURCE);
4199
4331
  if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
4200
- log(`${pc6.yellow("diff cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a bounded walk`);
4332
+ log(`${pc7.yellow("diff cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a bounded walk`);
4201
4333
  cursor = null;
4202
4334
  }
4203
4335
  if (cursor === repo.head) {
@@ -4226,13 +4358,13 @@ async function syncDiffs(store, projectId, opts, repo, config, log) {
4226
4358
  }
4227
4359
  flush();
4228
4360
  store.setSyncCursor(projectId, DIFF_SOURCE, repo.head);
4229
- log(` ${pc6.dim(`${DIFF_SOURCE}: ${seen} file diff(s) read`)}`);
4361
+ log(` ${pc7.dim(`${DIFF_SOURCE}: ${seen} file diff(s) read`)}`);
4230
4362
  return { totals, seen };
4231
4363
  }
4232
4364
  async function syncShell(store, projectId, opts, repoRoot, config, log) {
4233
4365
  const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
4234
4366
  if (!config.sources.shell.enabled) {
4235
- log(`${pc6.dim("shell")} disabled in config`);
4367
+ log(`${pc7.dim("shell")} disabled in config`);
4236
4368
  return { totals, seen: 0 };
4237
4369
  }
4238
4370
  const results = await collectAvailableShellHistory({
@@ -4241,7 +4373,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
4241
4373
  hookCursor: store.getSyncCursor(projectId, "shell:pwsh-hook")
4242
4374
  });
4243
4375
  if (results.length === 0) {
4244
- log(`${pc6.dim("shell")} no history source found on this machine`);
4376
+ log(`${pc7.dim("shell")} no history source found on this machine`);
4245
4377
  return { totals, seen: 0 };
4246
4378
  }
4247
4379
  let seen = 0;
@@ -4253,7 +4385,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
4253
4385
  addStats(totals, store.upsertNodes(nodes));
4254
4386
  }
4255
4387
  store.setSyncCursor(projectId, sourceKey, result.cursorAfter ?? `scanned:${result.entries.length}`);
4256
- log(` ${pc6.dim(`${sourceKey}: ${nodes.length} entr${nodes.length === 1 ? "y" : "ies"} read`)}`);
4388
+ log(` ${pc7.dim(`${sourceKey}: ${nodes.length} entr${nodes.length === 1 ? "y" : "ies"} read`)}`);
4257
4389
  }
4258
4390
  return { totals, seen };
4259
4391
  }
@@ -4265,13 +4397,13 @@ function syncConversation(store, projectId, turns, config, log, forceEnabled) {
4265
4397
  return { totals, seen: 0 };
4266
4398
  }
4267
4399
  if (turns.length === 0) {
4268
- log(`${pc6.dim("conversation")} no transcripts found`);
4400
+ log(`${pc7.dim("conversation")} no transcripts found`);
4269
4401
  return { totals, seen: 0 };
4270
4402
  }
4271
4403
  const nodes = collectConversationTurns(turns, projectId, { maxBodyChars: config.limits.maxBodyChars });
4272
4404
  if (nodes.length > 0) addStats(totals, store.upsertNodes(nodes));
4273
4405
  store.setSyncCursor(projectId, CONVERSATION_SOURCE, `scanned:${nodes.length}`);
4274
- log(` ${pc6.dim(`${CONVERSATION_SOURCE}: ${nodes.length} of ${turns.length} exchange(s) kept`)}`);
4406
+ log(` ${pc7.dim(`${CONVERSATION_SOURCE}: ${nodes.length} of ${turns.length} exchange(s) kept`)}`);
4275
4407
  return { totals, seen: nodes.length };
4276
4408
  }
4277
4409
  var SESSION_SOURCE = "session:claude-code";
@@ -4280,7 +4412,7 @@ async function syncSessions(store, projectId, turns, config, log) {
4280
4412
  const settings = config.sources.session;
4281
4413
  if (!settings.enabled) return { totals, seen: 0 };
4282
4414
  if (turns.length === 0) {
4283
- log(`${pc6.dim("session")} no transcripts found`);
4415
+ log(`${pc7.dim("session")} no transcripts found`);
4284
4416
  return { totals, seen: 0 };
4285
4417
  }
4286
4418
  const result = await collectSessionSummaries(turns, projectId, new OllamaChatProvider({ model: settings.model }), {
@@ -4292,12 +4424,12 @@ async function syncSessions(store, projectId, turns, config, log) {
4292
4424
  const meta = store.getNodeMeta(makeNodeId(projectId, "session_summary", sessionKey));
4293
4425
  return typeof meta?.contentHash === "string" ? meta.contentHash : null;
4294
4426
  },
4295
- onProgress: (done, total) => log(` ${pc6.dim(`session: summarizing ${done}/${total}`)}`)
4427
+ onProgress: (done, total) => log(` ${pc7.dim(`session: summarizing ${done}/${total}`)}`)
4296
4428
  });
4297
4429
  if (result.nodes.length > 0) addStats(totals, store.upsertNodes(result.nodes));
4298
4430
  if (result.providerUnavailable) {
4299
4431
  log(
4300
- `${pc6.dim("session")} summarization model unavailable (is Ollama running with \`${settings.model}\` pulled?) -- skipped`
4432
+ `${pc7.dim("session")} summarization model unavailable (is Ollama running with \`${settings.model}\` pulled?) -- skipped`
4301
4433
  );
4302
4434
  } else {
4303
4435
  const parts = [`${result.nodes.length} summarized`];
@@ -4305,7 +4437,7 @@ async function syncSessions(store, projectId, turns, config, log) {
4305
4437
  if (result.deferred > 0) parts.push(`${result.deferred} queued for the next sync`);
4306
4438
  if (result.unsettled > 0) parts.push(`${result.unsettled} still active`);
4307
4439
  if (result.failed > 0) parts.push(`${result.failed} failed`);
4308
- log(` ${pc6.dim(`${SESSION_SOURCE}: ${parts.join(", ")}`)}`);
4440
+ log(` ${pc7.dim(`${SESSION_SOURCE}: ${parts.join(", ")}`)}`);
4309
4441
  }
4310
4442
  store.setSyncCursor(projectId, SESSION_SOURCE, `scanned:${result.nodes.length}`);
4311
4443
  return { totals, seen: result.nodes.length };
@@ -4314,7 +4446,7 @@ var DOCS_SOURCE = "docs";
4314
4446
  async function syncDocs(store, projectId, repoRoot, config, log) {
4315
4447
  const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
4316
4448
  if (!config.sources.docs.enabled) {
4317
- log(`${pc6.dim("docs")} disabled in config`);
4449
+ log(`${pc7.dim("docs")} disabled in config`);
4318
4450
  return { totals, seen: 0 };
4319
4451
  }
4320
4452
  const { files, unreadable } = await readDocFiles(repoRoot, { include: config.sources.docs.include });
@@ -4328,23 +4460,23 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
4328
4460
  );
4329
4461
  store.setSyncCursor(projectId, DOCS_SOURCE, `scanned:${nodes.length}`);
4330
4462
  if (files.length === 0 && unreadable.length === 0) {
4331
- log(`${pc6.dim("docs")} no tracked .md files found`);
4463
+ log(`${pc7.dim("docs")} no tracked .md files found`);
4332
4464
  } else {
4333
- const prunedPart = pruned > 0 ? `, ${pc6.yellow(`${pruned} stale removed`)}` : "";
4465
+ const prunedPart = pruned > 0 ? `, ${pc7.yellow(`${pruned} stale removed`)}` : "";
4334
4466
  const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (kept)` : "";
4335
- log(` ${pc6.dim(`${DOCS_SOURCE}: ${nodes.length} section(s) from ${files.length} file(s)`)}${prunedPart}${pc6.dim(skippedPart)}`);
4467
+ log(` ${pc7.dim(`${DOCS_SOURCE}: ${nodes.length} section(s) from ${files.length} file(s)`)}${prunedPart}${pc7.dim(skippedPart)}`);
4336
4468
  }
4337
4469
  return { totals, seen: nodes.length };
4338
4470
  }
4339
4471
  async function syncStructure(store, projectId, repoRoot, config, log) {
4340
4472
  if (!config.sources.structure.enabled) {
4341
- log(`${pc6.dim("structure")} disabled in config`);
4473
+ log(`${pc7.dim("structure")} disabled in config`);
4342
4474
  return { edges: 0, filesScanned: 0 };
4343
4475
  }
4344
4476
  const { edges, filesScanned, unreadable } = await collectFileEdges(repoRoot);
4345
4477
  store.replaceFileEdges(projectId, edges);
4346
4478
  const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (skipped)` : "";
4347
- log(` ${pc6.dim(`structure: ${edges.length} edge(s) from ${filesScanned} file(s)`)}${pc6.dim(skippedPart)}`);
4479
+ log(` ${pc7.dim(`structure: ${edges.length} edge(s) from ${filesScanned} file(s)`)}${pc7.dim(skippedPart)}`);
4348
4480
  return { edges: edges.length, filesScanned };
4349
4481
  }
4350
4482
  var STALE_SHELL_SOURCES = ["shell:pwsh", "shell:bash", "shell:zsh"];
@@ -4361,15 +4493,15 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
4361
4493
  const counts = sources.flatMap((source) => scopeIds.map((id) => ({ source, id, count: store.countSourceNodes(id, source) })));
4362
4494
  const total = counts.reduce((sum, c) => sum + c.count, 0);
4363
4495
  if (total === 0) {
4364
- out(`${pc6.dim("prune-source")} no node(s) match ${sources.join(", ")} -- nothing to do
4496
+ out(`${pc7.dim("prune-source")} no node(s) match ${sources.join(", ")} -- nothing to do
4365
4497
  `);
4366
4498
  return 0;
4367
4499
  }
4368
- const describe = (c) => ` ${pc6.dim(c.source)}${c.id !== projectId ? pc6.dim(` (prior identity ${c.id.slice(0, 8)})`) : ""}: ${c.count} node(s)`;
4500
+ const describe = (c) => ` ${pc7.dim(c.source)}${c.id !== projectId ? pc7.dim(` (prior identity ${c.id.slice(0, 8)})`) : ""}: ${c.count} node(s)`;
4369
4501
  if (!yes) {
4370
4502
  const lines = counts.filter((c) => c.count > 0).map(describe);
4371
4503
  out(
4372
- [`${pc6.yellow("would remove")} ${total} node(s):`, ...lines, pc6.dim("re-run with --yes to actually delete these -- this cannot be undone"), ""].join(
4504
+ [`${pc7.yellow("would remove")} ${total} node(s):`, ...lines, pc7.dim("re-run with --yes to actually delete these -- this cannot be undone"), ""].join(
4373
4505
  "\n"
4374
4506
  )
4375
4507
  );
@@ -4378,7 +4510,7 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
4378
4510
  let removed = 0;
4379
4511
  for (const { source, id } of counts) removed += store.pruneSourceNodes(id, source, []);
4380
4512
  const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
4381
- out(`${pc6.green("pruned")} ${removed} node(s) across ${sources.length} source(s)${identityPart}
4513
+ out(`${pc7.green("pruned")} ${removed} node(s) across ${sources.length} source(s)${identityPart}
4382
4514
  `);
4383
4515
  return 0;
4384
4516
  }
@@ -4395,7 +4527,7 @@ async function runSync(opts) {
4395
4527
  store.upsertProject({ id: projectId, root: repo.root, originUrl: repo.originUrl });
4396
4528
  if (opts.rebuild) {
4397
4529
  const removed = store.clearProject(projectId);
4398
- log(`${pc6.dim("rebuild")} dropped ${removed} existing node(s)`);
4530
+ log(`${pc7.dim("rebuild")} dropped ${removed} existing node(s)`);
4399
4531
  }
4400
4532
  const staleProjectIds = store.listOtherProjectIds(projectId);
4401
4533
  for (const staleId of staleProjectIds) {
@@ -4409,7 +4541,7 @@ async function runSync(opts) {
4409
4541
  ].filter((part) => part !== null);
4410
4542
  if (parts.length > 0) {
4411
4543
  log(
4412
- `${pc6.yellow("reconciled")} previous project identity ${pc6.dim(staleId)} (remote URL likely changed): ${parts.join(", ")}`
4544
+ `${pc7.yellow("reconciled")} previous project identity ${pc7.dim(staleId)} (remote URL likely changed): ${parts.join(", ")}`
4413
4545
  );
4414
4546
  }
4415
4547
  }
@@ -4439,26 +4571,26 @@ async function runSync(opts) {
4439
4571
  let lastLogged = 0;
4440
4572
  const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId, {
4441
4573
  maxNodes: opts.embedLimit,
4442
- onInvalidated: (count) => log(`${pc6.yellow("vector")} embedding model changed -- dropped ${count} vector(s), re-embedding from scratch`),
4574
+ onInvalidated: (count) => log(`${pc7.yellow("vector")} embedding model changed -- dropped ${count} vector(s), re-embedding from scratch`),
4443
4575
  onProgress: (attempted, total) => {
4444
4576
  if (total < PROGRESS_THRESHOLD || attempted - lastLogged < PROGRESS_EVERY) return;
4445
4577
  lastLogged = attempted;
4446
- log(` ${pc6.dim(`vector: ${attempted}/${total} embedded`)}`);
4578
+ log(` ${pc7.dim(`vector: ${attempted}/${total} embedded`)}`);
4447
4579
  }
4448
4580
  });
4449
4581
  if (result.embedded > 0) {
4450
- const skippedPart = result.skipped > 0 ? pc6.dim(`, ${result.skipped} skipped`) : "";
4451
- const remainingPart = result.remaining > 0 ? pc6.yellow(`, ${result.remaining} still pending`) : "";
4452
- embedLine = ` ${pc6.dim(`vector: ${result.embedded} node(s) embedded`)}${skippedPart}${remainingPart}
4582
+ const skippedPart = result.skipped > 0 ? pc7.dim(`, ${result.skipped} skipped`) : "";
4583
+ const remainingPart = result.remaining > 0 ? pc7.yellow(`, ${result.remaining} still pending`) : "";
4584
+ embedLine = ` ${pc7.dim(`vector: ${result.embedded} node(s) embedded`)}${skippedPart}${remainingPart}
4453
4585
  `;
4454
4586
  } else if (result.providerUnavailable) {
4455
- log(`${pc6.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
4587
+ log(`${pc7.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
4456
4588
  }
4457
4589
  }
4458
4590
  let linkLine = "";
4459
4591
  if (opts.linkFailures) {
4460
4592
  const linkStats = correlateFailures(store, projectId);
4461
- linkLine = ` ${pc6.dim(`chains: ${linkStats.failuresExamined} failure(s) examined, ${linkStats.linkedByRetry} linked by retry, ${linkStats.linkedByDiscussion} by discussion`)}
4593
+ linkLine = ` ${pc7.dim(`chains: ${linkStats.failuresExamined} failure(s) examined, ${linkStats.linkedByRetry} linked by retry, ${linkStats.linkedByDiscussion} by discussion`)}
4462
4594
  `;
4463
4595
  }
4464
4596
  store.markSynced(projectId);
@@ -4469,19 +4601,19 @@ async function runSync(opts) {
4469
4601
  addStats(totals, conversation.totals);
4470
4602
  addStats(totals, sessions.totals);
4471
4603
  addStats(totals, docs.totals);
4472
- const stats = store.stats(projectId);
4604
+ const stats2 = store.stats(projectId);
4473
4605
  const elapsed = ((Date.now() - started) / 1e3).toFixed(2);
4474
4606
  const conversationPart = conversationEnabled ? `, ${conversation.seen} conversation exchange(s)` : "";
4475
4607
  const sessionPart = config.sources.session.enabled ? `, ${sessions.seen} session summar${sessions.seen === 1 ? "y" : "ies"}` : "";
4476
4608
  const docsPart = config.sources.docs.enabled ? `, ${docs.seen} doc section(s)` : "";
4477
4609
  const diffPart = config.sources.diff.enabled ? `, ${diffs.seen} file diff(s)` : "";
4478
4610
  const structurePart = config.sources.structure.enabled ? `, ${structure.edges} import edge(s)` : "";
4479
- const deniedPart = totals.denied > 0 ? ` ${pc6.red(`-${totals.denied} denied`)}` : "";
4611
+ const deniedPart = totals.denied > 0 ? ` ${pc7.red(`-${totals.denied} denied`)}` : "";
4480
4612
  out(
4481
4613
  [
4482
- `${pc6.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${sessionPart}${docsPart}${structurePart} in ${elapsed}s`,
4483
- ` ${pc6.green(`+${totals.inserted} new`)} ${pc6.yellow(`~${totals.updated} updated`)} ${pc6.dim(`=${totals.unchanged} unchanged`)}${deniedPart}`,
4484
- ` ${pc6.dim(`${stats.total} node(s) total across ${stats.distinctFiles} file path(s)`)}`,
4614
+ `${pc7.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${sessionPart}${docsPart}${structurePart} in ${elapsed}s`,
4615
+ ` ${pc7.green(`+${totals.inserted} new`)} ${pc7.yellow(`~${totals.updated} updated`)} ${pc7.dim(`=${totals.unchanged} unchanged`)}${deniedPart}`,
4616
+ ` ${pc7.dim(`${stats2.total} node(s) total across ${stats2.distinctFiles} file path(s)`)}`,
4485
4617
  ""
4486
4618
  ].join("\n") + embedLine + linkLine
4487
4619
  );
@@ -4576,8 +4708,8 @@ async function getStatus(input) {
4576
4708
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
4577
4709
  const store = MemoryStore.open(ws.dbPath);
4578
4710
  try {
4579
- const stats = store.stats(projectId);
4580
- return { total: stats.total, byKind: stats.byKind, sources: store.listSyncState(projectId) };
4711
+ const stats2 = store.stats(projectId);
4712
+ return { total: stats2.total, byKind: stats2.byKind, sources: store.listSyncState(projectId) };
4581
4713
  } finally {
4582
4714
  store.close();
4583
4715
  }
@@ -4677,7 +4809,7 @@ async function runMcpServer() {
4677
4809
  }
4678
4810
 
4679
4811
  // src/cli/commands/precheck.ts
4680
- import pc7 from "picocolors";
4812
+ import pc8 from "picocolors";
4681
4813
 
4682
4814
  // src/correlate/precheck.ts
4683
4815
  var DEFAULT_RECENT_DAYS = 30;
@@ -4734,7 +4866,7 @@ async function runPrecheck(opts) {
4734
4866
  const { repo, ws, projectId } = await loadContext(opts.cwd);
4735
4867
  const targetFiles = opts.files ?? (opts.working ? await workingTreeFiles(repo.root) : await stagedFiles(repo.root));
4736
4868
  if (targetFiles.length === 0) {
4737
- if (!opts.quiet) out(`${pc7.dim("precheck")} no files to check
4869
+ if (!opts.quiet) out(`${pc8.dim("precheck")} no files to check
4738
4870
  `);
4739
4871
  return 0;
4740
4872
  }
@@ -4747,45 +4879,45 @@ async function runPrecheck(opts) {
4747
4879
  }
4748
4880
  const flagged = risks.filter((r) => r.unresolvedFailures.length > 0 || r.commitsRecent >= HIGH_CHURN_THRESHOLD);
4749
4881
  if (flagged.length === 0) {
4750
- if (!opts.quiet) out(`${pc7.green("precheck")} no warnings \u2014 looking good
4882
+ if (!opts.quiet) out(`${pc8.green("precheck")} no warnings \u2014 looking good
4751
4883
  `);
4752
4884
  return 0;
4753
4885
  }
4754
4886
  out(`
4755
- ${pc7.bold("nexusmem precheck")}
4756
- ${pc7.dim("-".repeat(40))}
4887
+ ${pc8.bold("nexusmem precheck")}
4888
+ ${pc8.dim("-".repeat(40))}
4757
4889
 
4758
4890
  `);
4759
4891
  for (const risk of flagged) {
4760
- out(` ${pc7.bold(risk.path)}
4892
+ out(` ${pc8.bold(risk.path)}
4761
4893
  `);
4762
4894
  if (risk.unresolvedFailures.length > 0) {
4763
- out(` ${pc7.yellow("WARN")} What already failed here (${risk.unresolvedFailures.length} unresolved):
4895
+ out(` ${pc8.yellow("WARN")} What already failed here (${risk.unresolvedFailures.length} unresolved):
4764
4896
  `);
4765
4897
  for (const f of risk.unresolvedFailures.slice(0, 3)) {
4766
- out(` ${pc7.dim(`${f.ts.slice(0, 10)} ${f.command.slice(0, 90)}`)}
4898
+ out(` ${pc8.dim(`${f.ts.slice(0, 10)} ${f.command.slice(0, 90)}`)}
4767
4899
  `);
4768
4900
  }
4769
4901
  if (risk.unresolvedFailures.length > 3) {
4770
- out(` ${pc7.dim(`... and ${risk.unresolvedFailures.length - 3} more`)}
4902
+ out(` ${pc8.dim(`... and ${risk.unresolvedFailures.length - 3} more`)}
4771
4903
  `);
4772
4904
  }
4773
4905
  }
4774
4906
  if (risk.commitsRecent >= HIGH_CHURN_THRESHOLD) {
4775
- out(` ${pc7.yellow("WARN")} high churn: ${risk.commitsRecent} commits touched this file recently
4907
+ out(` ${pc8.yellow("WARN")} high churn: ${risk.commitsRecent} commits touched this file recently
4776
4908
  `);
4777
4909
  }
4778
4910
  out("\n");
4779
4911
  }
4780
4912
  const failureCount = flagged.filter((r) => r.unresolvedFailures.length > 0).length;
4781
- out(`${pc7.dim(`${flagged.length} file(s) flagged, ${failureCount} with unresolved failures.`)}
4913
+ out(`${pc8.dim(`${flagged.length} file(s) flagged, ${failureCount} with unresolved failures.`)}
4782
4914
  `);
4783
4915
  if (opts.strict && failureCount > 0) return 1;
4784
4916
  return 0;
4785
4917
  }
4786
4918
 
4787
4919
  // src/cli/commands/query.ts
4788
- import pc8 from "picocolors";
4920
+ import pc9 from "picocolors";
4789
4921
  async function runQuery(opts) {
4790
4922
  const { repo, ws, projectId } = await loadContext(opts.cwd);
4791
4923
  const opened = opts.allProjects ? await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath }) : null;
@@ -4807,15 +4939,15 @@ async function runQuery(opts) {
4807
4939
  const { bm25Count, vectorCount, hits, packed } = result;
4808
4940
  if (opened && !opts.json) {
4809
4941
  const searched = opened.sources.map((s) => s.label).join(", ");
4810
- process.stderr.write(`${pc8.dim("scope ")} ${opened.sources.length} project(s): ${searched}
4942
+ process.stderr.write(`${pc9.dim("scope ")} ${opened.sources.length} project(s): ${searched}
4811
4943
  `);
4812
4944
  for (const { entry } of opened.unreadable) {
4813
- process.stderr.write(`${pc8.yellow("unreadable")} ${entry.root} -- skipped
4945
+ process.stderr.write(`${pc9.yellow("unreadable")} ${entry.root} -- skipped
4814
4946
  `);
4815
4947
  }
4816
4948
  if (opened.missing.length > 0) {
4817
4949
  process.stderr.write(
4818
- `${pc8.dim("skipped")} ${opened.missing.length} registered project(s) whose database is not on disk ${pc8.dim("(nexusmem projects --prune to forget them)")}
4950
+ `${pc9.dim("skipped")} ${opened.missing.length} registered project(s) whose database is not on disk ${pc9.dim("(nexusmem projects --prune to forget them)")}
4819
4951
  `
4820
4952
  );
4821
4953
  }
@@ -4845,15 +4977,15 @@ async function runQuery(opts) {
4845
4977
  return 0;
4846
4978
  }
4847
4979
  if (matched === 0) {
4848
- process.stderr.write(`${pc8.yellow("no matches")} for "${opts.query}"
4980
+ process.stderr.write(`${pc9.yellow("no matches")} for "${opts.query}"
4849
4981
  `);
4850
4982
  return 0;
4851
4983
  }
4852
4984
  process.stderr.write(
4853
4985
  [
4854
- `${pc8.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc8.bold(String(packed.nodes.length))} into budget`,
4855
- `${pc8.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc8.dim(` (${packed.droppedForBudget} dropped for budget)`) : "") + (packed.droppedForDiversity ? pc8.dim(` (${packed.droppedForDiversity} dropped for diversity)`) : ""),
4856
- rawTokens > 0 ? `${pc8.dim("vs raw ")} ${rawTokens} tokens if these same matches were sent unpacked ${packerEfficiency >= 0 ? pc8.green(`(${(packerEfficiency * 100).toFixed(0)}% packer efficiency)`) : pc8.yellow(`(${(-packerEfficiency * 100).toFixed(0)}% larger -- overhead dominates on small result sets)`)}` : "",
4986
+ `${pc9.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc9.bold(String(packed.nodes.length))} into budget`,
4987
+ `${pc9.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc9.dim(` (${packed.droppedForBudget} dropped for budget)`) : "") + (packed.droppedForDiversity ? pc9.dim(` (${packed.droppedForDiversity} dropped for diversity)`) : ""),
4988
+ rawTokens > 0 ? `${pc9.dim("vs raw ")} ${rawTokens} tokens if these same matches were sent unpacked ${packerEfficiency >= 0 ? pc9.green(`(${(packerEfficiency * 100).toFixed(0)}% packer efficiency)`) : pc9.yellow(`(${(-packerEfficiency * 100).toFixed(0)}% larger -- overhead dominates on small result sets)`)}` : "",
4857
4989
  ""
4858
4990
  ].filter(Boolean).join("\n")
4859
4991
  );
@@ -4867,10 +4999,10 @@ async function runQuery(opts) {
4867
4999
  }
4868
5000
 
4869
5001
  // src/cli/commands/scan-conversation.ts
4870
- import pc10 from "picocolors";
5002
+ import pc11 from "picocolors";
4871
5003
 
4872
5004
  // src/cli/format.ts
4873
- import pc9 from "picocolors";
5005
+ import pc10 from "picocolors";
4874
5006
  var GIT_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
4875
5007
  var SHELL_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
4876
5008
  var CONVERSATION_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
@@ -4882,13 +5014,33 @@ function signalBand(signal, bands) {
4882
5014
  return "low";
4883
5015
  }
4884
5016
  var BAND_COLOR = {
4885
- high: pc9.green,
4886
- medium: pc9.yellow,
4887
- low: pc9.dim
5017
+ high: pc10.green,
5018
+ medium: pc10.yellow,
5019
+ low: pc10.dim
4888
5020
  };
4889
5021
  function formatSignal(signal, bands) {
4890
5022
  return BAND_COLOR[signalBand(signal, bands)](signal.toFixed(2));
4891
5023
  }
5024
+ function approxTotalTokens(nodes) {
5025
+ return nodes.reduce((n, x) => n + approxTokens(x.body), 0);
5026
+ }
5027
+ function summarize2(nodes) {
5028
+ if (nodes.length === 0) return pc10.yellow("no commits matched");
5029
+ const timestamps = nodes.map((n) => n.ts).sort();
5030
+ const avgSignal = nodes.reduce((n, x) => n + x.signal, 0) / nodes.length;
5031
+ const totalTokens = approxTotalTokens(nodes);
5032
+ const fileHits = /* @__PURE__ */ new Map();
5033
+ for (const node of nodes) {
5034
+ for (const f of node.files) fileHits.set(f.path, (fileHits.get(f.path) ?? 0) + 1);
5035
+ }
5036
+ const hottest = [...fileHits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([path, count]) => ` ${String(count).padStart(3)}x ${path}`);
5037
+ return [
5038
+ `${pc10.bold(String(nodes.length))} nodes ${pc10.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
5039
+ ` avg signal ${avgSignal.toFixed(3)} ~${totalTokens.toLocaleString()} tokens if sent raw`,
5040
+ hottest.length ? ` hottest files:
5041
+ ${hottest.join("\n")}` : ""
5042
+ ].filter(Boolean).join("\n");
5043
+ }
4892
5044
 
4893
5045
  // src/cli/commands/scan-conversation.ts
4894
5046
  async function runScanConversation(opts) {
@@ -4897,9 +5049,9 @@ async function runScanConversation(opts) {
4897
5049
  const files = await listTranscriptFiles(repo.root);
4898
5050
  if (!opts.json) {
4899
5051
  process.stderr.write(
4900
- files.length ? `${pc10.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
5052
+ files.length ? `${pc11.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
4901
5053
 
4902
- ` : `${pc10.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
5054
+ ` : `${pc11.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
4903
5055
  `
4904
5056
  );
4905
5057
  }
@@ -4913,10 +5065,10 @@ async function runScanConversation(opts) {
4913
5065
  for (const node of nodes) process.stdout.write(`${formatNode(node)}
4914
5066
  `);
4915
5067
  const redactedTotal = nodes.reduce((n, x) => n + (Number(x.meta.redactedCount) || 0), 0);
4916
- const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
5068
+ const approxTotal = approxTotalTokens(nodes);
4917
5069
  process.stderr.write(
4918
5070
  `
4919
- ${pc10.bold(String(nodes.length))} of ${turns.length} exchange(s) above threshold ${pc10.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}` + (redactedTotal > 0 ? ` ${pc10.yellow(`${redactedTotal} secret-like value(s) redacted`)}` : "") + "\n"
5071
+ ${pc11.bold(String(nodes.length))} of ${turns.length} exchange(s) above threshold ${pc11.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}` + (redactedTotal > 0 ? ` ${pc11.yellow(`${redactedTotal} secret-like value(s) redacted`)}` : "") + "\n"
4920
5072
  );
4921
5073
  return 0;
4922
5074
  }
@@ -4926,77 +5078,6 @@ function formatNode(node) {
4926
5078
 
4927
5079
  // src/cli/commands/scan-diff.ts
4928
5080
  import pc12 from "picocolors";
4929
-
4930
- // src/cli/commands/scan-git.ts
4931
- import pc11 from "picocolors";
4932
- async function runScanGit(opts) {
4933
- const repo = await readRepoInfo(opts.cwd);
4934
- const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
4935
- if (!opts.json) {
4936
- process.stderr.write(
4937
- [
4938
- `${pc11.dim("repo ")} ${repo.root}`,
4939
- `${pc11.dim("branch ")} ${repo.branch ?? pc11.yellow("(detached)")}`,
4940
- `${pc11.dim("origin ")} ${repo.originUrl ?? pc11.dim("(none)")}`,
4941
- `${pc11.dim("project")} ${pc11.cyan(projectId)}`,
4942
- ""
4943
- ].join("\n")
4944
- );
4945
- }
4946
- const nodes = [];
4947
- const collectOpts = {
4948
- since: opts.since ?? null,
4949
- maxCount: opts.limit ?? null,
4950
- includeMerges: opts.merges
4951
- };
4952
- for await (const node of collectGitCommits(repo.root, projectId, collectOpts)) {
4953
- if (node.signal < opts.minSignal) continue;
4954
- nodes.push(node);
4955
- if (!opts.json) process.stdout.write(`${formatNode2(node)}
4956
- `);
4957
- }
4958
- if (opts.json) {
4959
- process.stdout.write(`${JSON.stringify(nodes, null, 2)}
4960
- `);
4961
- return 0;
4962
- }
4963
- process.stderr.write(`
4964
- ${summarize2(nodes)}
4965
- `);
4966
- return 0;
4967
- }
4968
- function formatNode2(node) {
4969
- const sha = String(node.meta.shortSha ?? "").padEnd(9);
4970
- const date = node.ts.slice(0, 10);
4971
- const files = Number(node.meta.filesChanged ?? 0);
4972
- const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
4973
- return [
4974
- formatSignal(node.signal, GIT_SIGNAL_BANDS),
4975
- pc11.dim(date),
4976
- pc11.magenta(sha),
4977
- node.title,
4978
- pc11.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
4979
- ].join(" ");
4980
- }
4981
- function summarize2(nodes) {
4982
- if (nodes.length === 0) return pc11.yellow("no commits matched");
4983
- const timestamps = nodes.map((n) => n.ts).sort();
4984
- const avgSignal = nodes.reduce((n, x) => n + x.signal, 0) / nodes.length;
4985
- const totalTokens = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
4986
- const fileHits = /* @__PURE__ */ new Map();
4987
- for (const node of nodes) {
4988
- for (const f of node.files) fileHits.set(f.path, (fileHits.get(f.path) ?? 0) + 1);
4989
- }
4990
- const hottest = [...fileHits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([path, count]) => ` ${String(count).padStart(3)}x ${path}`);
4991
- return [
4992
- `${pc11.bold(String(nodes.length))} nodes ${pc11.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
4993
- ` avg signal ${avgSignal.toFixed(3)} ~${totalTokens.toLocaleString()} tokens if sent raw`,
4994
- hottest.length ? ` hottest files:
4995
- ${hottest.join("\n")}` : ""
4996
- ].filter(Boolean).join("\n");
4997
- }
4998
-
4999
- // src/cli/commands/scan-diff.ts
5000
5081
  var DEFAULT_SCAN_COMMITS = 50;
5001
5082
  async function runScanDiff(opts) {
5002
5083
  const repo = await readRepoInfo(opts.cwd);
@@ -5018,7 +5099,7 @@ async function runScanDiff(opts) {
5018
5099
  })) {
5019
5100
  if (node.signal < opts.minSignal) continue;
5020
5101
  nodes.push(node);
5021
- if (!opts.json) process.stdout.write(`${formatNode3(node)}
5102
+ if (!opts.json) process.stdout.write(`${formatNode2(node)}
5022
5103
  `);
5023
5104
  }
5024
5105
  if (opts.json) {
@@ -5031,7 +5112,7 @@ ${summarize2(nodes)}
5031
5112
  `);
5032
5113
  return 0;
5033
5114
  }
5034
- function formatNode3(node) {
5115
+ function formatNode2(node) {
5035
5116
  const sha = String(node.meta.shortSha ?? "").padEnd(9);
5036
5117
  const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
5037
5118
  return [
@@ -5068,9 +5149,9 @@ async function runScanDocs(opts) {
5068
5149
  `);
5069
5150
  return 0;
5070
5151
  }
5071
- for (const node of nodes) process.stdout.write(`${formatNode4(node)}
5152
+ for (const node of nodes) process.stdout.write(`${formatNode3(node)}
5072
5153
  `);
5073
- const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
5154
+ const approxTotal = approxTotalTokens(nodes);
5074
5155
  process.stderr.write(
5075
5156
  `
5076
5157
  ${pc13.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc13.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
@@ -5078,18 +5159,70 @@ ${pc13.bold(String(nodes.length))} section(s) from ${files.length} file(s) above
5078
5159
  );
5079
5160
  return 0;
5080
5161
  }
5081
- function formatNode4(node) {
5162
+ function formatNode3(node) {
5082
5163
  return [formatSignal(node.signal, DOCS_SIGNAL_BANDS), node.title].join(" ");
5083
5164
  }
5084
5165
 
5085
- // src/cli/commands/scan-session.ts
5166
+ // src/cli/commands/scan-git.ts
5086
5167
  import pc14 from "picocolors";
5168
+ async function runScanGit(opts) {
5169
+ const repo = await readRepoInfo(opts.cwd);
5170
+ const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5171
+ if (!opts.json) {
5172
+ process.stderr.write(
5173
+ [
5174
+ `${pc14.dim("repo ")} ${repo.root}`,
5175
+ `${pc14.dim("branch ")} ${repo.branch ?? pc14.yellow("(detached)")}`,
5176
+ `${pc14.dim("origin ")} ${repo.originUrl ?? pc14.dim("(none)")}`,
5177
+ `${pc14.dim("project")} ${pc14.cyan(projectId)}`,
5178
+ ""
5179
+ ].join("\n")
5180
+ );
5181
+ }
5182
+ const nodes = [];
5183
+ const collectOpts = {
5184
+ since: opts.since ?? null,
5185
+ maxCount: opts.limit ?? null,
5186
+ includeMerges: opts.merges
5187
+ };
5188
+ for await (const node of collectGitCommits(repo.root, projectId, collectOpts)) {
5189
+ if (node.signal < opts.minSignal) continue;
5190
+ nodes.push(node);
5191
+ if (!opts.json) process.stdout.write(`${formatNode4(node)}
5192
+ `);
5193
+ }
5194
+ if (opts.json) {
5195
+ process.stdout.write(`${JSON.stringify(nodes, null, 2)}
5196
+ `);
5197
+ return 0;
5198
+ }
5199
+ process.stderr.write(`
5200
+ ${summarize2(nodes)}
5201
+ `);
5202
+ return 0;
5203
+ }
5204
+ function formatNode4(node) {
5205
+ const sha = String(node.meta.shortSha ?? "").padEnd(9);
5206
+ const date = node.ts.slice(0, 10);
5207
+ const files = Number(node.meta.filesChanged ?? 0);
5208
+ const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
5209
+ return [
5210
+ formatSignal(node.signal, GIT_SIGNAL_BANDS),
5211
+ pc14.dim(date),
5212
+ pc14.magenta(sha),
5213
+ node.title,
5214
+ pc14.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
5215
+ ].join(" ");
5216
+ }
5217
+
5218
+ // src/cli/commands/scan-session.ts
5219
+ import pc15 from "picocolors";
5087
5220
  async function runScanSession(opts) {
5088
5221
  const repo = await readRepoInfo(opts.cwd);
5089
5222
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5090
5223
  const turns = await collectClaudeCodeTranscripts(repo.root);
5091
5224
  if (turns.length === 0) {
5092
- process.stderr.write(`${pc14.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
5225
+ process.stderr.write(`${pc15.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
5093
5226
  `);
5094
5227
  return 0;
5095
5228
  }
@@ -5097,7 +5230,7 @@ async function runScanSession(opts) {
5097
5230
  const settled = selectSettledSessions(sessions, opts.settleMinutes);
5098
5231
  if (!opts.json) {
5099
5232
  process.stderr.write(
5100
- `${pc14.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
5233
+ `${pc15.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
5101
5234
 
5102
5235
  `
5103
5236
  );
@@ -5123,7 +5256,7 @@ async function runScanSession(opts) {
5123
5256
  }
5124
5257
  for (const preview of previews) {
5125
5258
  process.stdout.write(
5126
- `${pc14.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
5259
+ `${pc15.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
5127
5260
  ${preview.prompt}
5128
5261
 
5129
5262
  `
@@ -5135,7 +5268,7 @@ ${preview.prompt}
5135
5268
  settleMinutes: opts.settleMinutes,
5136
5269
  maxSessions: opts.maxSessions,
5137
5270
  onProgress: (done, total) => {
5138
- if (!opts.json) process.stderr.write(` ${pc14.dim(`summarizing ${done}/${total}`)}
5271
+ if (!opts.json) process.stderr.write(` ${pc15.dim(`summarizing ${done}/${total}`)}
5139
5272
  `);
5140
5273
  }
5141
5274
  });
@@ -5145,21 +5278,21 @@ ${preview.prompt}
5145
5278
  return 0;
5146
5279
  }
5147
5280
  for (const node of result.nodes) {
5148
- process.stdout.write(`${pc14.bold(node.title)}
5149
- ${pc14.dim(node.ts.slice(0, 16).replace("T", " "))}
5281
+ process.stdout.write(`${pc15.bold(node.title)}
5282
+ ${pc15.dim(node.ts.slice(0, 16).replace("T", " "))}
5150
5283
  ${node.body}
5151
5284
 
5152
5285
  `);
5153
5286
  }
5154
5287
  if (result.providerUnavailable) {
5155
5288
  process.stderr.write(
5156
- `${pc14.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
5289
+ `${pc15.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
5157
5290
  `
5158
5291
  );
5159
5292
  return 0;
5160
5293
  }
5161
5294
  process.stderr.write(
5162
- `${pc14.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc14.yellow(`${result.failed} failed`)}` : "") + ` ${pc14.dim(`(model ${opts.model})`)}
5295
+ `${pc15.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc15.yellow(`${result.failed} failed`)}` : "") + ` ${pc15.dim(`(model ${opts.model})`)}
5163
5296
  `
5164
5297
  );
5165
5298
  return 0;
@@ -5167,16 +5300,16 @@ ${node.body}
5167
5300
  var SCAN_SESSION_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
5168
5301
 
5169
5302
  // src/cli/commands/scan-shell.ts
5170
- import pc15 from "picocolors";
5303
+ import pc16 from "picocolors";
5171
5304
  async function runScanShell(opts) {
5172
5305
  const repo = await readRepoInfo(opts.cwd);
5173
5306
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5174
5307
  const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
5175
5308
  if (!opts.json) {
5176
5309
  process.stderr.write(
5177
- results.length ? `${pc15.dim("sources found")} ${results.map((r) => r.name).join(", ")}
5310
+ results.length ? `${pc16.dim("sources found")} ${results.map((r) => r.name).join(", ")}
5178
5311
 
5179
- ` : `${pc15.yellow("no shell history source found on this machine")}
5312
+ ` : `${pc16.yellow("no shell history source found on this machine")}
5180
5313
  `
5181
5314
  );
5182
5315
  }
@@ -5185,7 +5318,7 @@ async function runScanShell(opts) {
5185
5318
  const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
5186
5319
  allNodes.push(...nodes);
5187
5320
  if (!opts.json) {
5188
- process.stdout.write(`${pc15.bold(`shell:${result.name}`)} ${pc15.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
5321
+ process.stdout.write(`${pc16.bold(`shell:${result.name}`)} ${pc16.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
5189
5322
  `);
5190
5323
  for (const node of nodes) process.stdout.write(`${formatNode5(node)}
5191
5324
  `);
@@ -5197,20 +5330,20 @@ async function runScanShell(opts) {
5197
5330
  `);
5198
5331
  return 0;
5199
5332
  }
5200
- const approxTotal = allNodes.reduce((n, x) => n + approxTokens(x.body), 0);
5201
- process.stderr.write(`${pc15.bold(String(allNodes.length))} node(s) total ${pc15.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
5333
+ const approxTotal = approxTotalTokens(allNodes);
5334
+ process.stderr.write(`${pc16.bold(String(allNodes.length))} node(s) total ${pc16.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
5202
5335
  `);
5203
5336
  return 0;
5204
5337
  }
5205
5338
  function formatNode5(node) {
5206
- const approx = node.meta.tsApprox ? pc15.dim("~") : " ";
5339
+ const approx = node.meta.tsApprox ? pc16.dim("~") : " ";
5207
5340
  const exit = node.meta.exitCode;
5208
- const exitLabel = typeof exit === "number" && exit !== 0 ? pc15.red(`exit ${exit}`) : "";
5341
+ const exitLabel = typeof exit === "number" && exit !== 0 ? pc16.red(`exit ${exit}`) : "";
5209
5342
  return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
5210
5343
  }
5211
5344
 
5212
5345
  // src/cli/commands/scan-structure.ts
5213
- import pc16 from "picocolors";
5346
+ import pc17 from "picocolors";
5214
5347
  async function runScanStructure(opts) {
5215
5348
  const repo = await readRepoInfo(opts.cwd);
5216
5349
  const { edges, filesScanned, unreadable } = await collectFileEdges(repo.root);
@@ -5220,17 +5353,17 @@ async function runScanStructure(opts) {
5220
5353
  return 0;
5221
5354
  }
5222
5355
  if (unreadable.length > 0) {
5223
- process.stderr.write(`${pc16.yellow("unreadable")} ${unreadable.join(", ")}
5356
+ process.stderr.write(`${pc17.yellow("unreadable")} ${unreadable.join(", ")}
5224
5357
 
5225
5358
  `);
5226
5359
  }
5227
5360
  for (const edge of edges) {
5228
- process.stdout.write(`${edge.fromPath} ${pc16.dim("->")} ${edge.toPath}
5361
+ process.stdout.write(`${edge.fromPath} ${pc17.dim("->")} ${edge.toPath}
5229
5362
  `);
5230
5363
  }
5231
5364
  process.stderr.write(
5232
5365
  `
5233
- ${pc16.bold(String(edges.length))} edge(s) from ${filesScanned} tracked .ts/.tsx/.js/.jsx file(s)
5366
+ ${pc17.bold(String(edges.length))} edge(s) from ${filesScanned} tracked .ts/.tsx/.js/.jsx file(s)
5234
5367
  `
5235
5368
  );
5236
5369
  return 0;
@@ -5238,7 +5371,7 @@ ${pc16.bold(String(edges.length))} edge(s) from ${filesScanned} tracked .ts/.tsx
5238
5371
 
5239
5372
  // src/cli/commands/status.ts
5240
5373
  import { statSync } from "fs";
5241
- import pc17 from "picocolors";
5374
+ import pc18 from "picocolors";
5242
5375
  function humanBytes(bytes) {
5243
5376
  if (bytes < 1024) return `${bytes} B`;
5244
5377
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -5256,7 +5389,7 @@ async function runStatus(opts) {
5256
5389
  const { repo, ws, projectId } = await loadContext(opts.cwd);
5257
5390
  const store = MemoryStore.open(ws.dbPath);
5258
5391
  try {
5259
- const stats = store.stats(projectId);
5392
+ const stats2 = store.stats(projectId);
5260
5393
  const sources = store.listSyncState(projectId);
5261
5394
  const gitCursor = sources.find((s) => s.source === "git")?.cursor ?? null;
5262
5395
  const schema = currentSchemaVersion(store.raw);
@@ -5265,33 +5398,33 @@ async function runStatus(opts) {
5265
5398
  const otherProjectNodes = store.countProjectNodes(otherProjectIds);
5266
5399
  const structure = store.fileEdgeStats(projectId);
5267
5400
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
5268
- const kinds = Object.entries(stats.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
5269
- const staleProjectWarning = otherProjectIds.length ? `${pc17.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc17.bold(
5401
+ const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
5402
+ const staleProjectWarning = otherProjectIds.length ? `${pc18.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc18.bold(
5270
5403
  "nexusmem sync --prune-source <name>"
5271
5404
  )} to remove stale source data` : "";
5272
5405
  out(
5273
5406
  [
5274
- `${pc17.dim("repo ")} ${repo.root}`,
5275
- `${pc17.dim("branch ")} ${repo.branch ?? pc17.yellow("(detached)")}`,
5276
- `${pc17.dim("project ")} ${pc17.cyan(projectId)}`,
5277
- `${pc17.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc17.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
5278
- `${pc17.dim("database")} ${ws.dbPath} ${pc17.dim(`(${humanBytes(dbBytes)})`)}`,
5407
+ `${pc18.dim("repo ")} ${repo.root}`,
5408
+ `${pc18.dim("branch ")} ${repo.branch ?? pc18.yellow("(detached)")}`,
5409
+ `${pc18.dim("project ")} ${pc18.cyan(projectId)}`,
5410
+ `${pc18.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc18.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
5411
+ `${pc18.dim("database")} ${ws.dbPath} ${pc18.dim(`(${humanBytes(dbBytes)})`)}`,
5279
5412
  staleProjectWarning,
5280
5413
  "",
5281
- `${pc17.bold(String(stats.total))} node(s)${stats.total ? ` ${pc17.dim(`${stats.oldest?.slice(0, 10)} .. ${stats.newest?.slice(0, 10)}`)}` : ""}`,
5414
+ `${pc18.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc18.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
5282
5415
  ...kinds,
5283
- stats.total ? ` ${pc17.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
5416
+ stats2.total ? ` ${pc18.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
5284
5417
  "",
5285
- sources.length ? pc17.dim("sources") : pc17.yellow("no sources synced yet"),
5418
+ sources.length ? pc18.dim("sources") : pc18.yellow("no sources synced yet"),
5286
5419
  ...sources.map((s) => {
5287
5420
  const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
5288
5421
  const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
5289
- return ` ${s.source.padEnd(14)} ${pc17.dim(`last run ${when}`)} ${pc17.dim(`cursor ${cursorLabel}`)}`;
5422
+ return ` ${s.source.padEnd(14)} ${pc18.dim(`last run ${when}`)} ${pc18.dim(`cursor ${cursorLabel}`)}`;
5290
5423
  }),
5291
- gitCursor && gitCursor !== repo.head ? `${pc17.yellow("git behind HEAD")} \u2014 run ${pc17.bold("nexusmem sync")}` : "",
5292
5424
  "",
5293
- chains.failuresTotal ? `${pc17.dim("chains ")} ${pc17.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc17.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc17.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
5294
- structure.edges ? `${pc17.dim("structure")} ${pc17.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : ""
5425
+ gitCursor && gitCursor !== repo.head ? `${pc18.yellow("git behind HEAD")} \u2014 run ${pc18.bold("nexusmem sync")}` : "",
5426
+ chains.failuresTotal ? `${pc18.dim("chains ")} ${pc18.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc18.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc18.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
5427
+ structure.edges ? `${pc18.dim("structure")} ${pc18.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : ""
5295
5428
  ].filter((line) => line !== "").join("\n").concat("\n")
5296
5429
  );
5297
5430
  return 0;
@@ -5306,7 +5439,7 @@ function isExpected(err) {
5306
5439
  // the user fixes, not stack traces they debug.
5307
5440
  err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
5308
5441
  // (antivirus, a bad install). Actionable, and not our stack to print.
5309
- err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof DenyListError;
5442
+ err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof DenyListError || err instanceof MarkStaleError;
5310
5443
  }
5311
5444
  function guard(run) {
5312
5445
  return async () => {
@@ -5314,7 +5447,7 @@ function guard(run) {
5314
5447
  process.exitCode = await run();
5315
5448
  } catch (err) {
5316
5449
  if (isExpected(err)) {
5317
- process.stderr.write(`${pc18.red("error")} ${err.message}
5450
+ process.stderr.write(`${pc19.red("error")} ${err.message}
5318
5451
  `);
5319
5452
  process.exitCode = 1;
5320
5453
  return;
@@ -5408,6 +5541,11 @@ program.command("forget").description(
5408
5541
  })
5409
5542
  )()
5410
5543
  );
5544
+ program.command("mark-stale").description(
5545
+ "Mark a node as superseded by another -- the ranker down-weights it (never deletes it) so its replacement usually outranks it"
5546
+ ).argument("<nodeId>", "id of the node to mark stale").requiredOption("--supersedes <newNodeId>", "id of the node that supersedes it").option("-C, --cwd <path>", "repository path", process.cwd()).action(
5547
+ (nodeId, options) => guard(() => runMarkStale({ cwd: options.cwd, nodeId, supersedesId: options.supersedes }))()
5548
+ );
5411
5549
  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 }))());
5412
5550
  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(
5413
5551
  (options) => guard(
@@ -5468,7 +5606,7 @@ program.command("scan-structure").description("Preview the JS/TS import-graph ed
5468
5606
  program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
5469
5607
  program.parseAsync(process.argv).catch((err) => {
5470
5608
  const message = err instanceof Error ? err.message : String(err);
5471
- process.stderr.write(`${pc18.red("error")} ${message}
5609
+ process.stderr.write(`${pc19.red("error")} ${message}
5472
5610
  `);
5473
5611
  process.exitCode = 1;
5474
5612
  });