nexusmem 0.6.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/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";
@@ -4216,6 +4412,8 @@ function extractPhpIncludeSpecifiers(source) {
4216
4412
 
4217
4413
  // src/structure/extract-python.ts
4218
4414
  var RELATIVE_IMPORT_PATTERN = /\bfrom\s+(\.+)([\w.]*)\s+import\s+([^\n]+)/g;
4415
+ var BARE_FROM_IMPORT_PATTERN = /^[ \t]*from\s+([A-Za-z_]\w*)\s+import\b/gm;
4416
+ var BARE_IMPORT_PATTERN = /^[ \t]*import\s+([^\n]+)/gm;
4219
4417
  function cleanNames(raw) {
4220
4418
  return raw.split("#")[0].replace(/[()]/g, "").split(",").map((token) => token.trim().split(/\s+as\s+/)[0].trim()).filter((name) => /^[A-Za-z_]\w*$/.test(name));
4221
4419
  }
@@ -4234,6 +4432,16 @@ function extractPythonImportSpecifiers(source) {
4234
4432
  seen.add(dots + name);
4235
4433
  }
4236
4434
  }
4435
+ BARE_FROM_IMPORT_PATTERN.lastIndex = 0;
4436
+ while ((match = BARE_FROM_IMPORT_PATTERN.exec(source)) !== null) {
4437
+ seen.add(match[1]);
4438
+ }
4439
+ BARE_IMPORT_PATTERN.lastIndex = 0;
4440
+ while ((match = BARE_IMPORT_PATTERN.exec(source)) !== null) {
4441
+ for (const name of cleanNames(match[1])) {
4442
+ seen.add(name);
4443
+ }
4444
+ }
4237
4445
  return [...seen];
4238
4446
  }
4239
4447
 
