nexusmem 0.7.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -11,6 +11,29 @@ built from, matched by publish timestamp: `v0.1.0` → `67a4776`, `v0.1.1` → `
11
11
 
12
12
  No unreleased changes yet.
13
13
 
14
+ ## [0.8.0] — 2026-08-22
15
+
16
+ ### Added
17
+
18
+ - Provenance widened from 2 tiers to a 4-tier trust hierarchy: `observed` (commits, diffs, shell
19
+ exit codes) > `authored` (doc sections — human-written claims) > `recorded` (conversation turns —
20
+ verbatim discourse) > `derived` (session summaries — a model's distillation). Schema V7 backfills
21
+ existing databases by kind. The ranker decays each tier at its own rate (lower trust fades
22
+ faster); the ordering is the design claim, the exact ratios are documented judgment calls.
23
+ - Contradiction checking now runs automatically during `sync`: at most 3 new SLM judgments per run
24
+ (`contradictions.maxPerSync`; `contradictions.autoCheck: false` turns it off), gated on the
25
+ embedding provider already being reachable. Every judgment — either verdict — is memoized in a new
26
+ `contradiction_checks` table (schema V8), so a judged pair is never sent to the model again and
27
+ repeat syncs converge to zero model calls. Measured live against this repo's own database: 5.4s
28
+ for the first 10 judgments, 0.5s for the identical re-run. Still suggest-only: nothing ever writes
29
+ `supersedes` automatically.
30
+ - Standing suggestions now surface everywhere without a model in the loop: plain `nexusmem stale`
31
+ decorates flagged candidates from the memoized judgments (instant, offline), `nexusmem status`
32
+ gains a `flagged` line, and the sync summary reports new/open suggestion counts.
33
+ - `stale --check-contradictions` reuses each candidate's stored embedding instead of re-embedding
34
+ it, and stops after two consecutive null SLM replies so a down provider costs at most two timeouts
35
+ rather than one per candidate.
36
+
14
37
  ## [0.7.0] — 2026-08-21
15
38
 
16
39
  ### Added
@@ -481,7 +504,8 @@ First public release.
481
504
  there is no local-model summarization pass, and the conversation collector has never been audited
482
505
  for the stale-node bug that was found and fixed in the docs collector.
483
506
 
484
- [Unreleased]: https://github.com/yaminbkk/NexusMem/compare/v0.7.0...HEAD
507
+ [Unreleased]: https://github.com/yaminbkk/NexusMem/compare/v0.8.0...HEAD
508
+ [0.8.0]: https://github.com/yaminbkk/NexusMem/compare/v0.7.0...v0.8.0
485
509
  [0.7.0]: https://github.com/yaminbkk/NexusMem/compare/v0.6.0...v0.7.0
486
510
  [0.6.0]: https://github.com/yaminbkk/NexusMem/compare/v0.5.4...v0.6.0
487
511
  [0.5.4]: https://github.com/yaminbkk/NexusMem/compare/v0.5.3...v0.5.4
package/README.md CHANGED
@@ -24,7 +24,7 @@ exists nowhere else, and it disappears when your terminal scrollback rolls over.
24
24
  **Contents:** [Try it](#try-it) · [Exact shell capture](#optional-exact-shell-capture) ·
25
25
  [Failure → fix chains](#failure--fix-chains-opt-in) · [How retrieval works](#how-retrieval-works) ·
26
26
  [Session summaries](#session-summaries-optional-local-model) · [Use it from an agent](#use-it-from-an-agent)
27
- · [What it costs you](#what-it-costs-you) · [Manual staleness & provenance](#manual-staleness--provenance) ·
27
+ · [What it costs you](#what-it-costs-you) · [Staleness & provenance](#staleness--provenance) ·
28
28
  [Where it breaks](#where-it-breaks) · [Commands](#commands) · [Cross-project recall](#recall-across-projects)
29
29
  · [On disk](#on-disk) · [Development](#development)
30
30
 
@@ -48,11 +48,11 @@ Relevant history for: windows spawn failure
48
48
  readRepoInfo collapsed three unrelated failures into one error: git running and reporting
49
49
  the path is not a work tree, git not being installed, and the process failing to spawn at
50
50
  all. Dogfooding hit the third case in two separate sessions...
51
- - 2026-08-09 [inferred] README.md — Before a tagged release
51
+ - 2026-08-09 [authored] README.md — Before a tagged release
52
52
  - [ ] Retry on transient process-spawn failures on Windows
53
53
  ```
