nexusmem 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli/index.ts
4
4
  import { Command } from "commander";
5
- import pc20 from "picocolors";
5
+ import pc21 from "picocolors";
6
6
 
7
7
  // src/config/workspace.ts
8
8
  import { existsSync } from "fs";
@@ -174,7 +174,20 @@ var ConfigSchema = z.object({
174
174
  limits: z.object({
175
175
  maxFilesPerNode: z.number().int().positive().default(40),
176
176
  maxBodyChars: z.number().int().positive().default(4e3)
177
- }).default({ maxFilesPerNode: 40, maxBodyChars: 4e3 })
177
+ }).default({ maxFilesPerNode: 40, maxBodyChars: 4e3 }),
178
+ /**
179
+ * Automatic contradiction checking during sync. On by default -- unlike the
180
+ * opt-in transcript sources, this reads nothing new, writes only suggestions
181
+ * (never `supersedes`), and stays affordable by construction: at most
182
+ * `maxPerSync` new SLM judgments per run, judged pairs memoized and never
183
+ * re-asked, and an unreachable model degrades to skipping quietly.
184
+ */
185
+ contradictions: z.object({
186
+ autoCheck: z.boolean().default(true),
187
+ maxPerSync: z.number().int().nonnegative().default(3),
188
+ /** Ollama model tag. Must be pulled locally, same as `sources.session.model`. */
189
+ model: z.string().default(DEFAULT_SLM_MODEL)
190
+ }).default({ autoCheck: true, maxPerSync: 3, model: DEFAULT_SLM_MODEL })
178
191
  });
