nexusmem 0.6.0 → 0.7.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
@@ -9,6 +9,36 @@ built from, matched by publish timestamp: `v0.1.0` → `67a4776`, `v0.1.1` → `
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ No unreleased changes yet.
13
+
14
+ ## [0.7.0] — 2026-08-21
15
+
16
+ ### Added
17
+
18
+ - `nexusmem stale --check-contradictions`: for each stale candidate, finds the most similar newer
19
+ node (local embedding search) and asks a local SLM (Ollama, `qwen2.5:3b` by default) whether it
20
+ actually contradicts the older one, instead of only surfacing by age. Suggest-only — nothing is
21
+ written, same as plain `stale`. Live-dogfooded against this repo's own real database and Ollama
22
+ instance; found and fixed a real gap along the way (below) before the feature surfaced anything
23
+ useful.
24
+ - Import graph: bare Python imports with no leading dot (`import foo`, `from foo import bar`)
25
+ now resolve to a same-directory sibling file too, alongside the existing relative-dot support.
26
+ Found dogfooding two real local projects: neither used a single PEP 328 relative import, both
27
+ relied entirely on this flat-script style. Guarded by a static list of stdlib module names so a
28
+ bare `import os`/`import queue`/etc. is never mistaken for a same-named local file.
29
+
30
+ ### Fixed
31
+
32
+ - Import graph: a Java wildcard import (`import a.b.*;`) no longer merges files from two
33
+ unrelated packages that happen to share a directory-name suffix (e.g. two Gradle/Maven modules
34
+ each with their own `.../foo`) — it now refuses to guess, same as the single-class-import case.
35
+ - `stale --check-contradictions`'s neighbor search now looks past same-timestamp sibling nodes
36
+ (e.g. the many chunks one long conversation gets split into) to reach genuinely newer content.
37
+ Found live-dogfooding against this repo's own database: the first real candidate's closest 15
38
+ neighbors were all same-conversation siblings sharing its exact timestamp, so the original
39
+ 5-neighbor default silently found zero suggestions for every candidate, regardless of what the
40
+ SLM would have said.
41
+
12
42
  ## [0.6.0] — 2026-08-20
13
43
 
14
44
  ### Added
@@ -451,7 +481,8 @@ First public release.
451
481
  there is no local-model summarization pass, and the conversation collector has never been audited
452
482
  for the stale-node bug that was found and fixed in the docs collector.
453
483
 
454
- [Unreleased]: https://github.com/yaminbkk/NexusMem/compare/v0.6.0...HEAD
484
+ [Unreleased]: https://github.com/yaminbkk/NexusMem/compare/v0.7.0...HEAD
485
+ [0.7.0]: https://github.com/yaminbkk/NexusMem/compare/v0.6.0...v0.7.0
455
486
  [0.6.0]: https://github.com/yaminbkk/NexusMem/compare/v0.5.4...v0.6.0
456
487
  [0.5.4]: https://github.com/yaminbkk/NexusMem/compare/v0.5.3...v0.5.4
457
488
  [0.5.3]: https://github.com/yaminbkk/NexusMem/compare/v0.5.2...v0.5.3
package/README.md CHANGED
@@ -290,10 +290,22 @@ nexusmem mark-stale <oldNodeId> --supersedes <newNodeId>
290
290
  Links `newNodeId` as the replacement for `oldNodeId`. The ranker down-weights the old node from then
291
291
  on (it stays queryable, just usually loses to its replacement) — nothing is deleted, unlike `forget`.
292
292
 
293
- **What this doesn't do:** nothing here reads content to detect a real contradiction. If a later commit
294
- contradicts an earlier doc section, `nexusmem stale` won't know that specifically — it only knows the
295
- doc section is old and inferred. Actual contradiction detection (comparing what two nodes claim, not
296
- just how old one is) is still an open problem.
293
+ ```bash
294
+ nexusmem stale --check-contradictions
295
+ ```
296
+
297
+ For each candidate, finds the most similar newer node (local embedding search) and asks a local SLM
298
+ (Ollama, `qwen2.5:3b` by default) whether it actually contradicts the older one — real content
299
+ 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).
303
+
304
+ **What this doesn't do:** it is one small model's yes/no judgment on one older/newer pair, not a
305
+ verified fact — treat a match as a lead to check, not a conclusion. It also only ever compares a
306
+ 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.
297
309
 
298
310
  ## Where it breaks
299
311
 
@@ -361,8 +373,9 @@ just how old one is) is still an open problem.
361
373
  ## Commands
362
374
 
363
375
  `init`, `sync`, `query <text>`, `status` (add `--share` for a plain-text summary worth pasting
364
- somewhere), `projects`, `mcp`, `forget <value>`, `stale`,
365
- `mark-stale <nodeId> --supersedes <newNodeId>`, and `hook install|remove|status`.
376
+ somewhere), `projects`, `mcp`, `forget <value>`, `stale` (add `--check-contradictions` for a local-SLM
377
+ content check, see above), `mark-stale <nodeId> --supersedes <newNodeId>`, and
378
+ `hook install|remove|status`.
366
379
 
367
380
  There are also five dry-run previews (`scan-git`, `scan-diff`, `scan-shell`, `scan-docs`,
368
381
  `scan-conversation`)
package/dist/cli/index.js CHANGED
@@ -4216,6 +4216,8 @@ function extractPhpIncludeSpecifiers(source) {
4216
4216
 
4217
4217
  // src/structure/extract-python.ts
4218
4218
  var RELATIVE_IMPORT_PATTERN = /\bfrom\s+(\.+)([\w.]*)\s+import\s+([^\n]+)/g;
4219
+ var BARE_FROM_IMPORT_PATTERN = /^[ \t]*from\s+([A-Za-z_]\w*)\s+import\b/gm;
4220
+ var BARE_IMPORT_PATTERN = /^[ \t]*import\s+([^\n]+)/gm;
4219
4221
  function cleanNames(raw) {
4220
4222
  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
4223
  }
@@ -4234,6 +4236,16 @@ function extractPythonImportSpecifiers(source) {
4234
4236
  seen.add(dots + name);
4235
4237
  }
4236
4238
  }
4239
+ BARE_FROM_IMPORT_PATTERN.lastIndex = 0;
4240
+ while ((match = BARE_FROM_IMPORT_PATTERN.exec(source)) !== null) {
4241
+ seen.add(match[1]);
4242
+ }
4243
+ BARE_IMPORT_PATTERN.lastIndex = 0;
4244
+ while ((match = BARE_IMPORT_PATTERN.exec(source)) !== null) {
4245
+ for (const name of cleanNames(match[1])) {
4246
+ seen.add(name);
4247
+ }
4248
+ }
4237
4249
  return [...seen];
4238
4250
  }
4239
4251
 
@@ -4314,7 +4326,9 @@ function resolveJavaSpecifier(specifier, trackedPaths) {
4314
4326
  if (segments.length === 0) return [];
4315
4327
  if (isWildcard) {
4316
4328
  const dirSuffix = segments.join("/");
4317
- return [...trackedPaths].filter((p) => p.endsWith(".java") && endsAtSegmentBoundary(posix3.dirname(p), dirSuffix)).sort();
4329
+ const matches2 = [...trackedPaths].filter((p) => p.endsWith(".java") && endsAtSegmentBoundary(posix3.dirname(p), dirSuffix));
4330
+ const dirs = new Set(matches2.map((p) => posix3.dirname(p)));
4331
+ return dirs.size === 1 ? matches2.sort() : [];
4318
4332
  }
4319
4333
  const fileSuffix = `${segments.join("/")}.java`;
4320
4334
  const matches = [...trackedPaths].filter((p) => endsAtSegmentBoundary(p, fileSuffix));
@@ -4335,11 +4349,182 @@ function resolvePhpSpecifier(fromPath, specifier, trackedPaths) {
4335
4349
 
4336
4350
  // src/structure/resolve-python.ts
4337
4351
  import { posix as posix5 } from "path";
4352
+ var STDLIB_MODULES = /* @__PURE__ */ new Set([
4353
+ "__future__",
4354
+ "abc",
4355
+ "argparse",
4356
+ "array",
4357
+ "ast",
4358
+ "asyncio",
4359
+ "base64",
4360
+ "bisect",
4361
+ "builtins",
4362
+ "calendar",
4363
+ "cgi",
4364
+ "cgitb",
4365
+ "cmd",
4366
+ "codecs",
4367
+ "collections",
4368
+ "colorsys",
4369
+ "compileall",
4370
+ "concurrent",
4371
+ "configparser",
4372
+ "contextlib",
4373
+ "contextvars",
4374
+ "copy",
4375
+ "copyreg",
4376
+ "cProfile",
4377
+ "csv",
4378
+ "ctypes",
4379
+ "curses",
4380
+ "dataclasses",
4381
+ "datetime",
4382
+ "dbm",
4383
+ "decimal",
4384
+ "difflib",
4385
+ "dis",
4386
+ "doctest",
4387
+ "email",
4388
+ "encodings",
4389
+ "ensurepip",
4390
+ "enum",
4391
+ "errno",
4392
+ "faulthandler",
4393
+ "fcntl",
4394
+ "filecmp",
4395
+ "fileinput",
4396
+ "fnmatch",
4397
+ "fractions",
4398
+ "ftplib",
4399
+ "functools",
4400
+ "gc",
4401
+ "getopt",
4402
+ "getpass",
4403
+ "gettext",
4404
+ "glob",
4405
+ "graphlib",
4406
+ "grp",
4407
+ "gzip",
4408
+ "hashlib",
4409
+ "heapq",
4410
+ "hmac",
4411
+ "html",
4412
+ "http",
4413
+ "imaplib",
4414
+ "importlib",
4415
+ "inspect",
4416
+ "io",
4417
+ "ipaddress",
4418
+ "itertools",
4419
+ "json",
4420
+ "keyword",
4421
+ "locale",
4422
+ "logging",
4423
+ "lzma",
4424
+ "mailbox",
4425
+ "marshal",
4426
+ "math",
4427
+ "mimetypes",
4428
+ "mmap",
4429
+ "msvcrt",
4430
+ "multiprocessing",
4431
+ "operator",
4432
+ "os",
4433
+ "pathlib",
4434
+ "pdb",
4435
+ "pickle",
4436
+ "pickletools",
4437
+ "pkgutil",
4438
+ "platform",
4439
+ "plistlib",
4440
+ "poplib",
4441
+ "posix",
4442
+ "pprint",
4443
+ "profile",
4444
+ "pstats",
4445
+ "pty",
4446
+ "pwd",
4447
+ "py_compile",
4448
+ "pyclbr",
4449
+ "pydoc",
4450
+ "queue",
4451
+ "quopri",
4452
+ "random",
4453
+ "re",
4454
+ "readline",
4455
+ "reprlib",
4456
+ "resource",
4457
+ "rlcompleter",
4458
+ "runpy",
4459
+ "sched",
4460
+ "secrets",
4461
+ "select",
4462
+ "selectors",
4463
+ "shelve",
4464
+ "shlex",
4465
+ "shutil",
4466
+ "signal",
4467
+ "site",
4468
+ "smtplib",
4469
+ "socket",
4470
+ "socketserver",
4471
+ "sqlite3",
4472
+ "ssl",
4473
+ "stat",
4474
+ "statistics",
4475
+ "string",
4476
+ "stringprep",
4477
+ "struct",
4478
+ "subprocess",
4479
+ "symtable",
4480
+ "sys",
4481
+ "sysconfig",
4482
+ "syslog",
4483
+ "tarfile",
4484
+ "telnetlib",
4485
+ "tempfile",
4486
+ "termios",
4487
+ "test",
4488
+ "textwrap",
4489
+ "threading",
4490
+ "time",
4491
+ "timeit",
4492
+ "tkinter",
4493
+ "token",
4494
+ "tokenize",
4495
+ "tomllib",
4496
+ "trace",
4497
+ "traceback",
4498
+ "tracemalloc",
4499
+ "tty",
4500
+ "turtle",
4501
+ "types",
4502
+ "typing",
4503
+ "unicodedata",
4504
+ "unittest",
4505
+ "urllib",
4506
+ "uuid",
4507
+ "venv",
4508
+ "warnings",
4509
+ "wave",
4510
+ "weakref",
4511
+ "webbrowser",
4512
+ "winreg",
4513
+ "winsound",
4514
+ "wsgiref",
4515
+ "xml",
4516
+ "xmlrpc",
4517
+ "zipapp",
4518
+ "zipfile",
4519
+ "zipimport",
4520
+ "zlib",
4521
+ "zoneinfo"
4522
+ ]);
4338
4523
  function resolvePythonSpecifier(fromPath, specifier, trackedPaths) {
4339
4524
  const dotsMatch = specifier.match(/^\.+/);
4340
- if (!dotsMatch) return null;
4341
- const level = dotsMatch[0].length;
4342
- const segments = specifier.slice(level).split(".").filter(Boolean);
4525
+ const level = dotsMatch ? dotsMatch[0].length : 0;
4526
+ if (level === 0 && STDLIB_MODULES.has(specifier)) return null;
4527
+ const segments = (level === 0 ? specifier : specifier.slice(level)).split(".").filter(Boolean);
4343
4528
  if (segments.length === 0) return null;
4344
4529
  let dir = posix5.dirname(fromPath);
4345
4530
  for (let i = 1; i < level; i++) {
@@ -5602,6 +5787,81 @@ ${pc17.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKE
5602
5787
 
5603
5788
  // src/cli/commands/stale.ts
5604
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
5864
+ var STALE_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
5605
5865
  async function runStale(opts) {
5606
5866
  const { projectId, ws } = await loadContext(opts.cwd);
5607
5867
  const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
@@ -5613,12 +5873,26 @@ async function runStale(opts) {
5613
5873
  `);