54
54
 
55
- `[observed]`/`[inferred]` is the provenance tag (see [Manual staleness & provenance](#manual-staleness--provenance))
55
+ `[observed]`/`[authored]` is the provenance tag (see [Staleness & provenance](#staleness--provenance))
56
56
  — a commit is a directly observed event, a doc section is a written claim that could go stale.
57
57
 
58
58
  A commit and a docs section, ranked against each other, inside whatever token budget you gave it.
@@ -269,19 +269,23 @@ Two things a memory layer needs and this one only partly has: a way to tell an o
269
269
  guess, and a way to retire a conclusion once something contradicts it. This section is what exists
270
270
  and what doesn't.
271
271
 
272
- Every node carries a `provenance`: `observed` (a commit that landed, a shell command's real exit
273
- code) or `inferred` (a conversation turn, a session summary, a doc section all readable as claims
274
- that could be wrong or go stale). Set once per collector at ingest time, shown as a `[observed]` /
275
- `[inferred]` tag on every query result, and now used to decay retrieval weight too `inferred` nodes
276
- fade from ranking twice as fast as `observed` ones as they age.
272
+ Every node carries a `provenance`, a four-tier trust hierarchy set once per collector at ingest
273
+ time: `observed` (a commit that landed, a shell command's real exit code) > `authored` (a doc
274
+ section a human's own written claim) > `recorded` (a conversation turn verbatim, but talk about
275
+ events rather than the events) > `derived` (a session summary a model's distillation). The tier is
276
+ shown as a tag on every query result and decays retrieval weight — the lower the trust, the faster a
277
+ node fades from ranking as it ages. The ordering is the design claim; the exact decay ratios are
278
+ judgment calls, not measured optima.
277
279
 
278
280
  ```bash
279
281
  nexusmem stale
280
282
  ```
281
283
 
282
- Lists `inferred` nodes old enough (45+ days by default) that nothing has confirmed they still hold —
283
- a heuristic on age and provenance, not on content. It writes nothing; you decide which candidates are
284
- actually wrong.
284
+ Lists non-`observed` nodes old enough (45+ days by default) that nothing has confirmed they still
285
+ hold — a heuristic on age and provenance, not on content. It writes nothing; you decide which
286
+ candidates are actually wrong. Any candidate the SLM has already flagged (see below) is decorated
287
+ with its standing `likely superseded by` suggestion — reading those costs nothing, so the plain
288
+ command stays instant and offline.
285
289
 
286
290
  ```bash
287
291
  nexusmem mark-stale <oldNodeId> --supersedes <newNodeId>
@@ -297,15 +301,20 @@ nexusmem stale --check-contradictions
297
301
  For each candidate, finds the most similar newer node (local embedding search) and asks a local SLM
298
302
  (Ollama, `qwen2.5:3b` by default) whether it actually contradicts the older one — real content
299
303
  comparison, not just age. A match is printed as `likely superseded by <id> <title> -- <reason>`
300
- under the candidate; nothing is written, same as plain `stale`. Needs Ollama running; costs one
301
- embedding call and, when a plausible newer node exists, one chat completion per candidate (capped at
302
- 10 by default — pass `-n` to raise it).
304
+ under the candidate. Every judgment (either verdict) is memoized, so a judged pair is never sent to
305
+ the model again; nothing else is written `supersedes` stays yours to set via `mark-stale`.
306
+
307
+ **This also runs automatically during `sync`** — at most 3 new judgments per run (configurable via
308
+ the `contradictions` block in `.nexusmem/config.json`; set `autoCheck: false` to turn it off), only
309
+ when the embedding provider was reachable anyway, and free on repeat syncs thanks to the
310
+ memoization. New and open suggestions show up in the sync summary, `nexusmem status` (a `flagged`
311
+ line), and plain `nexusmem stale`.
303
312
 
304
313
  **What this doesn't do:** it is one small model's yes/no judgment on one older/newer pair, not a
305
314
  verified fact — treat a match as a lead to check, not a conclusion. It also only ever compares a
306
315
  candidate against nodes *found by embedding similarity*; a contradiction from an unrelated-sounding
307
- node would never surface. Real contradiction detection (comprehensively, not just for the pair the
308
- vector search happens to surface) is still an open problem.
316
+ node would never surface. Comprehensive contradiction detection (not just for the pair the vector
317
+ search happens to surface) is still an open problem, and nothing here supersedes a node on its own.
309
318
 
310
319
  ## Where it breaks
311
320
 
package/dist/cli/index.js CHANGED
@@ -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 });
@@ -944,13 +957,32 @@ UPDATE nodes SET provenance = 'observed' WHERE kind IN ('git_commit', 'code_diff
944
957
 
945
958
  CREATE INDEX idx_nodes_supersedes ON nodes (supersedes) WHERE supersedes IS NOT NULL;
946
959
  `;
960
+ var V7 = `
961
+ UPDATE nodes SET provenance = 'authored' WHERE kind IN ('doc_section', 'note');
962
+ UPDATE nodes SET provenance = 'recorded' WHERE kind = 'conversation_turn';
963
+ UPDATE nodes SET provenance = 'derived' WHERE kind = 'session_summary';
964
+ UPDATE nodes SET provenance = 'recorded' WHERE provenance = 'inferred';
965
+ `;
966
+ var V8 = `
967
+ CREATE TABLE contradiction_checks (
968
+ candidate_id TEXT NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
969
+ against_id TEXT NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
970
+ contradicts INTEGER NOT NULL,
971
+ reason TEXT,
972
+ model TEXT NOT NULL,
973
+ checked_at INTEGER NOT NULL,
974
+ PRIMARY KEY (candidate_id, against_id)
975
+ );
976
+ `;
947
977
  var MIGRATIONS = [
948
978
  { version: 1, up: (db) => db.exec(V1) },
949
979
  { version: 2, up: (db) => db.exec(V2) },
950
980
  { version: 3, up: (db) => db.exec(V3) },
951
981
  { version: 4, up: (db) => db.exec(V4) },
952
982
  { version: 5, up: (db) => db.exec(V5) },
953
- { version: 6, up: (db) => db.exec(V6) }
983
+ { version: 6, up: (db) => db.exec(V6) },
984
+ { version: 7, up: (db) => db.exec(V7) },
985
+ { version: 8, up: (db) => db.exec(V8) }
954
986
  ];
955
987
  var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
956
988
  function currentSchemaVersion(db) {
@@ -1012,11 +1044,13 @@ function defaultProvenanceForKind(kind) {
1012
1044
  case "code_diff":
1013
1045
  case "shell_command":
1014
1046
  return "observed";
1015
- case "conversation_turn":
1016
- case "session_summary":
1017
1047
  case "doc_section":
1018
1048
  case "note":
1019
- return "inferred";
1049
+ return "authored";
1050
+ case "conversation_turn":
1051
+ return "recorded";
1052
+ case "session_summary":
1053
+ return "derived";
1020
1054
  }
1021
1055
  }
1022
1056
 
@@ -1171,7 +1205,7 @@ function listStaleCandidates(db, projectId, opts = {}) {
1171
1205
  const rows = db.prepare(
1172
1206
  `SELECT id, kind, ts, ts_epoch AS tsEpoch, source, title
1173
1207
  FROM nodes
1174
- WHERE project_id = @projectId AND provenance = 'inferred' AND ts_epoch < @cutoff
1208
+ WHERE project_id = @projectId AND provenance != 'observed' AND ts_epoch < @cutoff
1175
1209
  AND id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)
1176
1210
  ORDER BY ts_epoch ASC
1177
1211
  LIMIT @limit`
@@ -1191,7 +1225,7 @@ function countStaleCandidates(db, projectId, opts = {}) {
1191
1225
  const cutoff = now.getTime() - minAgeDays * 864e5;
1192
1226
  const row = db.prepare(
1193
1227
  `SELECT COUNT(*) AS count FROM nodes
1194
- WHERE project_id = @projectId AND provenance = 'inferred' AND ts_epoch < @cutoff
1228
+ WHERE project_id = @projectId AND provenance != 'observed' AND ts_epoch < @cutoff
1195
1229
  AND id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)`
1196
1230
  ).get({ projectId, cutoff });
1197
1231
  return row.count;
@@ -1372,6 +1406,11 @@ function countNodesNeedingEmbedding(db, projectId) {
1372
1406
  ).get(projectId);
1373
1407
  return row.n;
1374
1408
  }
1409
+ function getEmbedding(db, nodeId) {
1410
+ 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);
1411
+ if (!row) return null;
1412
+ return new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4);
1413
+ }
1375
1414
  function upsertEmbedding(db, rowid, embedding) {
1376
1415
  db.prepare("INSERT OR REPLACE INTO nodes_vec (rowid, embedding) VALUES (?, ?)").run(BigInt(rowid), embedding);
1377
1416
  }
@@ -1449,6 +1488,49 @@ function setMeta(db, key, value) {
1449
1488
  );
1450
1489
  }
1451
1490
 
1491
+ // src/store/contradictions.ts
1492
+ function recordContradictionCheck(db, input) {
1493
+ db.prepare(
1494
+ `INSERT OR REPLACE INTO contradiction_checks (candidate_id, against_id, contradicts, reason, model, checked_at)
1495
+ VALUES (@candidateId, @againstId, @contradicts, @reason, @model, @now)`
1496
+ ).run({
1497
+ candidateId: input.candidateId,
1498
+ againstId: input.againstId,
1499
+ contradicts: input.contradicts ? 1 : 0,
1500
+ reason: input.reason,
1501
+ model: input.model,
1502
+ now: Date.now()
1503
+ });
1504
+ }
1505
+ function hasContradictionCheck(db, candidateId, againstId) {
1506
+ const row = db.prepare("SELECT 1 FROM contradiction_checks WHERE candidate_id = ? AND against_id = ?").get(candidateId, againstId);
1507
+ return row !== void 0;
1508
+ }
1509
+ function listContradictionSuggestions(db, projectId, opts = {}) {
1510
+ return db.prepare(
1511
+ `SELECT c.candidate_id AS candidateId, n.title AS candidateTitle,
1512
+ c.against_id AS againstId, a.title AS againstTitle,
1513
+ c.reason AS reason, c.checked_at AS checkedAt
1514
+ FROM contradiction_checks c
1515
+ JOIN nodes n ON n.id = c.candidate_id
1516
+ JOIN nodes a ON a.id = c.against_id
1517
+ WHERE n.project_id = @projectId AND c.contradicts = 1
1518
+ AND c.candidate_id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)
1519
+ ORDER BY c.checked_at DESC
1520
+ LIMIT @limit`
1521
+ ).all({ projectId, limit: opts.limit ?? 50 });
1522
+ }
1523
+ function countContradictionSuggestions(db, projectId) {
1524
+ const row = db.prepare(
1525
+ `SELECT COUNT(*) AS count
1526
+ FROM contradiction_checks c
1527
+ JOIN nodes n ON n.id = c.candidate_id
1528
+ WHERE n.project_id = @projectId AND c.contradicts = 1
1529
+ AND c.candidate_id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)`
1530
+ ).get({ projectId });
1531
+ return row.count;
1532
+ }
1533
+
1452
1534
  // src/store/store.ts
1453
1535
  var MemoryStore = class _MemoryStore {
1454
1536
  constructor(db) {
@@ -1608,6 +1690,10 @@ var MemoryStore = class _MemoryStore {
1608
1690
  upsertEmbedding(rowid, embedding) {
1609
1691
  upsertEmbedding(this.db, rowid, embedding);
1610
1692
  }
1693
+ /** The stored vector for one node, or null if it has not been embedded yet. */
1694
+ getEmbedding(nodeId) {
1695
+ return getEmbedding(this.db, nodeId);
1696
+ }
1611
1697
  dropAllEmbeddings() {
1612
1698
  return dropAllEmbeddings(this.db);
1613
1699
  }
@@ -1638,7 +1724,7 @@ var MemoryStore = class _MemoryStore {
1638
1724
  setSupersedes(newNodeId, staleNodeId) {
1639
1725
  setSupersedes(this.db, newNodeId, staleNodeId);
1640
1726
  }
1641
- /** Aging `inferred` nodes nothing supersedes yet -- candidates for `nexusmem mark-stale`, not auto-applied. */
1727
+ /** Aging non-`observed` nodes nothing supersedes yet -- candidates for `nexusmem mark-stale`, not auto-applied. */
1642
1728
  listStaleCandidates(projectId, opts = {}) {
1643
1729
  return listStaleCandidates(this.db, projectId, opts);
1644
1730
  }
@@ -1646,6 +1732,20 @@ var MemoryStore = class _MemoryStore {
1646
1732
  countStaleCandidates(projectId, opts = {}) {
1647
1733
  return countStaleCandidates(this.db, projectId, opts);
1648
1734
  }
1735
+ /** Memoize one SLM contradiction judgment (either verdict). Suggest-only: never writes `supersedes`. */
1736
+ recordContradictionCheck(input) {
1737
+ recordContradictionCheck(this.db, input);
1738
+ }
1739
+ hasContradictionCheck(candidateId, againstId) {
1740
+ return hasContradictionCheck(this.db, candidateId, againstId);
1741
+ }
1742
+ /** Open YES verdicts awaiting a human's `mark-stale`, newest judgment first. */
1743
+ listContradictionSuggestions(projectId, opts = {}) {
1744
+ return listContradictionSuggestions(this.db, projectId, opts);
1745
+ }
1746
+ countContradictionSuggestions(projectId) {
1747
+ return countContradictionSuggestions(this.db, projectId);
1748
+ }
1649
1749
  /** Escape hatch for tests and future modules. */
1650
1750
  get raw() {
1651
1751
  return this.db;
@@ -2458,7 +2558,12 @@ var SIGNAL_FLOOR = 0.2;
2458
2558
  var RECENCY_FLOOR = 0.3;
2459
2559
  var DEFAULT_HALF_LIFE_DAYS = 30;
2460
2560
  var MS_PER_DAY = 864e5;
2461
- var INFERRED_HALF_LIFE_RATIO = 0.5;
2561
+ var HALF_LIFE_RATIO = {
2562
+ observed: 1,
2563
+ authored: 0.75,
2564
+ recorded: 0.5,
2565
+ derived: 0.35
2566
+ };
2462
2567
  var SUPERSEDED_PENALTY = 0.5;
2463
2568
  var MAX_PRIOR_OVERTURN = 2;
2464
2569
  var PRIOR_COUNT = 2;
@@ -2493,14 +2598,14 @@ function ageDaysOf(ts, now) {
2493
2598
  function rankHits(hits, opts = {}) {
2494
2599
  if (hits.length === 0) return [];
2495
2600
  const halfLife = opts.halfLifeDays ?? DEFAULT_HALF_LIFE_DAYS;
2496
- const inferredHalfLife = opts.inferredHalfLifeDays ?? halfLife * INFERRED_HALF_LIFE_RATIO;
2601
+ const ratios = { ...HALF_LIFE_RATIO, ...opts.halfLifeRatios };
2497
2602
  const now = opts.now ?? /* @__PURE__ */ new Date();
2498
2603
  const relevances = opts.relevanceScores ? normalizeExternalRelevance(hits, opts.relevanceScores) : normalizeRelevance(hits);
2499
2604
  const ranked = hits.map((hit, i) => {
2500
2605
  const relevance = relevances[i] ?? RELEVANCE_FLOOR;
2501
2606
  const signalWeight = SIGNAL_FLOOR + (1 - SIGNAL_FLOOR) * hit.signal;
2502
2607
  const ageDays = ageDaysOf(hit.ts, now);
2503
- const effectiveHalfLife = hit.provenance === "inferred" ? inferredHalfLife : halfLife;
2608
+ const effectiveHalfLife = halfLife * (ratios[hit.provenance] ?? 1);
2504
2609
  const recencyFactor = RECENCY_FLOOR + (1 - RECENCY_FLOOR) * 2 ** (-ageDays / effectiveHalfLife);
2505
2610
  const rawScore = relevance * signalWeight ** SIGNAL_EXPONENT * recencyFactor ** RECENCY_EXPONENT;
2506
2611
  const score = opts.supersededIds?.has(hit.id) ? rawScore * SUPERSEDED_PENALTY : rawScore;
@@ -2830,8 +2935,8 @@ function toMemoryNodes(turn, projectId, opts = {}) {
2830
2935
  files: extractMentionedFiles(`${userRedacted.text}
2831
2936
  ${chunk2.text}`),
2832
2937
  signal: scoreConversationTurn(userRedacted.text, chunk2.text),
2833
- provenance: "inferred",
2834
- // discourse about what happened, not the event itself
2938
+ provenance: "recorded",
2939
+ // verbatim discourse about what happened, not the event itself
2835
2940
  meta: {
2836
2941
  cwd: turn.cwd,
2837
2942
  source: turn.source,
@@ -3403,8 +3508,8 @@ function toMemoryNodes3(file, projectId, opts = {}) {
3403
3508
  body: truncate(chunk2.text, maxBody),
3404
3509
  files: [{ path: file.path, insertions: null, deletions: null, binary: false }],
3405
3510
  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
3511
+ provenance: "authored",
3512
+ // a human's own written claim -- deliberate, but can still go stale
3408
3513
  meta: {
3409
3514
  path: file.path,
3410
3515
  heading: chunk2.heading,
@@ -3566,7 +3671,7 @@ ${summary.body}`;
3566
3671
  files: extractMentionedFiles(session.turns.map((t) => `${t.userText}
3567
3672
  ${t.assistantText}`).join("\n")),
3568
3673
  signal: scoreSession(session.turns.length),
3569
- provenance: "inferred",
3674
+ provenance: "derived",
3570
3675
  // a model's distillation, not a directly observed event
3571
3676
  meta: {
3572
3677
  sessionKey: session.sessionKey,
@@ -3820,6 +3925,97 @@ async function readDocFiles(repoRoot, opts = {}) {
3820
3925
  return { files, unreadable };
3821
3926
  }
3822
3927
 
3928
+ // src/slm/contradiction.ts
3929
+ var MAX_BODY_CHARS = 1500;
3930
+ var MAX_REASON_CHARS = 200;
3931
+ var CONTRADICTION_INSTRUCTIONS = `You are checking whether a NEWER memory replaces or contradicts an OLDER one, for an AI coding assistant's memory index.
3932
+
3933
+ Answer in exactly this shape:
3934
+ VERDICT: YES or NO
3935
+ REASON: <one line, under 20 words>
3936
+
3937
+ 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.`;
3938
+ function buildContradictionPrompt(older, newer) {
3939
+ const body = [
3940
+ `OLDER (${older.title}):`,
3941
+ truncate(older.body, MAX_BODY_CHARS),
3942
+ "",
3943
+ `NEWER (${newer.title}):`,
3944
+ truncate(newer.body, MAX_BODY_CHARS)
3945
+ ].join("\n");
3946
+ return `${CONTRADICTION_INSTRUCTIONS}
3947
+
3948
+ ---
3949
+
3950
+ ${body}
3951
+
3952
+ ---
3953
+
3954
+ Answer:`;
3955
+ }
3956
+ function parseContradictionVerdict(raw) {
3957
+ const lines = raw.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
3958
+ const verdictLine = lines.find((l) => /^VERDICT:/i.test(l));
3959
+ if (!verdictLine) return null;
3960
+ const verdict = /^VERDICT:\s*(YES|NO)\b/i.exec(verdictLine);
3961
+ if (!verdict) return null;
3962
+ const reasonLine = lines.find((l) => /^REASON:/i.test(l));
3963
+ const reason = reasonLine ? reasonLine.replace(/^REASON:\s*/i, "").trim() : "";
3964
+ return {
3965
+ contradicts: verdict[1].toUpperCase() === "YES",
3966
+ reason: truncate(reason, MAX_REASON_CHARS)
3967
+ };
3968
+ }
3969
+
3970
+ // src/retrieval/contradiction.ts
3971
+ var DEFAULT_LIMIT = 10;
3972
+ var DEFAULT_NEIGHBOR_LIMIT = 25;
3973
+ async function checkContradictions(store, embeddingProvider, slmProvider, projectId, candidates, opts = {}) {
3974
+ const limit = opts.limit ?? DEFAULT_LIMIT;
3975
+ const neighborLimit = opts.neighborLimit ?? DEFAULT_NEIGHBOR_LIMIT;
3976
+ const model = opts.model ?? DEFAULT_SLM_MODEL;
3977
+ const suggestions = [];
3978
+ let judgments = 0;
3979
+ let consecutiveNullReplies = 0;
3980
+ for (const candidate of candidates.slice(0, limit)) {
3981
+ if (opts.maxJudgments !== void 0 && judgments >= opts.maxJudgments) break;
3982
+ const full = store.getNodesByIds([candidate.id])[0];
3983
+ if (!full) continue;
3984
+ const embedding = store.getEmbedding(candidate.id) ?? await embeddingProvider.embed(`${full.title}
3985
+ ${full.body}`);
3986
+ if (!embedding) continue;
3987
+ const candidateEpoch = Date.parse(candidate.ts);
3988
+ const nearest = store.vectorSearch(projectId, embedding, neighborLimit + 1).find((hit) => hit.id !== candidate.id && Date.parse(hit.ts) > candidateEpoch);
3989
+ if (!nearest) continue;
3990
+ if (store.hasContradictionCheck(candidate.id, nearest.id)) continue;
3991
+ const reply = await slmProvider.complete(buildContradictionPrompt(full, nearest));
3992
+ if (!reply) {
3993
+ consecutiveNullReplies += 1;
3994
+ if (consecutiveNullReplies >= 2) break;
3995
+ continue;
3996
+ }
3997
+ consecutiveNullReplies = 0;
3998
+ const verdict = parseContradictionVerdict(reply);
3999
+ if (!verdict) continue;
4000
+ judgments += 1;
4001
+ store.recordContradictionCheck({
4002
+ candidateId: candidate.id,
4003
+ againstId: nearest.id,
4004
+ contradicts: verdict.contradicts,
4005
+ reason: verdict.contradicts ? verdict.reason : null,
4006
+ model
4007
+ });
4008
+ if (!verdict.contradicts) continue;
4009
+ suggestions.push({
4010
+ candidateId: candidate.id,
4011
+ againstId: nearest.id,
4012
+ againstTitle: nearest.title,
4013
+ reason: verdict.reason
4014
+ });
4015
+ }
4016
+ return suggestions;
4017
+ }
4018
+
3823
4019
  // src/shell/detect.ts
3824
4020
  import { existsSync as existsSync4 } from "fs";
3825
4021
  import { readFile as readFile9, stat as stat2 } from "fs/promises";
@@ -4929,6 +5125,21 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
4929
5125
  `);
4930
5126
  return 0;
4931
5127
  }
5128
+ async function runAutoContradictionCheck(store, config, projectId, providers) {
5129
+ if (!config.contradictions.autoCheck) return "";
5130
+ const candidates = store.listStaleCandidates(projectId);
5131
+ if (candidates.length === 0) return "";
5132
+ const fresh = await checkContradictions(store, providers.embedder, providers.slm, projectId, candidates, {
5133
+ limit: candidates.length,
5134
+ maxJudgments: config.contradictions.maxPerSync,
5135
+ model: config.contradictions.model
5136
+ });
5137
+ const open = store.countContradictionSuggestions(projectId);
5138
+ if (fresh.length === 0 && open === 0) return "";
5139
+ const freshPart = fresh.length > 0 ? pc7.yellow(`${fresh.length} new`) : `${fresh.length} new`;
5140
+ return ` ${pc7.dim("contradictions:")} ${freshPart}${pc7.dim(`, ${open} open suggestion(s) -- run`)} ${pc7.bold("nexusmem stale")} ${pc7.dim("for detail")}
5141
+ `;
5142
+ }
4932
5143
  async function runSync(opts) {
4933
5144
  const { repo, ws, projectId, config } = await loadContext(opts.cwd);
4934
5145
  const log = (line) => {
@@ -4982,6 +5193,7 @@ async function runSync(opts) {
4982
5193
  const docs = await syncDocs(store, projectId, repo.root, config, log);
4983
5194
  const structure = await syncStructure(store, projectId, repo.root, config, log);
4984
5195
  let embedLine = "";
5196
+ let embeddingAvailable = false;
4985
5197
  if (!opts.noEmbed) {
4986
5198
  let lastLogged = 0;
4987
5199
  const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId, {
@@ -4993,6 +5205,7 @@ async function runSync(opts) {
4993
5205
  log(` ${pc7.dim(`vector: ${attempted}/${total} embedded`)}`);
4994
5206
  }
4995
5207
  });
5208
+ embeddingAvailable = !result.providerUnavailable;
4996
5209
  if (result.embedded > 0) {
4997
5210
  const skippedPart = result.skipped > 0 ? pc7.dim(`, ${result.skipped} skipped`) : "";
4998
5211
  const remainingPart = result.remaining > 0 ? pc7.yellow(`, ${result.remaining} still pending`) : "";
@@ -5002,6 +5215,10 @@ async function runSync(opts) {
5002
5215
  log(`${pc7.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
5003
5216
  }
5004
5217
  }
5218
+ const contradictionLine = !opts.noEmbed && embeddingAvailable ? await runAutoContradictionCheck(store, config, projectId, {
5219
+ embedder: new OllamaEmbeddingProvider(),
5220
+ slm: new OllamaChatProvider({ model: config.contradictions.model })
5221
+ }) : "";
5005
5222
  let linkLine = "";
5006
5223
  if (opts.linkFailures) {
5007
5224
  const linkStats = correlateFailures(store, projectId);
@@ -5030,7 +5247,7 @@ async function runSync(opts) {
5030
5247
  ` ${pc7.green(`+${totals.inserted} new`)} ${pc7.yellow(`~${totals.updated} updated`)} ${pc7.dim(`=${totals.unchanged} unchanged`)}${deniedPart}`,
5031
5248
  ` ${pc7.dim(`${stats2.total} node(s) total across ${stats2.distinctFiles} file path(s)`)}`,
5032
5249
  ""
5033
- ].join("\n") + embedLine + linkLine
5250
+ ].join("\n") + embedLine + linkLine + contradictionLine
5034
5251
  );
5035
5252
  return 0;
5036
5253
  } finally {
@@ -5787,80 +6004,6 @@ ${pc17.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKE
5787
6004
 
5788
6005
  // src/cli/commands/stale.ts
5789
6006
  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
5864
6007
  var STALE_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
5865
6008
  async function runStale(opts) {
5866
6009
  const { projectId, ws } = await loadContext(opts.cwd);
@@ -5869,7 +6012,7 @@ async function runStale(opts) {
5869
6012
  try {
5870
6013
  const candidates = store.listStaleCandidates(projectId, { minAgeDays: opts.minAgeDays, limit: opts.limit });
5871
6014
  if (candidates.length === 0) {
5872
- out(`${pc18.dim("no stale candidates")} -- no inferred node older than the threshold lacks a successor
6015
+ out(`${pc18.dim("no stale candidates")} -- no unconfirmed node older than the threshold lacks a successor
5873
6016
  `);
5874
6017
  return 0;
5875
6018
  }
@@ -5880,10 +6023,15 @@ async function runStale(opts) {
5880
6023
  new OllamaEmbeddingProvider(),
5881
6024
  new OllamaChatProvider({ model: opts.model ?? DEFAULT_SLM_MODEL }),
5882
6025
  projectId,
5883
- candidates
6026
+ candidates,
6027
+ { model: opts.model ?? DEFAULT_SLM_MODEL }
5884
6028
  );
5885
6029
  }
5886
- const byCandidateId = new Map(suggestions.map((s) => [s.candidateId, s]));
6030
+ const byCandidateId = /* @__PURE__ */ new Map();
6031
+ for (const s of store.listContradictionSuggestions(projectId)) {
6032
+ byCandidateId.set(s.candidateId, { againstId: s.againstId, againstTitle: s.againstTitle, reason: s.reason ?? "" });
6033
+ }
6034
+ for (const s of suggestions) byCandidateId.set(s.candidateId, s);
5887
6035
  out(
5888
6036
  [
5889
6037
  `${pc18.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
@@ -5963,6 +6111,7 @@ async function runStatus(opts) {
5963
6111
  const otherProjectNodes = store.countProjectNodes(otherProjectIds);
5964
6112
  const structure = store.fileEdgeStats(projectId);
5965
6113
  const staleCount = store.countStaleCandidates(projectId);
6114
+ const flaggedCount = store.countContradictionSuggestions(projectId);
5966
6115
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
5967
6116
  const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
5968
6117
  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(
@@ -5991,7 +6140,8 @@ async function runStatus(opts) {
5991
6140
  gitCursor && gitCursor !== repo.head ? `${pc19.yellow("git behind HEAD")} \u2014 run ${pc19.bold("nexusmem sync")}` : "",
5992
6141
  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
6142
  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")}` : ""
6143
+ staleCount ? `${pc19.dim("aging ")} ${pc19.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc19.bold("nexusmem stale")}` : "",
6144
+ flaggedCount ? `${pc19.dim("flagged ")} ${pc19.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc19.bold("nexusmem stale")} for detail` : ""
5995
6145
  ].filter((line) => line !== "").join("\n").concat("\n")
5996
6146
  );
5997
6147
  return 0;
@@ -6113,7 +6263,7 @@ program.command("mark-stale").description(
6113
6263
  ).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
6264
  (nodeId, options) => guard(() => runMarkStale({ cwd: options.cwd, nodeId, supersedesId: options.supersedes }))()
6115
6265
  );
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(
6266
+ 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
6267
  "--check-contradictions",
6118
6268
  "ask the local SLM whether a similar newer node actually contradicts each candidate (needs Ollama)"
6119
6269
  ).option("--model <name>", "Ollama chat model for --check-contradictions", STALE_DEFAULT_MODEL).action(