@@ -4314,7 +4522,9 @@ function resolveJavaSpecifier(specifier, trackedPaths) {
4314
4522
  if (segments.length === 0) return [];
4315
4523
  if (isWildcard) {
4316
4524
  const dirSuffix = segments.join("/");
4317
- return [...trackedPaths].filter((p) => p.endsWith(".java") && endsAtSegmentBoundary(posix3.dirname(p), dirSuffix)).sort();
4525
+ const matches2 = [...trackedPaths].filter((p) => p.endsWith(".java") && endsAtSegmentBoundary(posix3.dirname(p), dirSuffix));
4526
+ const dirs = new Set(matches2.map((p) => posix3.dirname(p)));
4527
+ return dirs.size === 1 ? matches2.sort() : [];
4318
4528
  }
4319
4529
  const fileSuffix = `${segments.join("/")}.java`;
4320
4530
  const matches = [...trackedPaths].filter((p) => endsAtSegmentBoundary(p, fileSuffix));
@@ -4335,11 +4545,182 @@ function resolvePhpSpecifier(fromPath, specifier, trackedPaths) {
4335
4545
 
4336
4546
  // src/structure/resolve-python.ts
4337
4547
  import { posix as posix5 } from "path";
4548
+ var STDLIB_MODULES = /* @__PURE__ */ new Set([
4549
+ "__future__",
4550
+ "abc",
4551
+ "argparse",
4552
+ "array",
4553
+ "ast",
4554
+ "asyncio",
4555
+ "base64",
4556
+ "bisect",
4557
+ "builtins",
4558
+ "calendar",
4559
+ "cgi",
4560
+ "cgitb",
4561
+ "cmd",
4562
+ "codecs",
4563
+ "collections",
4564
+ "colorsys",
4565
+ "compileall",
4566
+ "concurrent",
4567
+ "configparser",
4568
+ "contextlib",
4569
+ "contextvars",
4570
+ "copy",
4571
+ "copyreg",
4572
+ "cProfile",
4573
+ "csv",
4574
+ "ctypes",
4575
+ "curses",
4576
+ "dataclasses",
4577
+ "datetime",
4578
+ "dbm",
4579
+ "decimal",
4580
+ "difflib",
4581
+ "dis",
4582
+ "doctest",
4583
+ "email",
4584
+ "encodings",
4585
+ "ensurepip",
4586
+ "enum",
4587
+ "errno",
4588
+ "faulthandler",
4589
+ "fcntl",
4590
+ "filecmp",
4591
+ "fileinput",
4592
+ "fnmatch",
4593
+ "fractions",
4594
+ "ftplib",
4595
+ "functools",
4596
+ "gc",
4597
+ "getopt",
4598
+ "getpass",
4599
+ "gettext",
4600
+ "glob",
4601
+ "graphlib",
4602
+ "grp",
4603
+ "gzip",
4604
+ "hashlib",
4605
+ "heapq",
4606
+ "hmac",
4607
+ "html",
4608
+ "http",
4609
+ "imaplib",
4610
+ "importlib",
4611
+ "inspect",
4612
+ "io",
4613
+ "ipaddress",
4614
+ "itertools",
4615
+ "json",
4616
+ "keyword",
4617
+ "locale",
4618
+ "logging",
4619
+ "lzma",
4620
+ "mailbox",
4621
+ "marshal",
4622
+ "math",
4623
+ "mimetypes",
4624
+ "mmap",
4625
+ "msvcrt",
4626
+ "multiprocessing",
4627
+ "operator",
4628
+ "os",
4629
+ "pathlib",
4630
+ "pdb",
4631
+ "pickle",
4632
+ "pickletools",
4633
+ "pkgutil",
4634
+ "platform",
4635
+ "plistlib",
4636
+ "poplib",
4637
+ "posix",
4638
+ "pprint",
4639
+ "profile",
4640
+ "pstats",
4641
+ "pty",
4642
+ "pwd",
4643
+ "py_compile",
4644
+ "pyclbr",
4645
+ "pydoc",
4646
+ "queue",
4647
+ "quopri",
4648
+ "random",
4649
+ "re",
4650
+ "readline",
4651
+ "reprlib",
4652
+ "resource",
4653
+ "rlcompleter",
4654
+ "runpy",
4655
+ "sched",
4656
+ "secrets",
4657
+ "select",
4658
+ "selectors",
4659
+ "shelve",
4660
+ "shlex",
4661
+ "shutil",
4662
+ "signal",
4663
+ "site",
4664
+ "smtplib",
4665
+ "socket",
4666
+ "socketserver",
4667
+ "sqlite3",
4668
+ "ssl",
4669
+ "stat",
4670
+ "statistics",
4671
+ "string",
4672
+ "stringprep",
4673
+ "struct",
4674
+ "subprocess",
4675
+ "symtable",
4676
+ "sys",
4677
+ "sysconfig",
4678
+ "syslog",
4679
+ "tarfile",
4680
+ "telnetlib",
4681
+ "tempfile",
4682
+ "termios",
4683
+ "test",
4684
+ "textwrap",
4685
+ "threading",
4686
+ "time",
4687
+ "timeit",
4688
+ "tkinter",
4689
+ "token",
4690
+ "tokenize",
4691
+ "tomllib",
4692
+ "trace",
4693
+ "traceback",
4694
+ "tracemalloc",
4695
+ "tty",
4696
+ "turtle",
4697
+ "types",
4698
+ "typing",
4699
+ "unicodedata",
4700
+ "unittest",
4701
+ "urllib",
4702
+ "uuid",
4703
+ "venv",
4704
+ "warnings",
4705
+ "wave",
4706
+ "weakref",
4707
+ "webbrowser",
4708
+ "winreg",
4709
+ "winsound",
4710
+ "wsgiref",
4711
+ "xml",
4712
+ "xmlrpc",
4713
+ "zipapp",
4714
+ "zipfile",
4715
+ "zipimport",
4716
+ "zlib",
4717
+ "zoneinfo"
4718
+ ]);
4338
4719
  function resolvePythonSpecifier(fromPath, specifier, trackedPaths) {
4339
4720
  const dotsMatch = specifier.match(/^\.+/);
4340
- if (!dotsMatch) return null;
4341
- const level = dotsMatch[0].length;
4342
- const segments = specifier.slice(level).split(".").filter(Boolean);
4721
+ const level = dotsMatch ? dotsMatch[0].length : 0;
4722
+ if (level === 0 && STDLIB_MODULES.has(specifier)) return null;
4723
+ const segments = (level === 0 ? specifier : specifier.slice(level)).split(".").filter(Boolean);
4343
4724
  if (segments.length === 0) return null;
4344
4725
  let dir = posix5.dirname(fromPath);
4345
4726
  for (let i = 1; i < level; i++) {
@@ -4744,6 +5125,21 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
4744
5125
  `);
4745
5126
  return 0;
4746
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
+ }
4747
5143
  async function runSync(opts) {
4748
5144
  const { repo, ws, projectId, config } = await loadContext(opts.cwd);
4749
5145
  const log = (line) => {
@@ -4797,6 +5193,7 @@ async function runSync(opts) {
4797
5193
  const docs = await syncDocs(store, projectId, repo.root, config, log);
4798
5194
  const structure = await syncStructure(store, projectId, repo.root, config, log);
4799
5195
  let embedLine = "";
5196
+ let embeddingAvailable = false;
4800
5197
  if (!opts.noEmbed) {
4801
5198
  let lastLogged = 0;
4802
5199
  const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId, {
@@ -4808,6 +5205,7 @@ async function runSync(opts) {
4808
5205
  log(` ${pc7.dim(`vector: ${attempted}/${total} embedded`)}`);
4809
5206
  }
4810
5207
  });
5208
+ embeddingAvailable = !result.providerUnavailable;
4811
5209
  if (result.embedded > 0) {
4812
5210
  const skippedPart = result.skipped > 0 ? pc7.dim(`, ${result.skipped} skipped`) : "";
4813
5211
  const remainingPart = result.remaining > 0 ? pc7.yellow(`, ${result.remaining} still pending`) : "";
@@ -4817,6 +5215,10 @@ async function runSync(opts) {
4817
5215
  log(`${pc7.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
4818
5216
  }
4819
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
+ }) : "";
4820
5222
  let linkLine = "";
4821
5223
  if (opts.linkFailures) {
4822
5224
  const linkStats = correlateFailures(store, projectId);
@@ -4845,7 +5247,7 @@ async function runSync(opts) {
4845
5247
  ` ${pc7.green(`+${totals.inserted} new`)} ${pc7.yellow(`~${totals.updated} updated`)} ${pc7.dim(`=${totals.unchanged} unchanged`)}${deniedPart}`,
4846
5248
  ` ${pc7.dim(`${stats2.total} node(s) total across ${stats2.distinctFiles} file path(s)`)}`,
4847
5249
  ""
4848
- ].join("\n") + embedLine + linkLine
5250
+ ].join("\n") + embedLine + linkLine + contradictionLine
4849
5251
  );
4850
5252
  return 0;
4851
5253
  } finally {
@@ -5602,6 +6004,7 @@ ${pc17.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKE
5602
6004
 
5603
6005
  // src/cli/commands/stale.ts
5604
6006
  import pc18 from "picocolors";
6007
+ var STALE_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
5605
6008
  async function runStale(opts) {
5606
6009
  const { projectId, ws } = await loadContext(opts.cwd);
5607
6010
  const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
@@ -5609,16 +6012,35 @@ async function runStale(opts) {
5609
6012
  try {
5610
6013
  const candidates = store.listStaleCandidates(projectId, { minAgeDays: opts.minAgeDays, limit: opts.limit });
5611
6014
  if (candidates.length === 0) {
5612
- 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
5613
6016
  `);
5614
6017
  return 0;
5615
6018
  }
6019
+ let suggestions = [];
6020
+ if (opts.checkContradictions) {
6021
+ suggestions = await checkContradictions(
6022
+ store,
6023
+ new OllamaEmbeddingProvider(),
6024
+ new OllamaChatProvider({ model: opts.model ?? DEFAULT_SLM_MODEL }),
6025
+ projectId,
6026
+ candidates,
6027
+ { model: opts.model ?? DEFAULT_SLM_MODEL }
6028
+ );
6029
+ }
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);
5616
6035
  out(
5617
6036
  [
5618
6037
  `${pc18.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
5619
- ...candidates.map(
5620
- (c) => ` ${pc18.dim(c.id)} ${pc18.yellow(`${c.ageDays}d old`)} [${c.kind}] ${c.title}`
5621
- ),
6038
+ ...candidates.map((c) => {
6039
+ const line = ` ${pc18.dim(c.id)} ${pc18.yellow(`${c.ageDays}d old`)} [${c.kind}] ${c.title}`;
6040
+ const hit = byCandidateId.get(c.id);
6041
+ return hit ? `${line}
6042
+ ${pc18.red("likely superseded by")} ${pc18.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
6043
+ }),
5622
6044
  "",
5623
6045
  `run ${pc18.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
5624
6046
  ].join("\n").concat("\n")
@@ -5689,6 +6111,7 @@ async function runStatus(opts) {
5689
6111
  const otherProjectNodes = store.countProjectNodes(otherProjectIds);
5690
6112
  const structure = store.fileEdgeStats(projectId);
5691
6113
  const staleCount = store.countStaleCandidates(projectId);
6114
+ const flaggedCount = store.countContradictionSuggestions(projectId);
5692
6115
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
5693
6116
  const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
5694
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(
@@ -5717,7 +6140,8 @@ async function runStatus(opts) {
5717
6140
  gitCursor && gitCursor !== repo.head ? `${pc19.yellow("git behind HEAD")} \u2014 run ${pc19.bold("nexusmem sync")}` : "",
5718
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` : ""}` : "",
5719
6142
  structure.edges ? `${pc19.dim("structure")} ${pc19.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
5720
- 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` : ""
5721
6145
  ].filter((line) => line !== "").join("\n").concat("\n")
5722
6146
  );
5723
6147
  return 0;
@@ -5839,8 +6263,19 @@ program.command("mark-stale").description(
5839
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(
5840
6264
  (nodeId, options) => guard(() => runMarkStale({ cwd: options.cwd, nodeId, supersedesId: options.supersedes }))()
5841
6265
  );
5842
- 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)).action(
5843
- (options) => guard(() => runStale({ cwd: options.cwd, minAgeDays: options.minAgeDays, limit: options.limit }))()
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(
6267
+ "--check-contradictions",
6268
+ "ask the local SLM whether a similar newer node actually contradicts each candidate (needs Ollama)"
6269
+ ).option("--model <name>", "Ollama chat model for --check-contradictions", STALE_DEFAULT_MODEL).action(
6270
+ (options) => guard(
6271
+ () => runStale({
6272
+ cwd: options.cwd,
6273
+ minAgeDays: options.minAgeDays,
6274
+ limit: options.limit,
6275
+ checkContradictions: options.checkContradictions,
6276
+ model: options.model
6277
+ })
6278
+ )()
5844
6279
  );
5845
6280
  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 }))());
5846
6281
  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(
@@ -5898,7 +6333,7 @@ program.command("precheck").description("Warn about staged files with unresolved
5898
6333
  })
5899
6334
  )()
5900
6335
  );
5901
- program.command("scan-structure").description("Preview the JS/TS/Python import-graph edges a sync would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--json", "emit edges as JSON on stdout", false).action((options) => guard(() => runScanStructure({ cwd: options.cwd, json: options.json }))());
6336
+ program.command("scan-structure").description("Preview the JS/TS/Python/Go/Rust/Java/PHP import-graph edges a sync would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--json", "emit edges as JSON on stdout", false).action((options) => guard(() => runScanStructure({ cwd: options.cwd, json: options.json }))());
5902
6337
  program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
5903
6338
  program.parseAsync(process.argv).catch((err) => {
5904
6339
  const message = err instanceof Error ? err.message : String(err);