179
192
  function defaultConfig(projectId) {
180
193
  return ConfigSchema.parse({ version: 1, projectId });
@@ -277,6 +290,7 @@ function crashStatus(code, signal) {
277
290
  return null;
278
291
  }
279
292
  var TRANSIENT_SPAWN_CODES = /* @__PURE__ */ new Set(["EAGAIN", "EPERM", "EACCES", "EMFILE", "ENFILE", "ENOMEM", "EBUSY", "ETXTBSY"]);
293
+ var GIT_LAUNCH_FAILURE = /error launching git/i;
280
294
  function toSpawnError(err, cwd, args) {
281
295
  const code = err?.code;
282
296
  if (code === "ENOENT") {
@@ -353,6 +367,15 @@ async function* runGitOnce(cwd, args, opts) {
353
367
  }
354
368
  if (code !== 0) {
355
369
  const trimmed = stderr.trim();
370
+ if (GIT_LAUNCH_FAILURE.test(trimmed)) {
371
+ throw new GitSpawnError(
372
+ `Could not start git (${trimmed.split("\n")[0]}) in ${cwd}. This is usually transient on Windows -- retrying the same command often succeeds.`,
373
+ fullArgs,
374
+ void 0,
375
+ true,
376
+ null
377
+ );
378
+ }
356
379
  const detail = trimmed.split("\n")[0];
357
380
  throw new GitError(
358
381
  `git ${args.join(" ")} exited with code ${code}${detail ? `: ${detail}` : ""}`,
@@ -478,6 +501,9 @@ function renderHookSnippet(logPath) {
478
501
  "$global:__ssd_last_history_id = -1",
479
502
  `$global:__ssd_log_path = ${toPowerShellLiteral(logPath)}`,
480
503
  "function global:prompt {",
504
+ // Must be first: Get-History (or anything else) below would overwrite $?.
505
+ " $__ssd_ok = $?",
506
+ " $__ssd_exit = $LASTEXITCODE",
481
507
  " $__ssd_h = Get-History -Count 1 -ErrorAction SilentlyContinue",
482
508
  " if ($__ssd_h -and $__ssd_h.Id -ne $global:__ssd_last_history_id) {",
483
509
  " $global:__ssd_last_history_id = $__ssd_h.Id",
@@ -485,7 +511,8 @@ function renderHookSnippet(logPath) {
485
511
  " $__ssd_entry = [ordered]@{",
486
512
  ' ts = (Get-Date).ToString("o")',
487
513
  " cwd = (Get-Location).Path",
488
- " exitCode = $LASTEXITCODE",
514
+ // $LASTEXITCODE alone misses cmdlet failures and goes stale after them; $? catches both.
515
+ " exitCode = if ($__ssd_ok) { 0 } elseif ($__ssd_exit) { $__ssd_exit } else { 1 }",
489
516
  " durationMs = [int](($__ssd_h.EndExecutionTime - $__ssd_h.StartExecutionTime).TotalMilliseconds)",
490
517
  " command = $__ssd_h.CommandLine",
491
518
  " }",
@@ -944,13 +971,41 @@ UPDATE nodes SET provenance = 'observed' WHERE kind IN ('git_commit', 'code_diff
944
971
 
945
972
  CREATE INDEX idx_nodes_supersedes ON nodes (supersedes) WHERE supersedes IS NOT NULL;
946
973
  `;
974
+ var V7 = `
975
+ UPDATE nodes SET provenance = 'authored' WHERE kind IN ('doc_section', 'note');
976
+ UPDATE nodes SET provenance = 'recorded' WHERE kind = 'conversation_turn';
977
+ UPDATE nodes SET provenance = 'derived' WHERE kind = 'session_summary';
978
+ UPDATE nodes SET provenance = 'recorded' WHERE provenance = 'inferred';
979
+ `;
980
+ var V8 = `
981
+ CREATE TABLE contradiction_checks (
982
+ candidate_id TEXT NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
983
+ against_id TEXT NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
984
+ contradicts INTEGER NOT NULL,
985
+ reason TEXT,
986
+ model TEXT NOT NULL,
987
+ checked_at INTEGER NOT NULL,
988
+ PRIMARY KEY (candidate_id, against_id)
989
+ );
990
+ `;
991
+ var V9 = `
992
+ ALTER TABLE contradiction_checks ADD COLUMN dismissed INTEGER NOT NULL DEFAULT 0;
993
+ `;
994
+ var V10 = `
995
+ ALTER TABLE nodes ADD COLUMN trust_state TEXT NOT NULL DEFAULT 'candidate';
996
+ CREATE INDEX idx_nodes_trust_state ON nodes (project_id, trust_state) WHERE trust_state != 'candidate';
997
+ `;
947
998
  var MIGRATIONS = [
948
999
  { version: 1, up: (db) => db.exec(V1) },
949
1000
  { version: 2, up: (db) => db.exec(V2) },
950
1001
  { version: 3, up: (db) => db.exec(V3) },
951
1002
  { version: 4, up: (db) => db.exec(V4) },
952
1003
  { version: 5, up: (db) => db.exec(V5) },
953
- { version: 6, up: (db) => db.exec(V6) }
1004
+ { version: 6, up: (db) => db.exec(V6) },
1005
+ { version: 7, up: (db) => db.exec(V7) },
1006
+ { version: 8, up: (db) => db.exec(V8) },
1007
+ { version: 9, up: (db) => db.exec(V9) },
1008
+ { version: 10, up: (db) => db.exec(V10) }
954
1009
  ];
955
1010
  var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
956
1011
  function currentSchemaVersion(db) {
@@ -968,6 +1023,36 @@ function migrate(db) {
968
1023
  return { from, to: currentSchemaVersion(db) };
969
1024
  }
970
1025
 
1026
+ // src/store/audit.ts
1027
+ function recordMutationAudit(db, input) {
1028
+ return Number(
1029
+ db.prepare(
1030
+ `INSERT INTO mutation_audit (action, project_id, detail, affected_count, succeeded, error, started_at, finished_at)
1031
+ VALUES (@action, @projectId, @detail, @affectedCount, @succeeded, @error, @startedAt, @finishedAt)`
1032
+ ).run({
1033
+ action: input.action,
1034
+ projectId: input.projectId,
1035
+ detail: JSON.stringify(input.detail),
1036
+ affectedCount: input.affectedCount,
1037
+ succeeded: input.succeeded ? 1 : 0,
1038
+ error: input.error ?? null,
1039
+ startedAt: input.startedAt,
1040
+ finishedAt: input.finishedAt
1041
+ }).lastInsertRowid
1042
+ );
1043
+ }
1044
+ function listMutationAudit(db, projectId, opts = {}) {
1045
+ const rows = db.prepare(
1046
+ `SELECT id, action, project_id AS projectId, detail, affected_count AS affectedCount,
1047
+ succeeded, error, started_at AS startedAt, finished_at AS finishedAt
1048
+ FROM mutation_audit
1049
+ WHERE project_id = @projectId
1050
+ ORDER BY started_at DESC
1051
+ LIMIT @limit`
1052
+ ).all({ projectId, limit: opts.limit ?? 50 });
1053
+ return rows.map((r) => ({ ...r, succeeded: r.succeeded === 1 }));
1054
+ }
1055
+
971
1056
  // src/store/projects.ts
972
1057
  function upsertProject(db, project) {
973
1058
  db.prepare(
@@ -1012,11 +1097,13 @@ function defaultProvenanceForKind(kind) {
1012
1097
  case "code_diff":
1013
1098
  case "shell_command":
1014
1099
  return "observed";
1015
- case "conversation_turn":
1016
- case "session_summary":
1017
1100
  case "doc_section":
1018
1101
  case "note":
1019
- return "inferred";
1102
+ return "authored";
1103
+ case "conversation_turn":
1104
+ return "recorded";
1105
+ case "session_summary":
1106
+ return "derived";
1020
1107
  }
1021
1108
  }
1022
1109
 
@@ -1118,7 +1205,7 @@ function clearProject(db, projectId) {
1118
1205
  function getNodesByIds(db, ids) {
1119
1206
  if (ids.length === 0) return [];
1120
1207
  return db.prepare(
1121
- `SELECT id, kind, project_id AS projectId, ts, title, body, signal, provenance
1208
+ `SELECT id, kind, project_id AS projectId, ts, title, body, signal, provenance, trust_state AS trustState
1122
1209
  FROM nodes WHERE id IN (SELECT value FROM json_each(?))`
1123
1210
  ).all(JSON.stringify(ids));
1124
1211
  }
@@ -1164,6 +1251,9 @@ function getSupersededIds(db, projectId) {
1164
1251
  function setSupersedes(db, newNodeId, staleNodeId) {
1165
1252
  db.prepare("UPDATE nodes SET supersedes = ? WHERE id = ?").run(staleNodeId, newNodeId);
1166
1253
  }
1254
+ function setTrustState(db, nodeId, state) {
1255
+ return db.prepare("UPDATE nodes SET trust_state = ? WHERE id = ?").run(state, nodeId).changes > 0;
1256
+ }
1167
1257
  function listStaleCandidates(db, projectId, opts = {}) {
1168
1258
  const now = opts.now ?? /* @__PURE__ */ new Date();
1169
1259
  const minAgeDays = opts.minAgeDays ?? 45;
@@ -1171,7 +1261,7 @@ function listStaleCandidates(db, projectId, opts = {}) {
1171
1261
  const rows = db.prepare(
1172
1262
  `SELECT id, kind, ts, ts_epoch AS tsEpoch, source, title
1173
1263
  FROM nodes
1174
- WHERE project_id = @projectId AND provenance = 'inferred' AND ts_epoch < @cutoff
1264
+ WHERE project_id = @projectId AND provenance != 'observed' AND ts_epoch < @cutoff
1175
1265
  AND id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)
1176
1266
  ORDER BY ts_epoch ASC
1177
1267
  LIMIT @limit`
@@ -1191,7 +1281,7 @@ function countStaleCandidates(db, projectId, opts = {}) {
1191
1281
  const cutoff = now.getTime() - minAgeDays * 864e5;
1192
1282
  const row = db.prepare(
1193
1283
  `SELECT COUNT(*) AS count FROM nodes
1194
- WHERE project_id = @projectId AND provenance = 'inferred' AND ts_epoch < @cutoff
1284
+ WHERE project_id = @projectId AND provenance != 'observed' AND ts_epoch < @cutoff
1195
1285
  AND id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)`
1196
1286
  ).get({ projectId, cutoff });
1197
1287
  return row.count;
@@ -1372,22 +1462,29 @@ function countNodesNeedingEmbedding(db, projectId) {
1372
1462
  ).get(projectId);
1373
1463
  return row.n;
1374
1464
  }
1465
+ function getEmbedding(db, nodeId) {
1466
+ const row = db.prepare("SELECT v.embedding AS embedding FROM nodes_vec v JOIN nodes n ON n.rowid = v.rowid WHERE n.id = ?").get(nodeId);
1467
+ if (!row) return null;
1468
+ return new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4);
1469
+ }
1375
1470
  function upsertEmbedding(db, rowid, embedding) {
1376
1471
  db.prepare("INSERT OR REPLACE INTO nodes_vec (rowid, embedding) VALUES (?, ?)").run(BigInt(rowid), embedding);
1377
1472
  }
1378
1473
  function dropAllEmbeddings(db) {
1379
1474
  return db.prepare("DELETE FROM nodes_vec").run().changes;
1380
1475
  }
1381
- function vectorSearch(db, projectId, embedding, limit = 20) {
1476
+ function vectorSearch(db, projectId, embedding, limit = 20, opts = {}) {
1382
1477
  const overfetch = Math.max(limit * 8, 50);
1478
+ const asOfEpoch = opts.asOfEpoch ?? null;
1383
1479
  return db.prepare(
1384
- `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance, v.distance AS distance
1480
+ `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance, n.trust_state AS trustState, v.distance AS distance
1385
1481
  FROM nodes_vec v
1386
1482
  JOIN nodes n ON n.rowid = v.rowid
1387
1483
  WHERE v.embedding MATCH ? AND k = ? AND n.project_id = ?
1484
+ AND (? IS NULL OR n.created_at <= ?)
1388
1485
  ORDER BY v.distance
1389
1486
  LIMIT ?`
1390
- ).all(embedding, overfetch, projectId, limit);
1487
+ ).all(embedding, overfetch, projectId, asOfEpoch, asOfEpoch, limit);
1391
1488
  }
1392
1489
 
1393
1490
  // src/store/fts.ts
@@ -1406,18 +1503,20 @@ function toMatchQuery(input) {
1406
1503
  }
1407
1504
 
1408
1505
  // src/store/search.ts
1409
- function search(db, projectId, query, limit = 20) {
1506
+ function search(db, projectId, query, limit = 20, opts = {}) {
1410
1507
  const match = toMatchQuery(query);
1411
1508
  if (!match) return [];
1509
+ const asOfEpoch = opts.asOfEpoch ?? null;
1412
1510
  const rows = db.prepare(
1413
- `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance,
1511
+ `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance, n.trust_state AS trustState,
1414
1512
  bm25(nodes_fts, 10.0, 1.0) AS rank
1415
1513
  FROM nodes_fts
1416
1514
  JOIN nodes n ON n.rowid = nodes_fts.rowid
1417
1515
  WHERE nodes_fts MATCH ? AND n.project_id = ?
1516
+ AND (? IS NULL OR n.created_at <= ?)
1418
1517
  ORDER BY rank
1419
1518
  LIMIT ?`
1420
- ).all(match, projectId, limit);
1519
+ ).all(match, projectId, asOfEpoch, asOfEpoch, limit);
1421
1520
  return rows;
1422
1521
  }
1423
1522
  function stats(db, projectId) {
@@ -1449,6 +1548,57 @@ function setMeta(db, key, value) {
1449
1548
  );
1450
1549
  }
1451
1550
 
1551
+ // src/store/contradictions.ts
1552
+ function recordContradictionCheck(db, input) {
1553
+ db.prepare(
1554
+ `INSERT OR REPLACE INTO contradiction_checks (candidate_id, against_id, contradicts, reason, model, checked_at)
1555
+ VALUES (@candidateId, @againstId, @contradicts, @reason, @model, @now)`
1556
+ ).run({
1557
+ candidateId: input.candidateId,
1558
+ againstId: input.againstId,
1559
+ contradicts: input.contradicts ? 1 : 0,
1560
+ reason: input.reason,
1561
+ model: input.model,
1562
+ now: Date.now()
1563
+ });
1564
+ }
1565
+ function hasContradictionCheck(db, candidateId, againstId) {
1566
+ const row = db.prepare("SELECT 1 FROM contradiction_checks WHERE candidate_id = ? AND against_id = ?").get(candidateId, againstId);
1567
+ return row !== void 0;
1568
+ }
1569
+ function listContradictionSuggestions(db, projectId, opts = {}) {
1570
+ return db.prepare(
1571
+ `SELECT c.candidate_id AS candidateId, n.title AS candidateTitle,
1572
+ c.against_id AS againstId, a.title AS againstTitle,
1573
+ c.reason AS reason, c.checked_at AS checkedAt
1574
+ FROM contradiction_checks c
1575
+ JOIN nodes n ON n.id = c.candidate_id
1576
+ JOIN nodes a ON a.id = c.against_id
1577
+ WHERE n.project_id = @projectId AND c.contradicts = 1 AND c.dismissed = 0
1578
+ AND c.candidate_id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)
1579
+ ORDER BY c.checked_at DESC
1580
+ LIMIT @limit`
1581
+ ).all({ projectId, limit: opts.limit ?? 50 });
1582
+ }
1583
+ function countContradictionSuggestions(db, projectId) {
1584
+ const row = db.prepare(
1585
+ `SELECT COUNT(*) AS count
1586
+ FROM contradiction_checks c
1587
+ JOIN nodes n ON n.id = c.candidate_id
1588
+ WHERE n.project_id = @projectId AND c.contradicts = 1 AND c.dismissed = 0
1589
+ AND c.candidate_id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)`
1590
+ ).get({ projectId });
1591
+ return row.count;
1592
+ }
1593
+ function dismissContradictionSuggestion(db, projectId, candidateId) {
1594
+ return db.prepare(
1595
+ `UPDATE contradiction_checks
1596
+ SET dismissed = 1
1597
+ WHERE candidate_id = @candidateId AND contradicts = 1 AND dismissed = 0
1598
+ AND candidate_id IN (SELECT id FROM nodes WHERE project_id = @projectId)`
1599
+ ).run({ projectId, candidateId }).changes;
1600
+ }
1601
+
1452
1602
  // src/store/store.ts
1453
1603
  var MemoryStore = class _MemoryStore {
1454
1604
  constructor(db) {
@@ -1608,6 +1758,10 @@ var MemoryStore = class _MemoryStore {
1608
1758
  upsertEmbedding(rowid, embedding) {
1609
1759
  upsertEmbedding(this.db, rowid, embedding);
1610
1760
  }
1761
+ /** The stored vector for one node, or null if it has not been embedded yet. */
1762
+ getEmbedding(nodeId) {
1763
+ return getEmbedding(this.db, nodeId);
1764
+ }
1611
1765
  dropAllEmbeddings() {
1612
1766
  return dropAllEmbeddings(this.db);
1613
1767
  }
@@ -1617,14 +1771,14 @@ var MemoryStore = class _MemoryStore {
1617
1771
  setMeta(key, value) {
1618
1772
  setMeta(this.db, key, value);
1619
1773
  }
1620
- vectorSearch(projectId, embedding, limit = 20) {
1621
- return vectorSearch(this.db, projectId, embedding, limit);
1774
+ vectorSearch(projectId, embedding, limit = 20, opts = {}) {
1775
+ return vectorSearch(this.db, projectId, embedding, limit, opts);
1622
1776
  }
1623
1777
  stats(projectId) {
1624
1778
  return stats(this.db, projectId);
1625
1779
  }
1626
- search(projectId, query, limit = 20) {
1627
- return search(this.db, projectId, query, limit);
1780
+ search(projectId, query, limit = 20, opts = {}) {
1781
+ return search(this.db, projectId, query, limit, opts);
1628
1782
  }
1629
1783
  /** The project a node belongs to, or null if no node has this id. Used by `mark-stale` to validate both ids. */
1630
1784
  getNodeProjectId(id) {
@@ -1638,7 +1792,11 @@ var MemoryStore = class _MemoryStore {
1638
1792
  setSupersedes(newNodeId, staleNodeId) {
1639
1793
  setSupersedes(this.db, newNodeId, staleNodeId);
1640
1794
  }
1641
- /** Aging `inferred` nodes nothing supersedes yet -- candidates for `nexusmem mark-stale`, not auto-applied. */
1795
+ /** Record a human's verdict on one node -- the write behind `nexusmem review`. Caller validates project ownership first. */
1796
+ setTrustState(nodeId, state) {
1797
+ return setTrustState(this.db, nodeId, state);
1798
+ }
1799
+ /** Aging non-`observed` nodes nothing supersedes yet -- candidates for `nexusmem mark-stale`, not auto-applied. */
1642
1800
  listStaleCandidates(projectId, opts = {}) {
1643
1801
  return listStaleCandidates(this.db, projectId, opts);
1644
1802
  }
@@ -1646,6 +1804,32 @@ var MemoryStore = class _MemoryStore {
1646
1804
  countStaleCandidates(projectId, opts = {}) {
1647
1805
  return countStaleCandidates(this.db, projectId, opts);
1648
1806
  }
1807
+ /** Memoize one SLM contradiction judgment (either verdict). Suggest-only: never writes `supersedes`. */
1808
+ recordContradictionCheck(input) {
1809
+ recordContradictionCheck(this.db, input);
1810
+ }
1811
+ hasContradictionCheck(candidateId, againstId) {
1812
+ return hasContradictionCheck(this.db, candidateId, againstId);
1813
+ }
1814
+ /** Open YES verdicts awaiting a human's `mark-stale`, newest judgment first. */
1815
+ listContradictionSuggestions(projectId, opts = {}) {
1816
+ return listContradictionSuggestions(this.db, projectId, opts);
1817
+ }
1818
+ countContradictionSuggestions(projectId) {
1819
+ return countContradictionSuggestions(this.db, projectId);
1820
+ }
1821
+ /** Reject every open suggestion for this candidate; returns how many were actually dismissed. */
1822
+ dismissContradictionSuggestion(projectId, candidateId) {
1823
+ return dismissContradictionSuggestion(this.db, projectId, candidateId);
1824
+ }
1825
+ /** Record one `mutation_audit` row for a coarse/destructive operation outside `forget` (currently: `--prune-source`/`--prune-stale-shell`). */
1826
+ recordMutationAudit(input) {
1827
+ return recordMutationAudit(this.db, input);
1828
+ }
1829
+ /** Newest-first `mutation_audit` rows for this project -- every `forget` and `--prune-source` run, whether or not anything matched. */
1830
+ listMutationAudit(projectId, opts = {}) {
1831
+ return listMutationAudit(this.db, projectId, opts);
1832
+ }
1649
1833
  /** Escape hatch for tests and future modules. */
1650
1834
  get raw() {
1651
1835
  return this.db;
@@ -2295,6 +2479,7 @@ function packContext(ranked, tokensBudget, opts = {}) {
2295
2479
  summary,
2296
2480
  tokens,
2297
2481
  provenance: hit.provenance,
2482
+ trustState: hit.trustState,
2298
2483
  ...hit.project ? { project: hit.project } : {}
2299
2484
  });
2300
2485
  tokensUsed += tokens;
@@ -2308,7 +2493,8 @@ function renderContextBlock(query, result) {
2308
2493
  for (const node of result.nodes) {
2309
2494
  const project = node.project ? `[${node.project}] ` : "";
2310
2495
  const provenance = `[${node.provenance}] `;
2311
- lines.push(`- ${node.ts.slice(0, 10)} ${provenance}${project}${node.title}`);
2496
+ const trust = node.trustState !== "candidate" ? `[${node.trustState}] ` : "";
2497
+ lines.push(`- ${node.ts.slice(0, 10)} ${provenance}${trust}${project}${node.title}`);
2312
2498
  if (node.summary && node.summary !== node.title) {
2313
2499
  if (node.kind === "code_diff") {
2314
2500
  for (const line of node.summary.split("\n")) lines.push(` ${line}`);
@@ -2446,6 +2632,7 @@ function mergeSearchAndVectorHits(bm25Hits, vectorHits) {
2446
2632
  body: hit.body,
2447
2633
  signal: hit.signal,
2448
2634
  provenance: hit.provenance,
2635
+ trustState: hit.trustState,
2449
2636
  rank: 0
2450
2637
  });
2451
2638
  }
@@ -2458,8 +2645,14 @@ var SIGNAL_FLOOR = 0.2;
2458
2645
  var RECENCY_FLOOR = 0.3;
2459
2646
  var DEFAULT_HALF_LIFE_DAYS = 30;
2460
2647
  var MS_PER_DAY = 864e5;
2461
- var INFERRED_HALF_LIFE_RATIO = 0.5;
2648
+ var HALF_LIFE_RATIO = {
2649
+ observed: 1,
2650
+ authored: 0.75,
2651
+ recorded: 0.5,
2652
+ derived: 0.35
2653
+ };
2462
2654
  var SUPERSEDED_PENALTY = 0.5;
2655
+ var REJECTED_TRUST_PENALTY = 0.3;
2463
2656
  var MAX_PRIOR_OVERTURN = 2;
2464
2657
  var PRIOR_COUNT = 2;
2465
2658
  var PER_PRIOR_OVERTURN = MAX_PRIOR_OVERTURN ** (1 / PRIOR_COUNT);
@@ -2493,17 +2686,18 @@ function ageDaysOf(ts, now) {
2493
2686
  function rankHits(hits, opts = {}) {
2494
2687
  if (hits.length === 0) return [];
2495
2688
  const halfLife = opts.halfLifeDays ?? DEFAULT_HALF_LIFE_DAYS;
2496
- const inferredHalfLife = opts.inferredHalfLifeDays ?? halfLife * INFERRED_HALF_LIFE_RATIO;
2689
+ const ratios = { ...HALF_LIFE_RATIO, ...opts.halfLifeRatios };
2497
2690
  const now = opts.now ?? /* @__PURE__ */ new Date();
2498
2691
  const relevances = opts.relevanceScores ? normalizeExternalRelevance(hits, opts.relevanceScores) : normalizeRelevance(hits);
2499
2692
  const ranked = hits.map((hit, i) => {
2500
2693
  const relevance = relevances[i] ?? RELEVANCE_FLOOR;
2501
2694
  const signalWeight = SIGNAL_FLOOR + (1 - SIGNAL_FLOOR) * hit.signal;
2502
2695
  const ageDays = ageDaysOf(hit.ts, now);
2503
- const effectiveHalfLife = hit.provenance === "inferred" ? inferredHalfLife : halfLife;
2696
+ const effectiveHalfLife = halfLife * (ratios[hit.provenance] ?? 1);
2504
2697
  const recencyFactor = RECENCY_FLOOR + (1 - RECENCY_FLOOR) * 2 ** (-ageDays / effectiveHalfLife);
2505
2698
  const rawScore = relevance * signalWeight ** SIGNAL_EXPONENT * recencyFactor ** RECENCY_EXPONENT;
2506
- const score = opts.supersededIds?.has(hit.id) ? rawScore * SUPERSEDED_PENALTY : rawScore;
2699
+ const supersededScore = opts.supersededIds?.has(hit.id) ? rawScore * SUPERSEDED_PENALTY : rawScore;
2700
+ const score = hit.trustState === "rejected" ? supersededScore * REJECTED_TRUST_PENALTY : supersededScore;
2507
2701
  return { ...hit, relevance, signalWeight, ageDays, recencyFactor, score };
2508
2702
  });
2509
2703
  return ranked.sort((a, b) => b.score - a.score);
@@ -2533,6 +2727,7 @@ function pullLinkedResolutions(resolveStore, ranked) {
2533
2727
  body: resolution.body,
2534
2728
  signal: resolution.signal,
2535
2729
  provenance: resolution.provenance,
2730
+ trustState: resolution.trustState,
2536
2731
  rank: 0,
2537
2732
  // no bm25/vector rank of its own -- never read again past this point
2538
2733
  relevance: hit.relevance,
@@ -2557,8 +2752,8 @@ async function runCrossProjectQuery(sources, query, opts) {
2557
2752
  const supersededIds = /* @__PURE__ */ new Set();
2558
2753
  for (const source of sources) {
2559
2754
  const label = (hit) => ({ ...hit, project: source.label });
2560
- const bm25Hits = source.store.search(source.projectId, query, opts.candidates).map(label);
2561
- const vectorHits = queryVector ? source.store.vectorSearch(source.projectId, queryVector, opts.candidates) : [];
2755
+ const bm25Hits = source.store.search(source.projectId, query, opts.candidates, { asOfEpoch: opts.asOfEpoch }).map(label);
2756
+ const vectorHits = queryVector ? source.store.vectorSearch(source.projectId, queryVector, opts.candidates, { asOfEpoch: opts.asOfEpoch }) : [];
2562
2757
  bm25Count += bm25Hits.length;
2563
2758
  vectorCount += vectorHits.length;
2564
2759
  perProject.push({ label: source.label, bm25: bm25Hits.length, vector: vectorHits.length });
@@ -2579,11 +2774,11 @@ async function runCrossProjectQuery(sources, query, opts) {
2579
2774
  return { bm25Count, vectorCount, hits, packed, perProject };
2580
2775
  }
2581
2776
  async function runHybridQuery(store, projectId, query, opts) {
2582
- const bm25Hits = store.search(projectId, query, opts.candidates);
2777
+ const bm25Hits = store.search(projectId, query, opts.candidates, { asOfEpoch: opts.asOfEpoch });
2583
2778
  let vectorHits = [];
2584
2779
  if (opts.embeddingProvider) {
2585
2780
  const queryVector = await opts.embeddingProvider.embed(query);
2586
- if (queryVector) vectorHits = store.vectorSearch(projectId, queryVector, opts.candidates);
2781
+ if (queryVector) vectorHits = store.vectorSearch(projectId, queryVector, opts.candidates, { asOfEpoch: opts.asOfEpoch });
2587
2782
  }
2588
2783
  const hits = vectorHits.length > 0 ? mergeSearchAndVectorHits(bm25Hits, vectorHits) : bm25Hits;
2589
2784
  const relevanceScores = vectorHits.length > 0 ? reciprocalRankFusion([bm25Hits, vectorHits]) : void 0;
@@ -2830,8 +3025,8 @@ function toMemoryNodes(turn, projectId, opts = {}) {
2830
3025
  files: extractMentionedFiles(`${userRedacted.text}
2831
3026
  ${chunk2.text}`),
2832
3027
  signal: scoreConversationTurn(userRedacted.text, chunk2.text),
2833
- provenance: "inferred",
2834
- // discourse about what happened, not the event itself
3028
+ provenance: "recorded",
3029
+ // verbatim discourse about what happened, not the event itself
2835
3030
  meta: {
2836
3031
  cwd: turn.cwd,
2837
3032
  source: turn.source,
@@ -3403,8 +3598,8 @@ function toMemoryNodes3(file, projectId, opts = {}) {
3403
3598
  body: truncate(chunk2.text, maxBody),
3404
3599
  files: [{ path: file.path, insertions: null, deletions: null, binary: false }],
3405
3600
  signal: scoreDocSection(file.path, chunk2.heading, chunk2.text),
3406
- provenance: "inferred",
3407
- // a written claim, and the kind of content most likely to go stale
3601
+ provenance: "authored",
3602
+ // a human's own written claim -- deliberate, but can still go stale
3408
3603
  meta: {
3409
3604
  path: file.path,
3410
3605
  heading: chunk2.heading,
@@ -3566,7 +3761,7 @@ ${summary.body}`;
3566
3761
  files: extractMentionedFiles(session.turns.map((t) => `${t.userText}
3567
3762
  ${t.assistantText}`).join("\n")),
3568
3763
  signal: scoreSession(session.turns.length),
3569
- provenance: "inferred",
3764
+ provenance: "derived",
3570
3765
  // a model's distillation, not a directly observed event
3571
3766
  meta: {
3572
3767
  sessionKey: session.sessionKey,
@@ -3820,6 +4015,97 @@ async function readDocFiles(repoRoot, opts = {}) {
3820
4015
  return { files, unreadable };
3821
4016
  }
3822
4017
 
4018
+ // src/slm/contradiction.ts
4019
+ var MAX_BODY_CHARS = 1500;
4020
+ var MAX_REASON_CHARS = 200;
4021
+ var CONTRADICTION_INSTRUCTIONS = `You are checking whether a NEWER memory replaces or contradicts an OLDER one, for an AI coding assistant's memory index.
4022
+
4023
+ Answer in exactly this shape:
4024
+ VERDICT: YES or NO
4025
+ REASON: <one line, under 20 words>
4026
+
4027
+ Say YES only if the NEWER memory states something that makes the OLDER one factually wrong or obsolete -- a decision reversed, a bug fixed, a plan abandoned. Say NO if they are about different things, or the newer one only adds detail without contradicting the older one. When unsure, say NO.`;
4028
+ function buildContradictionPrompt(older, newer) {
4029
+ const body = [
4030
+ `OLDER (${older.title}):`,
4031
+ truncate(older.body, MAX_BODY_CHARS),
4032
+ "",
4033
+ `NEWER (${newer.title}):`,
4034
+ truncate(newer.body, MAX_BODY_CHARS)
4035
+ ].join("\n");
4036
+ return `${CONTRADICTION_INSTRUCTIONS}
4037
+
4038
+ ---
4039
+
4040
+ ${body}
4041
+
4042
+ ---
4043
+
4044
+ Answer:`;
4045
+ }
4046
+ function parseContradictionVerdict(raw) {
4047
+ const lines = raw.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
4048
+ const verdictLine = lines.find((l) => /^VERDICT:/i.test(l));
4049
+ if (!verdictLine) return null;
4050
+ const verdict = /^VERDICT:\s*(YES|NO)\b/i.exec(verdictLine);
4051
+ if (!verdict) return null;
4052
+ const reasonLine = lines.find((l) => /^REASON:/i.test(l));
4053
+ const reason = reasonLine ? reasonLine.replace(/^REASON:\s*/i, "").trim() : "";
4054
+ return {
4055
+ contradicts: verdict[1].toUpperCase() === "YES",
4056
+ reason: truncate(reason, MAX_REASON_CHARS)
4057
+ };
4058
+ }
4059
+
4060
+ // src/retrieval/contradiction.ts
4061
+ var DEFAULT_LIMIT = 10;
4062
+ var DEFAULT_NEIGHBOR_LIMIT = 25;
4063
+ async function checkContradictions(store, embeddingProvider, slmProvider, projectId, candidates, opts = {}) {
4064
+ const limit = opts.limit ?? DEFAULT_LIMIT;
4065
+ const neighborLimit = opts.neighborLimit ?? DEFAULT_NEIGHBOR_LIMIT;
4066
+ const model = opts.model ?? DEFAULT_SLM_MODEL;
4067
+ const suggestions = [];
4068
+ let judgments = 0;
4069
+ let consecutiveNullReplies = 0;
4070
+ for (const candidate of candidates.slice(0, limit)) {
4071
+ if (opts.maxJudgments !== void 0 && judgments >= opts.maxJudgments) break;
4072
+ const full = store.getNodesByIds([candidate.id])[0];
4073
+ if (!full) continue;
4074
+ const embedding = store.getEmbedding(candidate.id) ?? await embeddingProvider.embed(`${full.title}
4075
+ ${full.body}`);
4076
+ if (!embedding) continue;
4077
+ const candidateEpoch = Date.parse(candidate.ts);
4078
+ const nearest = store.vectorSearch(projectId, embedding, neighborLimit + 1).find((hit) => hit.id !== candidate.id && Date.parse(hit.ts) > candidateEpoch);
4079
+ if (!nearest) continue;
4080
+ if (store.hasContradictionCheck(candidate.id, nearest.id)) continue;
4081
+ const reply = await slmProvider.complete(buildContradictionPrompt(full, nearest));
4082
+ if (!reply) {
4083
+ consecutiveNullReplies += 1;
4084
+ if (consecutiveNullReplies >= 2) break;
4085
+ continue;
4086
+ }
4087
+ consecutiveNullReplies = 0;
4088
+ const verdict = parseContradictionVerdict(reply);
4089
+ if (!verdict) continue;
4090
+ judgments += 1;
4091
+ store.recordContradictionCheck({
4092
+ candidateId: candidate.id,
4093
+ againstId: nearest.id,
4094
+ contradicts: verdict.contradicts,
4095
+ reason: verdict.contradicts ? verdict.reason : null,
4096
+ model
4097
+ });
4098
+ if (!verdict.contradicts) continue;
4099
+ suggestions.push({
4100
+ candidateId: candidate.id,
4101
+ againstId: nearest.id,
4102
+ againstTitle: nearest.title,
4103
+ reason: verdict.reason
4104
+ });
4105
+ }
4106
+ return suggestions;
4107
+ }
4108
+
3823
4109
  // src/shell/detect.ts
3824
4110
  import { existsSync as existsSync4 } from "fs";
3825
4111
  import { readFile as readFile9, stat as stat2 } from "fs/promises";
@@ -4922,13 +5208,38 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
4922
5208
  );
4923
5209
  return 0;
4924
5210
  }
5211
+ const startedAt = Date.now();
4925
5212
  let removed = 0;
4926
5213
  for (const { source, id } of counts) removed += store.pruneSourceNodes(id, source, []);
5214
+ store.recordMutationAudit({
5215
+ action: "prune_source",
5216
+ projectId,
5217
+ detail: { sources, scopeProjectIds: scopeIds },
5218
+ affectedCount: removed,
5219
+ succeeded: true,
5220
+ startedAt,
5221
+ finishedAt: Date.now()
5222
+ });
4927
5223
  const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
4928
5224
  out(`${pc7.green("pruned")} ${removed} node(s) across ${sources.length} source(s)${identityPart}
4929
5225
  `);
4930
5226
  return 0;
4931
5227
  }
5228
+ async function runAutoContradictionCheck(store, config, projectId, providers) {
5229
+ if (!config.contradictions.autoCheck) return "";
5230
+ const candidates = store.listStaleCandidates(projectId);
5231
+ if (candidates.length === 0) return "";
5232
+ const fresh = await checkContradictions(store, providers.embedder, providers.slm, projectId, candidates, {
5233
+ limit: candidates.length,
5234
+ maxJudgments: config.contradictions.maxPerSync,
5235
+ model: config.contradictions.model
5236
+ });
5237
+ const open = store.countContradictionSuggestions(projectId);
5238
+ if (fresh.length === 0 && open === 0) return "";
5239
+ const freshPart = fresh.length > 0 ? pc7.yellow(`${fresh.length} new`) : `${fresh.length} new`;
5240
+ return ` ${pc7.dim("contradictions:")} ${freshPart}${pc7.dim(`, ${open} open suggestion(s) -- run`)} ${pc7.bold("nexusmem stale")} ${pc7.dim("for detail")}
5241
+ `;
5242
+ }
4932
5243
  async function runSync(opts) {
4933
5244
  const { repo, ws, projectId, config } = await loadContext(opts.cwd);
4934
5245
  const log = (line) => {
@@ -4982,6 +5293,7 @@ async function runSync(opts) {
4982
5293
  const docs = await syncDocs(store, projectId, repo.root, config, log);
4983
5294
  const structure = await syncStructure(store, projectId, repo.root, config, log);
4984
5295
  let embedLine = "";
5296
+ let embeddingAvailable = false;
4985
5297
  if (!opts.noEmbed) {
4986
5298
  let lastLogged = 0;
4987
5299
  const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId, {
@@ -4993,6 +5305,7 @@ async function runSync(opts) {
4993
5305
  log(` ${pc7.dim(`vector: ${attempted}/${total} embedded`)}`);
4994
5306
  }
4995
5307
  });
5308
+ embeddingAvailable = !result.providerUnavailable;
4996
5309
  if (result.embedded > 0) {
4997
5310
  const skippedPart = result.skipped > 0 ? pc7.dim(`, ${result.skipped} skipped`) : "";
4998
5311
  const remainingPart = result.remaining > 0 ? pc7.yellow(`, ${result.remaining} still pending`) : "";
@@ -5002,6 +5315,10 @@ async function runSync(opts) {
5002
5315
  log(`${pc7.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
5003
5316
  }
5004
5317
  }
5318
+ const contradictionLine = !opts.noEmbed && embeddingAvailable ? await runAutoContradictionCheck(store, config, projectId, {
5319
+ embedder: new OllamaEmbeddingProvider(),
5320
+ slm: new OllamaChatProvider({ model: config.contradictions.model })
5321
+ }) : "";
5005
5322
  let linkLine = "";
5006
5323
  if (opts.linkFailures) {
5007
5324
  const linkStats = correlateFailures(store, projectId);
@@ -5030,7 +5347,7 @@ async function runSync(opts) {
5030
5347
  ` ${pc7.green(`+${totals.inserted} new`)} ${pc7.yellow(`~${totals.updated} updated`)} ${pc7.dim(`=${totals.unchanged} unchanged`)}${deniedPart}`,
5031
5348
  ` ${pc7.dim(`${stats2.total} node(s) total across ${stats2.distinctFiles} file path(s)`)}`,
5032
5349
  ""
5033
- ].join("\n") + embedLine + linkLine
5350
+ ].join("\n") + embedLine + linkLine + contradictionLine
5034
5351
  );
5035
5352
  return 0;
5036
5353
  } finally {
@@ -5045,10 +5362,17 @@ async function searchMemory(input) {
5045
5362
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5046
5363
  const budget = input.budget ?? 2e3;
5047
5364
  const candidates = input.candidates ?? 30;
5365
+ let asOfEpoch;
5366
+ if (input.asOf) {
5367
+ const parsed = Date.parse(input.asOf);
5368
+ if (Number.isNaN(parsed)) throw new Error(`asOf "${input.asOf}" is not a parseable date`);
5369
+ asOfEpoch = parsed;
5370
+ }
5048
5371
  const queryOpts = {
5049
5372
  budget,
5050
5373
  candidates,
5051
- embeddingProvider: input.noVector ? null : new OllamaEmbeddingProvider()
5374
+ embeddingProvider: input.noVector ? null : new OllamaEmbeddingProvider(),
5375
+ asOfEpoch
5052
5376
  };
5053
5377
  if (input.allProjects) {
5054
5378
  const opened = await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath });
@@ -5144,11 +5468,14 @@ function createServer() {
5144
5468
  budget: z4.number().int().positive().optional().describe("Max tokens in the returned context block. Default 2000."),
5145
5469
  allProjects: z4.boolean().optional().describe(
5146
5470
  "Search every repository NexusMem has been run in on this machine, not just projectRoot. Use when the answer may live in a different project (a pattern solved elsewhere, a tool that failed the same way before). Each result is tagged with its repository."
5471
+ ),
5472
+ asOf: z4.string().optional().describe(
5473
+ 'ISO-8601 date/time. Restricts results to nodes recorded at or before this instant -- "what did memory hold as of then", not "what happened then". Omit for the normal, unrestricted read.'
5147
5474
  )
5148
5475
  }
5149
5476
  },
5150
- async ({ projectRoot, query, budget, allProjects }) => {
5151
- const result = await searchMemory({ projectRoot, query, budget, allProjects });
5477
+ async ({ projectRoot, query, budget, allProjects, asOf }) => {
5478
+ const result = await searchMemory({ projectRoot, query, budget, allProjects, asOf });
5152
5479
  return {
5153
5480
  content: [{ type: "text", text: result.text }],
5154
5481
  structuredContent: {
@@ -5333,8 +5660,20 @@ ${pc8.dim("-".repeat(40))}
5333
5660
 
5334
5661
  // src/cli/commands/query.ts
5335
5662
  import pc9 from "picocolors";
5663
+ var QueryError = class extends Error {
5664
+ constructor(message) {
5665
+ super(message);
5666
+ this.name = "QueryError";
5667
+ }
5668
+ };
5336
5669
  async function runQuery(opts) {
5337
5670
  const { repo, ws, projectId } = await loadContext(opts.cwd);
5671
+ let asOfEpoch;
5672
+ if (opts.asOf) {
5673
+ const parsed = Date.parse(opts.asOf);
5674
+ if (Number.isNaN(parsed)) throw new QueryError(`--as-of "${opts.asOf}" is not a parseable date`);
5675
+ asOfEpoch = parsed;
5676
+ }
5338
5677
  const opened = opts.allProjects ? await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath }) : null;
5339
5678
  let store = null;
5340
5679
  try {
@@ -5342,7 +5681,8 @@ async function runQuery(opts) {
5342
5681
  budget: opts.budget,
5343
5682
  candidates: opts.candidates,
5344
5683
  halfLifeDays: opts.halfLifeDays,
5345
- embeddingProvider: opts.noVector ? null : new OllamaEmbeddingProvider()
5684
+ embeddingProvider: opts.noVector ? null : new OllamaEmbeddingProvider(),
5685
+ asOfEpoch
5346
5686
  };
5347
5687
  let result;
5348
5688
  if (opened) {
@@ -5352,6 +5692,10 @@ async function runQuery(opts) {
5352
5692
  result = await runHybridQuery(store, projectId, opts.query, queryOpts);
5353
5693
  }
5354
5694
  const { bm25Count, vectorCount, hits, packed } = result;
5695
+ if (asOfEpoch !== void 0 && !opts.json) {
5696
+ process.stderr.write(`${pc9.dim("as of ")} ${new Date(asOfEpoch).toISOString()} -- excludes anything recorded after
5697
+ `);
5698
+ }
5355
5699
  if (opened && !opts.json) {
5356
5700
  const searched = opened.sources.map((s) => s.label).join(", ");
5357
5701
  process.stderr.write(`${pc9.dim("scope ")} ${opened.sources.length} project(s): ${searched}
@@ -5413,11 +5757,45 @@ async function runQuery(opts) {
5413
5757
  }
5414
5758
  }
5415
5759
 
5760
+ // src/cli/commands/review.ts
5761
+ import pc10 from "picocolors";
5762
+ var ReviewError = class extends Error {
5763
+ constructor(message) {
5764
+ super(message);
5765
+ this.name = "ReviewError";
5766
+ }
5767
+ };
5768
+ async function runReview(opts) {
5769
+ const { projectId, ws } = await loadContext(opts.cwd);
5770
+ const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
5771
+ const store = MemoryStore.open(ws.dbPath);
5772
+ try {
5773
+ const owner = store.getNodeProjectId(opts.nodeId);
5774
+ if (owner === null) {
5775
+ throw new ReviewError(`no node found with id ${opts.nodeId}`);
5776
+ }
5777
+ if (owner !== projectId) {
5778
+ throw new ReviewError("node must belong to the current project");
5779
+ }
5780
+ store.setTrustState(opts.nodeId, opts.verdict);
5781
+ const verb = opts.verdict === "verified" ? "verified" : "rejected";
5782
+ out(
5783
+ `${pc10.green(verb)} ${opts.nodeId}
5784
+ ` + (opts.verdict === "rejected" ? `${pc10.dim("down-weighted in ranking")} -- the node stays queryable, just ranked lower
5785
+ ` : `${pc10.dim("labeled only")} -- verifying does not change ranking
5786
+ `)
5787
+ );
5788
+ return 0;
5789
+ } finally {
5790
+ store.close();
5791
+ }
5792
+ }
5793
+
5416
5794
  // src/cli/commands/scan-conversation.ts
5417
- import pc11 from "picocolors";
5795
+ import pc12 from "picocolors";
5418
5796
 
5419
5797
  // src/cli/format.ts
5420
- import pc10 from "picocolors";
5798
+ import pc11 from "picocolors";
5421
5799
  var GIT_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
5422
5800
  var SHELL_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
5423
5801
  var CONVERSATION_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
@@ -5429,9 +5807,9 @@ function signalBand(signal, bands) {
5429
5807
  return "low";
5430
5808
  }
5431
5809
  var BAND_COLOR = {
5432
- high: pc10.green,
5433
- medium: pc10.yellow,
5434
- low: pc10.dim
5810
+ high: pc11.green,
5811
+ medium: pc11.yellow,
5812
+ low: pc11.dim
5435
5813
  };
5436
5814
  function formatSignal(signal, bands) {
5437
5815
  return BAND_COLOR[signalBand(signal, bands)](signal.toFixed(2));
@@ -5440,7 +5818,7 @@ function approxTotalTokens(nodes) {
5440
5818
  return nodes.reduce((n, x) => n + approxTokens(x.body), 0);
5441
5819
  }
5442
5820
  function summarize2(nodes) {
5443
- if (nodes.length === 0) return pc10.yellow("no commits matched");
5821
+ if (nodes.length === 0) return pc11.yellow("no commits matched");
5444
5822
  const timestamps = nodes.map((n) => n.ts).sort();
5445
5823
  const avgSignal = nodes.reduce((n, x) => n + x.signal, 0) / nodes.length;
5446
5824
  const totalTokens = approxTotalTokens(nodes);
@@ -5450,7 +5828,7 @@ function summarize2(nodes) {
5450
5828
  }
5451
5829
  const hottest = [...fileHits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([path, count]) => ` ${String(count).padStart(3)}x ${path}`);
5452
5830
  return [
5453
- `${pc10.bold(String(nodes.length))} nodes ${pc10.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
5831
+ `${pc11.bold(String(nodes.length))} nodes ${pc11.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
5454
5832
  ` avg signal ${avgSignal.toFixed(3)} ~${totalTokens.toLocaleString()} tokens if sent raw`,
5455
5833
  hottest.length ? ` hottest files:
5456
5834
  ${hottest.join("\n")}` : ""
@@ -5464,9 +5842,9 @@ async function runScanConversation(opts) {
5464
5842
  const files = await listTranscriptFiles(repo.root);
5465
5843
  if (!opts.json) {
5466
5844
  process.stderr.write(
5467
- files.length ? `${pc11.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
5845
+ files.length ? `${pc12.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
5468
5846
 
5469
- ` : `${pc11.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
5847
+ ` : `${pc12.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
5470
5848
  `
5471
5849
  );
5472
5850
  }
@@ -5483,7 +5861,7 @@ async function runScanConversation(opts) {
5483
5861
  const approxTotal = approxTotalTokens(nodes);
5484
5862
  process.stderr.write(
5485
5863
  `
5486
- ${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"
5864
+ ${pc12.bold(String(nodes.length))} of ${turns.length} exchange(s) above threshold ${pc12.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}` + (redactedTotal > 0 ? ` ${pc12.yellow(`${redactedTotal} secret-like value(s) redacted`)}` : "") + "\n"
5487
5865
  );
5488
5866
  return 0;
5489
5867
  }
@@ -5492,7 +5870,7 @@ function formatNode(node) {
5492
5870
  }
5493
5871
 
5494
5872
  // src/cli/commands/scan-diff.ts
5495
- import pc12 from "picocolors";
5873
+ import pc13 from "picocolors";
5496
5874
  var DEFAULT_SCAN_COMMITS = 50;
5497
5875
  async function runScanDiff(opts) {
5498
5876
  const repo = await readRepoInfo(opts.cwd);
@@ -5500,9 +5878,9 @@ async function runScanDiff(opts) {
5500
5878
  if (!opts.json) {
5501
5879
  process.stderr.write(
5502
5880
  [
5503
- `${pc12.dim("repo ")} ${repo.root}`,
5504
- `${pc12.dim("branch ")} ${repo.branch ?? pc12.yellow("(detached)")}`,
5505
- `${pc12.dim("project")} ${pc12.cyan(projectId)}`,
5881
+ `${pc13.dim("repo ")} ${repo.root}`,
5882
+ `${pc13.dim("branch ")} ${repo.branch ?? pc13.yellow("(detached)")}`,
5883
+ `${pc13.dim("project")} ${pc13.cyan(projectId)}`,
5506
5884
  ""
5507
5885
  ].join("\n")
5508
5886
  );
@@ -5532,28 +5910,28 @@ function formatNode2(node) {
5532
5910
  const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
5533
5911
  return [
5534
5912
  formatSignal(node.signal, DIFF_SIGNAL_BANDS),
5535
- pc12.dim(node.ts.slice(0, 10)),
5536
- pc12.magenta(sha),
5913
+ pc13.dim(node.ts.slice(0, 10)),
5914
+ pc13.magenta(sha),
5537
5915
  String(node.meta.path ?? ""),
5538
- pc12.dim(`(${churn}, ${node.meta.hunkCount ?? 0} hunk(s))`)
5916
+ pc13.dim(`(${churn}, ${node.meta.hunkCount ?? 0} hunk(s))`)
5539
5917
  ].join(" ");
5540
5918
  }
5541
5919
 
5542
5920
  // src/cli/commands/scan-docs.ts
5543
- import pc13 from "picocolors";
5921
+ import pc14 from "picocolors";
5544
5922
  async function runScanDocs(opts) {
5545
5923
  const repo = await readRepoInfo(opts.cwd);
5546
5924
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5547
5925
  const { files, unreadable } = await readDocFiles(repo.root);
5548
5926
  if (!opts.json) {
5549
5927
  process.stderr.write(
5550
- files.length ? `${pc13.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
5928
+ files.length ? `${pc14.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
5551
5929
 
5552
- ` : `${pc13.yellow("no tracked .md files found")}
5930
+ ` : `${pc14.yellow("no tracked .md files found")}
5553
5931
  `
5554
5932
  );
5555
5933
  if (unreadable.length > 0) {
5556
- process.stderr.write(`${pc13.yellow("unreadable")} ${unreadable.join(", ")}
5934
+ process.stderr.write(`${pc14.yellow("unreadable")} ${unreadable.join(", ")}
5557
5935
 
5558
5936
  `);
5559
5937
  }
@@ -5569,7 +5947,7 @@ async function runScanDocs(opts) {
5569
5947
  const approxTotal = approxTotalTokens(nodes);
5570
5948
  process.stderr.write(
5571
5949
  `
5572
- ${pc13.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc13.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
5950
+ ${pc14.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc14.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
5573
5951
  `
5574
5952
  );
5575
5953
  return 0;
@@ -5579,17 +5957,17 @@ function formatNode3(node) {
5579
5957
  }
5580
5958
 
5581
5959
  // src/cli/commands/scan-git.ts
5582
- import pc14 from "picocolors";
5960
+ import pc15 from "picocolors";
5583
5961
  async function runScanGit(opts) {
5584
5962
  const repo = await readRepoInfo(opts.cwd);
5585
5963
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5586
5964
  if (!opts.json) {
5587
5965
  process.stderr.write(
5588
5966
  [
5589
- `${pc14.dim("repo ")} ${repo.root}`,
5590
- `${pc14.dim("branch ")} ${repo.branch ?? pc14.yellow("(detached)")}`,
5591
- `${pc14.dim("origin ")} ${repo.originUrl ?? pc14.dim("(none)")}`,
5592
- `${pc14.dim("project")} ${pc14.cyan(projectId)}`,
5967
+ `${pc15.dim("repo ")} ${repo.root}`,
5968
+ `${pc15.dim("branch ")} ${repo.branch ?? pc15.yellow("(detached)")}`,
5969
+ `${pc15.dim("origin ")} ${repo.originUrl ?? pc15.dim("(none)")}`,
5970
+ `${pc15.dim("project")} ${pc15.cyan(projectId)}`,
5593
5971
  ""
5594
5972
  ].join("\n")
5595
5973
  );
@@ -5623,21 +6001,21 @@ function formatNode4(node) {
5623
6001
  const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
5624
6002
  return [
5625
6003
  formatSignal(node.signal, GIT_SIGNAL_BANDS),
5626
- pc14.dim(date),
5627
- pc14.magenta(sha),
6004
+ pc15.dim(date),
6005
+ pc15.magenta(sha),
5628
6006
  node.title,
5629
- pc14.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
6007
+ pc15.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
5630
6008
  ].join(" ");
5631
6009
  }
5632
6010
 
5633
6011
  // src/cli/commands/scan-session.ts
5634
- import pc15 from "picocolors";
6012
+ import pc16 from "picocolors";
5635
6013
  async function runScanSession(opts) {
5636
6014
  const repo = await readRepoInfo(opts.cwd);
5637
6015
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5638
6016
  const turns = await collectClaudeCodeTranscripts(repo.root);
5639
6017
  if (turns.length === 0) {
5640
- process.stderr.write(`${pc15.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
6018
+ process.stderr.write(`${pc16.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
5641
6019
  `);
5642
6020
  return 0;
5643
6021
  }
@@ -5645,7 +6023,7 @@ async function runScanSession(opts) {
5645
6023
  const settled = selectSettledSessions(sessions, opts.settleMinutes);
5646
6024
  if (!opts.json) {
5647
6025
  process.stderr.write(
5648
- `${pc15.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
6026
+ `${pc16.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
5649
6027
 
5650
6028
  `
5651
6029
  );
@@ -5671,7 +6049,7 @@ async function runScanSession(opts) {
5671
6049
  }
5672
6050
  for (const preview of previews) {
5673
6051
  process.stdout.write(
5674
- `${pc15.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
6052
+ `${pc16.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
5675
6053
  ${preview.prompt}
5676
6054
 
5677
6055
  `
@@ -5683,7 +6061,7 @@ ${preview.prompt}
5683
6061
  settleMinutes: opts.settleMinutes,
5684
6062
  maxSessions: opts.maxSessions,
5685
6063
  onProgress: (done, total) => {
5686
- if (!opts.json) process.stderr.write(` ${pc15.dim(`summarizing ${done}/${total}`)}
6064
+ if (!opts.json) process.stderr.write(` ${pc16.dim(`summarizing ${done}/${total}`)}
5687
6065
  `);
5688
6066
  }
5689
6067
  });
@@ -5693,21 +6071,21 @@ ${preview.prompt}
5693
6071
  return 0;
5694
6072
  }
5695
6073
  for (const node of result.nodes) {
5696
- process.stdout.write(`${pc15.bold(node.title)}
5697
- ${pc15.dim(node.ts.slice(0, 16).replace("T", " "))}
6074
+ process.stdout.write(`${pc16.bold(node.title)}
6075
+ ${pc16.dim(node.ts.slice(0, 16).replace("T", " "))}
5698
6076
  ${node.body}
5699
6077
 
5700
6078
  `);
5701
6079
  }
5702
6080
  if (result.providerUnavailable) {
5703
6081
  process.stderr.write(
5704
- `${pc15.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
6082
+ `${pc16.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
5705
6083
  `
5706
6084
  );
5707
6085
  return 0;
5708
6086
  }
5709
6087
  process.stderr.write(
5710
- `${pc15.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc15.yellow(`${result.failed} failed`)}` : "") + ` ${pc15.dim(`(model ${opts.model})`)}
6088
+ `${pc16.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc16.yellow(`${result.failed} failed`)}` : "") + ` ${pc16.dim(`(model ${opts.model})`)}
5711
6089
  `
5712
6090
  );
5713
6091
  return 0;
@@ -5715,16 +6093,16 @@ ${node.body}
5715
6093
  var SCAN_SESSION_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
5716
6094
 
5717
6095
  // src/cli/commands/scan-shell.ts
5718
- import pc16 from "picocolors";
6096
+ import pc17 from "picocolors";
5719
6097
  async function runScanShell(opts) {
5720
6098
  const repo = await readRepoInfo(opts.cwd);
5721
6099
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5722
6100
  const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
5723
6101
  if (!opts.json) {
5724
6102
  process.stderr.write(
5725
- results.length ? `${pc16.dim("sources found")} ${results.map((r) => r.name).join(", ")}
6103
+ results.length ? `${pc17.dim("sources found")} ${results.map((r) => r.name).join(", ")}
5726
6104
 
5727
- ` : `${pc16.yellow("no shell history source found on this machine")}
6105
+ ` : `${pc17.yellow("no shell history source found on this machine")}
5728
6106
  `
5729
6107
  );
5730
6108
  }
@@ -5733,7 +6111,7 @@ async function runScanShell(opts) {
5733
6111
  const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
5734
6112
  allNodes.push(...nodes);
5735
6113
  if (!opts.json) {
5736
- process.stdout.write(`${pc16.bold(`shell:${result.name}`)} ${pc16.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
6114
+ process.stdout.write(`${pc17.bold(`shell:${result.name}`)} ${pc17.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
5737
6115
  `);
5738
6116
  for (const node of nodes) process.stdout.write(`${formatNode5(node)}
5739
6117
  `);
@@ -5746,19 +6124,19 @@ async function runScanShell(opts) {
5746
6124
  return 0;
5747
6125
  }
5748
6126
  const approxTotal = approxTotalTokens(allNodes);
5749
- process.stderr.write(`${pc16.bold(String(allNodes.length))} node(s) total ${pc16.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
6127
+ process.stderr.write(`${pc17.bold(String(allNodes.length))} node(s) total ${pc17.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
5750
6128
  `);
5751
6129
  return 0;
5752
6130
  }
5753
6131
  function formatNode5(node) {
5754
- const approx = node.meta.tsApprox ? pc16.dim("~") : " ";
6132
+ const approx = node.meta.tsApprox ? pc17.dim("~") : " ";
5755
6133
  const exit = node.meta.exitCode;
5756
- const exitLabel = typeof exit === "number" && exit !== 0 ? pc16.red(`exit ${exit}`) : "";
6134
+ const exitLabel = typeof exit === "number" && exit !== 0 ? pc17.red(`exit ${exit}`) : "";
5757
6135
  return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
5758
6136
  }
5759
6137
 
5760
6138
  // src/cli/commands/scan-structure.ts
5761
- import pc17 from "picocolors";
6139
+ import pc18 from "picocolors";
5762
6140
  var TRACKED_EXTENSIONS = TRACKED_PATHSPECS.map((p) => p.replace("*", "")).join("/");
5763
6141
  async function runScanStructure(opts) {
5764
6142
  const repo = await readRepoInfo(opts.cwd);
@@ -5769,107 +6147,42 @@ async function runScanStructure(opts) {
5769
6147
  return 0;
5770
6148
  }
5771
6149
  if (unreadable.length > 0) {
5772
- process.stderr.write(`${pc17.yellow("unreadable")} ${unreadable.join(", ")}
6150
+ process.stderr.write(`${pc18.yellow("unreadable")} ${unreadable.join(", ")}
5773
6151
 
5774
6152
  `);
5775
6153
  }
5776
6154
  for (const edge of edges) {
5777
- process.stdout.write(`${edge.fromPath} ${pc17.dim("->")} ${edge.toPath}
6155
+ process.stdout.write(`${edge.fromPath} ${pc18.dim("->")} ${edge.toPath}
5778
6156
  `);
5779
6157
  }
5780
6158
  process.stderr.write(
5781
6159
  `
5782
- ${pc17.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKED_EXTENSIONS} file(s)
6160
+ ${pc18.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKED_EXTENSIONS} file(s)
5783
6161
  `
5784
6162
  );
5785
6163
  return 0;
5786
6164
  }
5787
6165
 
5788
6166
  // src/cli/commands/stale.ts
5789
- import pc18 from "picocolors";
5790
-
5791
- // src/slm/contradiction.ts
5792
- var MAX_BODY_CHARS = 1500;
5793
- var MAX_REASON_CHARS = 200;
5794
- var CONTRADICTION_INSTRUCTIONS = `You are checking whether a NEWER memory replaces or contradicts an OLDER one, for an AI coding assistant's memory index.
5795
-
5796
- Answer in exactly this shape:
5797
- VERDICT: YES or NO
5798
- REASON: <one line, under 20 words>
5799
-
5800
- Say YES only if the NEWER memory states something that makes the OLDER one factually wrong or obsolete -- a decision reversed, a bug fixed, a plan abandoned. Say NO if they are about different things, or the newer one only adds detail without contradicting the older one. When unsure, say NO.`;
5801
- function buildContradictionPrompt(older, newer) {
5802
- const body = [
5803
- `OLDER (${older.title}):`,
5804
- truncate(older.body, MAX_BODY_CHARS),
5805
- "",
5806
- `NEWER (${newer.title}):`,
5807
- truncate(newer.body, MAX_BODY_CHARS)
5808
- ].join("\n");
5809
- return `${CONTRADICTION_INSTRUCTIONS}
5810
-
5811
- ---
5812
-
5813
- ${body}
5814
-
5815
- ---
5816
-
5817
- Answer:`;
5818
- }
5819
- function parseContradictionVerdict(raw) {
5820
- const lines = raw.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
5821
- const verdictLine = lines.find((l) => /^VERDICT:/i.test(l));
5822
- if (!verdictLine) return null;
5823
- const verdict = /^VERDICT:\s*(YES|NO)\b/i.exec(verdictLine);
5824
- if (!verdict) return null;
5825
- const reasonLine = lines.find((l) => /^REASON:/i.test(l));
5826
- const reason = reasonLine ? reasonLine.replace(/^REASON:\s*/i, "").trim() : "";
5827
- return {
5828
- contradicts: verdict[1].toUpperCase() === "YES",
5829
- reason: truncate(reason, MAX_REASON_CHARS)
5830
- };
5831
- }
5832
-
5833
- // src/retrieval/contradiction.ts
5834
- var DEFAULT_LIMIT = 10;
5835
- var DEFAULT_NEIGHBOR_LIMIT = 25;
5836
- async function checkContradictions(store, embeddingProvider, slmProvider, projectId, candidates, opts = {}) {
5837
- const limit = opts.limit ?? DEFAULT_LIMIT;
5838
- const neighborLimit = opts.neighborLimit ?? DEFAULT_NEIGHBOR_LIMIT;
5839
- const suggestions = [];
5840
- for (const candidate of candidates.slice(0, limit)) {
5841
- const full = store.getNodesByIds([candidate.id])[0];
5842
- if (!full) continue;
5843
- const embedding = await embeddingProvider.embed(`${full.title}
5844
- ${full.body}`);
5845
- if (!embedding) continue;
5846
- const candidateEpoch = Date.parse(candidate.ts);
5847
- const nearest = store.vectorSearch(projectId, embedding, neighborLimit + 1).find((hit) => hit.id !== candidate.id && Date.parse(hit.ts) > candidateEpoch);
5848
- if (!nearest) continue;
5849
- const reply = await slmProvider.complete(buildContradictionPrompt(full, nearest));
5850
- if (!reply) continue;
5851
- const verdict = parseContradictionVerdict(reply);
5852
- if (!verdict?.contradicts) continue;
5853
- suggestions.push({
5854
- candidateId: candidate.id,
5855
- againstId: nearest.id,
5856
- againstTitle: nearest.title,
5857
- reason: verdict.reason
5858
- });
5859
- }
5860
- return suggestions;
5861
- }
5862
-
5863
- // src/cli/commands/stale.ts
6167
+ import pc19 from "picocolors";
5864
6168
  var STALE_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
5865
6169
  async function runStale(opts) {
5866
6170
  const { projectId, ws } = await loadContext(opts.cwd);
5867
6171
  const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
5868
6172
  const store = MemoryStore.open(ws.dbPath);
5869
6173
  try {
6174
+ if (opts.dismiss) {
6175
+ const dismissed = store.dismissContradictionSuggestion(projectId, opts.dismiss);
6176
+ out(
6177
+ dismissed > 0 ? `${pc19.green("dismissed")} the contradiction suggestion for ${opts.dismiss} -- it will not resurface
6178
+ ` : `${pc19.dim("no open contradiction suggestion")} for ${opts.dismiss} -- nothing to dismiss
6179
+ `
6180
+ );
6181
+ return 0;
6182
+ }
5870
6183
  const candidates = store.listStaleCandidates(projectId, { minAgeDays: opts.minAgeDays, limit: opts.limit });
5871
6184
  if (candidates.length === 0) {
5872
- out(`${pc18.dim("no stale candidates")} -- no inferred node older than the threshold lacks a successor
6185
+ out(`${pc19.dim("no stale candidates")} -- no unconfirmed node older than the threshold lacks a successor
5873
6186
  `);
5874
6187
  return 0;
5875
6188
  }
@@ -5880,21 +6193,26 @@ async function runStale(opts) {
5880
6193
  new OllamaEmbeddingProvider(),
5881
6194
  new OllamaChatProvider({ model: opts.model ?? DEFAULT_SLM_MODEL }),
5882
6195
  projectId,
5883
- candidates
6196
+ candidates,
6197
+ { model: opts.model ?? DEFAULT_SLM_MODEL }
5884
6198
  );
5885
6199
  }
5886
- const byCandidateId = new Map(suggestions.map((s) => [s.candidateId, s]));
6200
+ const byCandidateId = /* @__PURE__ */ new Map();
6201
+ for (const s of store.listContradictionSuggestions(projectId)) {
6202
+ byCandidateId.set(s.candidateId, { againstId: s.againstId, againstTitle: s.againstTitle, reason: s.reason ?? "" });
6203
+ }
6204
+ for (const s of suggestions) byCandidateId.set(s.candidateId, s);
5887
6205
  out(
5888
6206
  [
5889
- `${pc18.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
6207
+ `${pc19.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
5890
6208
  ...candidates.map((c) => {
5891
- const line = ` ${pc18.dim(c.id)} ${pc18.yellow(`${c.ageDays}d old`)} [${c.kind}] ${c.title}`;
6209
+ const line = ` ${pc19.dim(c.id)} ${pc19.yellow(`${c.ageDays}d old`)} [${c.kind}] ${c.title}`;
5892
6210
  const hit = byCandidateId.get(c.id);
5893
6211
  return hit ? `${line}
5894
- ${pc18.red("likely superseded by")} ${pc18.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
6212
+ ${pc19.red("likely superseded by")} ${pc19.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
5895
6213
  }),
5896
6214
  "",
5897
- `run ${pc18.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
6215
+ `run ${pc19.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
5898
6216
  ].join("\n").concat("\n")
5899
6217
  );
5900
6218
  return 0;
@@ -5906,7 +6224,7 @@ async function runStale(opts) {
5906
6224
  // src/cli/commands/status.ts
5907
6225
  import { basename as basename4 } from "path";
5908
6226
  import { statSync } from "fs";
5909
- import pc19 from "picocolors";
6227
+ import pc20 from "picocolors";
5910
6228
  function daySpan(oldest, newest) {
5911
6229
  const oldestDay = Date.parse(oldest.slice(0, 10));
5912
6230
  const newestDay = Date.parse(newest.slice(0, 10));
@@ -5963,35 +6281,37 @@ async function runStatus(opts) {
5963
6281
  const otherProjectNodes = store.countProjectNodes(otherProjectIds);
5964
6282
  const structure = store.fileEdgeStats(projectId);
5965
6283
  const staleCount = store.countStaleCandidates(projectId);
6284
+ const flaggedCount = store.countContradictionSuggestions(projectId);
5966
6285
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
5967
6286
  const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
5968
- const staleProjectWarning = otherProjectIds.length ? `${pc19.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc19.bold(
6287
+ const staleProjectWarning = otherProjectIds.length ? `${pc20.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc20.bold(
5969
6288
  "nexusmem sync --prune-source <name>"
5970
6289
  )} to remove stale source data` : "";
5971
6290
  out(
5972
6291
  [
5973
- `${pc19.dim("repo ")} ${repo.root}`,
5974
- `${pc19.dim("branch ")} ${repo.branch ?? pc19.yellow("(detached)")}`,
5975
- `${pc19.dim("project ")} ${pc19.cyan(projectId)}`,
5976
- `${pc19.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc19.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
5977
- `${pc19.dim("database")} ${ws.dbPath} ${pc19.dim(`(${humanBytes(dbBytes)})`)}`,
6292
+ `${pc20.dim("repo ")} ${repo.root}`,
6293
+ `${pc20.dim("branch ")} ${repo.branch ?? pc20.yellow("(detached)")}`,
6294
+ `${pc20.dim("project ")} ${pc20.cyan(projectId)}`,
6295
+ `${pc20.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc20.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
6296
+ `${pc20.dim("database")} ${ws.dbPath} ${pc20.dim(`(${humanBytes(dbBytes)})`)}`,
5978
6297
  staleProjectWarning,
5979
6298
  "",
5980
- `${pc19.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc19.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
6299
+ `${pc20.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc20.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
5981
6300
  ...kinds,
5982
- stats2.total ? ` ${pc19.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
6301
+ stats2.total ? ` ${pc20.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
5983
6302
  "",
5984
- sources.length ? pc19.dim("sources") : pc19.yellow("no sources synced yet"),
6303
+ sources.length ? pc20.dim("sources") : pc20.yellow("no sources synced yet"),
5985
6304
  ...sources.map((s) => {
5986
6305
  const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
5987
6306
  const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
5988
- return ` ${s.source.padEnd(14)} ${pc19.dim(`last run ${when}`)} ${pc19.dim(`cursor ${cursorLabel}`)}`;
6307
+ return ` ${s.source.padEnd(14)} ${pc20.dim(`last run ${when}`)} ${pc20.dim(`cursor ${cursorLabel}`)}`;
5989
6308
  }),
5990
6309
  "",
5991
- gitCursor && gitCursor !== repo.head ? `${pc19.yellow("git behind HEAD")} \u2014 run ${pc19.bold("nexusmem sync")}` : "",
5992
- chains.failuresTotal ? `${pc19.dim("chains ")} ${pc19.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc19.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc19.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
5993
- structure.edges ? `${pc19.dim("structure")} ${pc19.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
5994
- staleCount ? `${pc19.dim("aging ")} ${pc19.bold(String(staleCount))} inferred node(s) worth a look \u2014 run ${pc19.bold("nexusmem stale")}` : ""
6310
+ gitCursor && gitCursor !== repo.head ? `${pc20.yellow("git behind HEAD")} \u2014 run ${pc20.bold("nexusmem sync")}` : "",
6311
+ chains.failuresTotal ? `${pc20.dim("chains ")} ${pc20.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc20.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc20.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
6312
+ structure.edges ? `${pc20.dim("structure")} ${pc20.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
6313
+ staleCount ? `${pc20.dim("aging ")} ${pc20.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc20.bold("nexusmem stale")}` : "",
6314
+ flaggedCount ? `${pc20.dim("flagged ")} ${pc20.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc20.bold("nexusmem stale")} for detail` : ""
5995
6315
  ].filter((line) => line !== "").join("\n").concat("\n")
5996
6316
  );
5997
6317
  return 0;
@@ -6006,7 +6326,7 @@ function isExpected(err) {
6006
6326
  // the user fixes, not stack traces they debug.
6007
6327
  err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
6008
6328
  // (antivirus, a bad install). Actionable, and not our stack to print.
6009
- err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof DenyListError || err instanceof MarkStaleError;
6329
+ err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof DenyListError || err instanceof MarkStaleError || err instanceof QueryError || err instanceof ReviewError;
6010
6330
  }
6011
6331
  function guard(run) {
6012
6332
  return async () => {
@@ -6014,7 +6334,7 @@ function guard(run) {
6014
6334
  process.exitCode = await run();
6015
6335
  } catch (err) {
6016
6336
  if (isExpected(err)) {
6017
- process.stderr.write(`${pc20.red("error")} ${err.message}
6337
+ process.stderr.write(`${pc21.red("error")} ${err.message}
6018
6338
  `);
6019
6339
  process.exitCode = 1;
6020
6340
  return;
@@ -6077,7 +6397,7 @@ program.command("hook").description("Manage the opt-in PowerShell hook that logs
6077
6397
  )
6078
6398
  );
6079
6399
  program.command("status").description("Show what is currently remembered for this repository").option("-C, --cwd <path>", "repository path", process.cwd()).option("--share", "print a plain-text summary formatted for sharing, e.g. on X or Reddit").action((options) => guard(() => runStatus({ cwd: options.cwd, share: options.share }))());
6080
- program.command("query").description("Search remembered history and print a token-budgeted context block").argument("<text>", "free-text query").option("-C, --cwd <path>", "repository path", process.cwd()).option("-b, --budget <tokens>", "max tokens in the packed context", (v) => Number.parseInt(v, 10), 2e3).option("-n, --candidates <count>", "how many search hits to rank before packing", (v) => Number.parseInt(v, 10), 30).option("--half-life <days>", "days for a node's recency weight to halve", (v) => Number.parseFloat(v)).option("--no-vector", "BM25 only -- skip embedding the query and vector search").option("-a, --all-projects", "search every registered repository, not just this one", false).option("--json", "emit the packed result as JSON on stdout", false).action(
6400
+ program.command("query").description("Search remembered history and print a token-budgeted context block").argument("<text>", "free-text query").option("-C, --cwd <path>", "repository path", process.cwd()).option("-b, --budget <tokens>", "max tokens in the packed context", (v) => Number.parseInt(v, 10), 2e3).option("-n, --candidates <count>", "how many search hits to rank before packing", (v) => Number.parseInt(v, 10), 30).option("--half-life <days>", "days for a node's recency weight to halve", (v) => Number.parseFloat(v)).option("--no-vector", "BM25 only -- skip embedding the query and vector search").option("-a, --all-projects", "search every registered repository, not just this one", false).option("--as-of <date>", 'bi-temporal read: only nodes recorded at or before this date -- "what did the store hold then", not "what happened then"').option("--json", "emit the packed result as JSON on stdout", false).action(
6081
6401
  (text, options) => guard(
6082
6402
  () => runQuery({
6083
6403
  cwd: options.cwd,
@@ -6087,6 +6407,7 @@ program.command("query").description("Search remembered history and print a toke
6087
6407
  halfLifeDays: options.halfLife,
6088
6408
  noVector: !options.vector,
6089
6409
  allProjects: options.allProjects,
6410
+ asOf: options.asOf,
6090
6411
  json: options.json
6091
6412
  })
6092
6413
  )()
@@ -6113,20 +6434,29 @@ program.command("mark-stale").description(
6113
6434
  ).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(
6114
6435
  (nodeId, options) => guard(() => runMarkStale({ cwd: options.cwd, nodeId, supersedesId: options.supersedes }))()
6115
6436
  );
6116
- program.command("stale").description("List inferred nodes old enough to be worth double-checking (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--min-age-days <days>", "only nodes at least this old", (v) => Number.parseFloat(v)).option("-n, --limit <count>", "stop after N candidates", (v) => Number.parseInt(v, 10)).option(
6437
+ program.command("stale").description("List unconfirmed (non-observed) nodes old enough to be worth double-checking (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--min-age-days <days>", "only nodes at least this old", (v) => Number.parseFloat(v)).option("-n, --limit <count>", "stop after N candidates", (v) => Number.parseInt(v, 10)).option(
6117
6438
  "--check-contradictions",
6118
6439
  "ask the local SLM whether a similar newer node actually contradicts each candidate (needs Ollama)"
6119
- ).option("--model <name>", "Ollama chat model for --check-contradictions", STALE_DEFAULT_MODEL).action(
6440
+ ).option("--model <name>", "Ollama chat model for --check-contradictions", STALE_DEFAULT_MODEL).option("--dismiss <candidateId>", "reject the open contradiction suggestion for this node id so it stops resurfacing").action(
6120
6441
  (options) => guard(
6121
6442
  () => runStale({
6122
6443
  cwd: options.cwd,
6123
6444
  minAgeDays: options.minAgeDays,
6124
6445
  limit: options.limit,
6125
6446
  checkContradictions: options.checkContradictions,
6126
- model: options.model
6447
+ model: options.model,
6448
+ dismiss: options.dismiss
6127
6449
  })
6128
6450
  )()
6129
6451
  );
6452
+ program.command("review").description("Record a human verdict on one node: --verify or --reject (a rejected node is down-weighted in ranking, never deleted)").argument("<nodeId>", "id of the node being reviewed").option("-C, --cwd <path>", "repository path", process.cwd()).option("--verify", "mark the node verified (label only, no ranking change)", false).option("--reject", "mark the node rejected (down-weighted in ranking, still queryable)", false).action(
6453
+ (nodeId, options) => guard(async () => {
6454
+ if (options.verify === options.reject) {
6455
+ throw new ReviewError("pass exactly one of --verify or --reject");
6456
+ }
6457
+ return runReview({ cwd: options.cwd, nodeId, verdict: options.verify ? "verified" : "rejected" });
6458
+ })()
6459
+ );
6130
6460
  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 }))());
6131
6461
  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(
6132
6462
  (options) => guard(
@@ -6187,7 +6517,7 @@ program.command("scan-structure").description("Preview the JS/TS/Python/Go/Rust/
6187
6517
  program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
6188
6518
  program.parseAsync(process.argv).catch((err) => {
6189
6519
  const message = err instanceof Error ? err.message : String(err);
6190
- process.stderr.write(`${pc20.red("error")} ${message}
6520
+ process.stderr.write(`${pc21.red("error")} ${message}
6191
6521
  `);
6192
6522
  process.exitCode = 1;
6193
6523
  });