5614
5874
  return 0;
5615
5875
  }
5876
+ let suggestions = [];
5877
+ if (opts.checkContradictions) {
5878
+ suggestions = await checkContradictions(
5879
+ store,
5880
+ new OllamaEmbeddingProvider(),
5881
+ new OllamaChatProvider({ model: opts.model ?? DEFAULT_SLM_MODEL }),
5882
+ projectId,
5883
+ candidates
5884
+ );
5885
+ }
5886
+ const byCandidateId = new Map(suggestions.map((s) => [s.candidateId, s]));
5616
5887
  out(
5617
5888
  [
5618
5889
  `${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
- ),
5890
+ ...candidates.map((c) => {
5891
+ const line = ` ${pc18.dim(c.id)} ${pc18.yellow(`${c.ageDays}d old`)} [${c.kind}] ${c.title}`;
5892
+ const hit = byCandidateId.get(c.id);
5893
+ return hit ? `${line}
5894
+ ${pc18.red("likely superseded by")} ${pc18.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
5895
+ }),
5622
5896
  "",
5623
5897
  `run ${pc18.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
5624
5898
  ].join("\n").concat("\n")
@@ -5839,8 +6113,19 @@ program.command("mark-stale").description(
5839
6113
  ).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
6114
  (nodeId, options) => guard(() => runMarkStale({ cwd: options.cwd, nodeId, supersedesId: options.supersedes }))()
5841
6115
  );
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 }))()
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(
6117
+ "--check-contradictions",
6118
+ "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(
6120
+ (options) => guard(
6121
+ () => runStale({
6122
+ cwd: options.cwd,
6123
+ minAgeDays: options.minAgeDays,
6124
+ limit: options.limit,
6125
+ checkContradictions: options.checkContradictions,
6126
+ model: options.model
6127
+ })
6128
+ )()
5844
6129
  );
5845
6130
  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
6131
  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 +6183,7 @@ program.command("precheck").description("Warn about staged files with unresolved
5898
6183
  })
5899
6184
  )()
5900
6185
  );
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 }))());
6186
+ 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
6187
  program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
5903
6188
  program.parseAsync(process.argv).catch((err) => {
5904
6189
  const message = err instanceof Error ? err.message : String(err);