nexusmem 0.5.4 → 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/dist/cli/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli/index.ts
4
4
  import { Command } from "commander";
5
- import pc19 from "picocolors";
5
+ import pc20 from "picocolors";
6
6
 
7
7
  // src/config/workspace.ts
8
8
  import { existsSync } from "fs";
@@ -1164,6 +1164,38 @@ function getSupersededIds(db, projectId) {
1164
1164
  function setSupersedes(db, newNodeId, staleNodeId) {
1165
1165
  db.prepare("UPDATE nodes SET supersedes = ? WHERE id = ?").run(staleNodeId, newNodeId);
1166
1166
  }
1167
+ function listStaleCandidates(db, projectId, opts = {}) {
1168
+ const now = opts.now ?? /* @__PURE__ */ new Date();
1169
+ const minAgeDays = opts.minAgeDays ?? 45;
1170
+ const cutoff = now.getTime() - minAgeDays * 864e5;
1171
+ const rows = db.prepare(
1172
+ `SELECT id, kind, ts, ts_epoch AS tsEpoch, source, title
1173
+ FROM nodes
1174
+ WHERE project_id = @projectId AND provenance = 'inferred' AND ts_epoch < @cutoff
1175
+ AND id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)
1176
+ ORDER BY ts_epoch ASC
1177
+ LIMIT @limit`
1178
+ ).all({ projectId, cutoff, limit: opts.limit ?? 50 });
1179
+ return rows.map((r) => ({
1180
+ id: r.id,
1181
+ kind: r.kind,
1182
+ ts: r.ts,
1183
+ source: r.source,
1184
+ title: r.title,
1185
+ ageDays: Math.round((now.getTime() - r.tsEpoch) / 864e5)
1186
+ }));
1187
+ }
1188
+ function countStaleCandidates(db, projectId, opts = {}) {
1189
+ const now = opts.now ?? /* @__PURE__ */ new Date();
1190
+ const minAgeDays = opts.minAgeDays ?? 45;
1191
+ const cutoff = now.getTime() - minAgeDays * 864e5;
1192
+ const row = db.prepare(
1193
+ `SELECT COUNT(*) AS count FROM nodes
1194
+ WHERE project_id = @projectId AND provenance = 'inferred' AND ts_epoch < @cutoff
1195
+ AND id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)`
1196
+ ).get({ projectId, cutoff });
1197
+ return row.count;
1198
+ }
1167
1199
 
1168
1200
  // src/store/links.ts
1169
1201
  function linkNodes(db, fromNodeId, toNodeId, relation) {
@@ -1606,6 +1638,14 @@ var MemoryStore = class _MemoryStore {
1606
1638
  setSupersedes(newNodeId, staleNodeId) {
1607
1639
  setSupersedes(this.db, newNodeId, staleNodeId);
1608
1640
  }
1641
+ /** Aging `inferred` nodes nothing supersedes yet -- candidates for `nexusmem mark-stale`, not auto-applied. */
1642
+ listStaleCandidates(projectId, opts = {}) {
1643
+ return listStaleCandidates(this.db, projectId, opts);
1644
+ }
1645
+ /** Same criteria as `listStaleCandidates`, but just the count -- for `status`'s summary line. */
1646
+ countStaleCandidates(projectId, opts = {}) {
1647
+ return countStaleCandidates(this.db, projectId, opts);
1648
+ }
1609
1649
  /** Escape hatch for tests and future modules. */
1610
1650
  get raw() {
1611
1651
  return this.db;
@@ -2418,6 +2458,7 @@ var SIGNAL_FLOOR = 0.2;
2418
2458
  var RECENCY_FLOOR = 0.3;
2419
2459
  var DEFAULT_HALF_LIFE_DAYS = 30;
2420
2460
  var MS_PER_DAY = 864e5;
2461
+ var INFERRED_HALF_LIFE_RATIO = 0.5;
2421
2462
  var SUPERSEDED_PENALTY = 0.5;
2422
2463
  var MAX_PRIOR_OVERTURN = 2;
2423
2464
  var PRIOR_COUNT = 2;
@@ -2452,13 +2493,15 @@ function ageDaysOf(ts, now) {
2452
2493
  function rankHits(hits, opts = {}) {
2453
2494
  if (hits.length === 0) return [];
2454
2495
  const halfLife = opts.halfLifeDays ?? DEFAULT_HALF_LIFE_DAYS;
2496
+ const inferredHalfLife = opts.inferredHalfLifeDays ?? halfLife * INFERRED_HALF_LIFE_RATIO;
2455
2497
  const now = opts.now ?? /* @__PURE__ */ new Date();
2456
2498
  const relevances = opts.relevanceScores ? normalizeExternalRelevance(hits, opts.relevanceScores) : normalizeRelevance(hits);
2457
2499
  const ranked = hits.map((hit, i) => {
2458
2500
  const relevance = relevances[i] ?? RELEVANCE_FLOOR;
2459
2501
  const signalWeight = SIGNAL_FLOOR + (1 - SIGNAL_FLOOR) * hit.signal;
2460
2502
  const ageDays = ageDaysOf(hit.ts, now);
2461
- const recencyFactor = RECENCY_FLOOR + (1 - RECENCY_FLOOR) * 2 ** (-ageDays / halfLife);
2503
+ const effectiveHalfLife = hit.provenance === "inferred" ? inferredHalfLife : halfLife;
2504
+ const recencyFactor = RECENCY_FLOOR + (1 - RECENCY_FLOOR) * 2 ** (-ageDays / effectiveHalfLife);
2462
2505
  const rawScore = relevance * signalWeight ** SIGNAL_EXPONENT * recencyFactor ** RECENCY_EXPONENT;
2463
2506
  const score = opts.supersededIds?.has(hit.id) ? rawScore * SUPERSEDED_PENALTY : rawScore;
2464
2507
  return { ...hit, relevance, signalWeight, ageDays, recencyFactor, score };
@@ -4124,6 +4167,100 @@ function extractImportSpecifiers(source) {
4124
4167
  return [...seen];
4125
4168
  }
4126
4169
 
4170
+ // src/structure/extract-go.ts
4171
+ var SINGLE_IMPORT = /\bimport\s+(?:[\w.]+\s+)?"([^"]+)"/g;
4172
+ var IMPORT_BLOCK = /\bimport\s*\(([\s\S]*?)\)/g;
4173
+ var BLOCK_LINE = /(?:^|\n)\s*(?:[\w.]+\s+)?"([^"]+)"/g;
4174
+ function extractGoImportSpecifiers(source) {
4175
+ const seen = /* @__PURE__ */ new Set();
4176
+ IMPORT_BLOCK.lastIndex = 0;
4177
+ let block;
4178
+ while ((block = IMPORT_BLOCK.exec(source)) !== null) {
4179
+ BLOCK_LINE.lastIndex = 0;
4180
+ let line;
4181
+ while ((line = BLOCK_LINE.exec(block[1])) !== null) {
4182
+ seen.add(line[1]);
4183
+ }
4184
+ }
4185
+ SINGLE_IMPORT.lastIndex = 0;
4186
+ let single;
4187
+ while ((single = SINGLE_IMPORT.exec(source)) !== null) {
4188
+ seen.add(single[1]);
4189
+ }
4190
+ return [...seen];
4191
+ }
4192
+
4193
+ // src/structure/extract-java.ts
4194
+ var IMPORT_PATTERN = /\bimport\s+(?!static\b)([\w.]+(?:\.\*)?)\s*;/g;
4195
+ function extractJavaImportSpecifiers(source) {
4196
+ const seen = /* @__PURE__ */ new Set();
4197
+ IMPORT_PATTERN.lastIndex = 0;
4198
+ let match;
4199
+ while ((match = IMPORT_PATTERN.exec(source)) !== null) {
4200
+ seen.add(match[1]);
4201
+ }
4202
+ return [...seen];
4203
+ }
4204
+
4205
+ // src/structure/extract-php.ts
4206
+ var ANCHORED_INCLUDE = /\b(?:require|include)(?:_once)?\b\s*\(?\s*(?:__DIR__|dirname\s*\(\s*__FILE__\s*\))\s*\.\s*['"]([^'"]+)['"]/g;
4207
+ function extractPhpIncludeSpecifiers(source) {
4208
+ const seen = /* @__PURE__ */ new Set();
4209
+ ANCHORED_INCLUDE.lastIndex = 0;
4210
+ let match;
4211
+ while ((match = ANCHORED_INCLUDE.exec(source)) !== null) {
4212
+ seen.add(match[1]);
4213
+ }
4214
+ return [...seen];
4215
+ }
4216
+
4217
+ // src/structure/extract-python.ts
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;
4221
+ function cleanNames(raw) {
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));
4223
+ }
4224
+ function extractPythonImportSpecifiers(source) {
4225
+ const seen = /* @__PURE__ */ new Set();
4226
+ RELATIVE_IMPORT_PATTERN.lastIndex = 0;
4227
+ let match;
4228
+ while ((match = RELATIVE_IMPORT_PATTERN.exec(source)) !== null) {
4229
+ const dots = match[1];
4230
+ const module = match[2];
4231
+ if (module) {
4232
+ seen.add(dots + module);
4233
+ continue;
4234
+ }
4235
+ for (const name of cleanNames(match[3])) {
4236
+ seen.add(dots + name);
4237
+ }
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
+ }
4249
+ return [...seen];
4250
+ }
4251
+
4252
+ // src/structure/extract-rust.ts
4253
+ var MOD_DECLARATION = /\b(?:pub(?:\([^)]*\))?\s+)?mod\s+([A-Za-z_]\w*)\s*;/g;
4254
+ function extractRustModSpecifiers(source) {
4255
+ const seen = /* @__PURE__ */ new Set();
4256
+ MOD_DECLARATION.lastIndex = 0;
4257
+ let match;
4258
+ while ((match = MOD_DECLARATION.exec(source)) !== null) {
4259
+ seen.add(match[1]);
4260
+ }
4261
+ return [...seen];
4262
+ }
4263
+
4127
4264
  // src/structure/resolve.ts
4128
4265
  import { posix } from "path";
4129
4266
  var REWRITE_EXTENSIONS = {
@@ -4160,12 +4297,291 @@ function resolveSpecifier(fromPath, specifier, trackedPaths) {
4160
4297
  return null;
4161
4298
  }
4162
4299
 
4300
+ // src/structure/resolve-go.ts
4301
+ import { posix as posix2 } from "path";
4302
+ function parseGoModulePath(goModContent) {
4303
+ const match = goModContent.match(/^module\s+(\S+)/m);
4304
+ return match ? match[1] : null;
4305
+ }
4306
+ function resolveGoSpecifier(modulePath, importPath, trackedPaths) {
4307
+ let relDir;
4308
+ if (importPath === modulePath) {
4309
+ relDir = ".";
4310
+ } else if (importPath.startsWith(`${modulePath}/`)) {
4311
+ relDir = importPath.slice(modulePath.length + 1);
4312
+ } else {
4313
+ return [];
4314
+ }
4315
+ return [...trackedPaths].filter((p) => p.endsWith(".go") && !p.endsWith("_test.go") && posix2.dirname(p) === relDir).sort();
4316
+ }
4317
+
4318
+ // src/structure/resolve-java.ts
4319
+ import { posix as posix3 } from "path";
4320
+ function endsAtSegmentBoundary(path, suffix) {
4321
+ return path === suffix || path.endsWith(`/${suffix}`);
4322
+ }
4323
+ function resolveJavaSpecifier(specifier, trackedPaths) {
4324
+ const isWildcard = specifier.endsWith(".*");
4325
+ const segments = (isWildcard ? specifier.slice(0, -2) : specifier).split(".").filter(Boolean);
4326
+ if (segments.length === 0) return [];
4327
+ if (isWildcard) {
4328
+ const dirSuffix = segments.join("/");
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() : [];
4332
+ }
4333
+ const fileSuffix = `${segments.join("/")}.java`;
4334
+ const matches = [...trackedPaths].filter((p) => endsAtSegmentBoundary(p, fileSuffix));
4335
+ return matches.length === 1 ? matches : [];
4336
+ }
4337
+
4338
+ // src/structure/resolve-php.ts
4339
+ import { posix as posix4 } from "path";
4340
+ function resolvePhpSpecifier(fromPath, specifier, trackedPaths) {
4341
+ const fromDir = posix4.dirname(fromPath);
4342
+ const relative2 = specifier.startsWith("/") ? specifier.slice(1) : specifier;
4343
+ const joined = posix4.normalize(posix4.join(fromDir, relative2));
4344
+ if (trackedPaths.has(joined)) return joined;
4345
+ const withExt = joined.endsWith(".php") ? joined : `${joined}.php`;
4346
+ if (trackedPaths.has(withExt)) return withExt;
4347
+ return null;
4348
+ }
4349
+
4350
+ // src/structure/resolve-python.ts
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
+ ]);
4523
+ function resolvePythonSpecifier(fromPath, specifier, trackedPaths) {
4524
+ const dotsMatch = specifier.match(/^\.+/);
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);
4528
+ if (segments.length === 0) return null;
4529
+ let dir = posix5.dirname(fromPath);
4530
+ for (let i = 1; i < level; i++) {
4531
+ dir = posix5.dirname(dir);
4532
+ }
4533
+ const joined = posix5.join(dir, ...segments);
4534
+ const moduleFile = `${joined}.py`;
4535
+ if (trackedPaths.has(moduleFile)) return moduleFile;
4536
+ const packageInit = posix5.join(joined, "__init__.py");
4537
+ if (trackedPaths.has(packageInit)) return packageInit;
4538
+ return null;
4539
+ }
4540
+
4541
+ // src/structure/resolve-rust.ts
4542
+ import { posix as posix6 } from "path";
4543
+ function resolveRustModSpecifier(fromPath, name, trackedPaths) {
4544
+ const dir = posix6.dirname(fromPath);
4545
+ const base = posix6.basename(fromPath, ".rs");
4546
+ const moduleDir = base === "mod" || base === "lib" || base === "main" ? dir : posix6.join(dir, base);
4547
+ const sibling = posix6.join(moduleDir, `${name}.rs`);
4548
+ if (trackedPaths.has(sibling)) return sibling;
4549
+ const nested = posix6.join(moduleDir, name, "mod.rs");
4550
+ if (trackedPaths.has(nested)) return nested;
4551
+ return null;
4552
+ }
4553
+
4163
4554
  // src/structure/collect.ts
4164
- var TRACKED_PATHSPECS = ["*.ts", "*.tsx", "*.js", "*.jsx"];
4555
+ var TRACKED_PATHSPECS = ["*.ts", "*.tsx", "*.js", "*.jsx", "*.py", "*.go", "*.rs", "*.java", "*.php"];
4556
+ function resolveTargets(path, content, trackedPaths, goModulePath) {
4557
+ if (path.endsWith(".py")) {
4558
+ return extractPythonImportSpecifiers(content).map((s) => resolvePythonSpecifier(path, s, trackedPaths)).filter((t) => t !== null);
4559
+ }
4560
+ if (path.endsWith(".rs")) {
4561
+ return extractRustModSpecifiers(content).map((s) => resolveRustModSpecifier(path, s, trackedPaths)).filter((t) => t !== null);
4562
+ }
4563
+ if (path.endsWith(".go")) {
4564
+ if (!goModulePath) return [];
4565
+ return extractGoImportSpecifiers(content).flatMap((imp) => resolveGoSpecifier(goModulePath, imp, trackedPaths));
4566
+ }
4567
+ if (path.endsWith(".java")) {
4568
+ return extractJavaImportSpecifiers(content).flatMap((s) => resolveJavaSpecifier(s, trackedPaths));
4569
+ }
4570
+ if (path.endsWith(".php")) {
4571
+ return extractPhpIncludeSpecifiers(content).map((s) => resolvePhpSpecifier(path, s, trackedPaths)).filter((t) => t !== null);
4572
+ }
4573
+ return extractImportSpecifiers(content).map((s) => resolveSpecifier(path, s, trackedPaths)).filter((t) => t !== null);
4574
+ }
4165
4575
  async function collectFileEdges(repoRoot) {
4166
4576
  const out = await git(repoRoot, ["ls-files", "--", ...TRACKED_PATHSPECS]);
4167
4577
  const paths = out.split("\n").map((line) => line.trim().replace(/\\/g, "/")).filter(Boolean);
4168
4578
  const trackedPaths = new Set(paths);
4579
+ let goModulePath = null;
4580
+ try {
4581
+ goModulePath = parseGoModulePath(await readFile10(join8(repoRoot, "go.mod"), "utf8"));
4582
+ } catch {
4583
+ goModulePath = null;
4584
+ }
4169
4585
  const edges = [];
4170
4586
  const seenEdges = /* @__PURE__ */ new Set();
4171
4587
  const unreadable = [];
@@ -4177,10 +4593,9 @@ async function collectFileEdges(repoRoot) {
4177
4593
  unreadable.push(path);
4178
4594
  continue;
4179
4595
  }
4180
- for (const specifier of extractImportSpecifiers(content)) {
4181
- const target = resolveSpecifier(path, specifier, trackedPaths);
4182
- if (!target || target === path) continue;
4183
- const key = `${path}\0${target}`;
4596
+ for (const target of resolveTargets(path, content, trackedPaths, goModulePath)) {
4597
+ if (target === path) continue;
4598
+ const key = `${path} ${target}`;
4184
4599
  if (seenEdges.has(key)) continue;
4185
4600
  seenEdges.add(key);
4186
4601
  edges.push({ fromPath: path, toPath: target, kind: "import" });
@@ -5344,6 +5759,7 @@ function formatNode5(node) {
5344
5759
 
5345
5760
  // src/cli/commands/scan-structure.ts
5346
5761
  import pc17 from "picocolors";
5762
+ var TRACKED_EXTENSIONS = TRACKED_PATHSPECS.map((p) => p.replace("*", "")).join("/");
5347
5763
  async function runScanStructure(opts) {
5348
5764
  const repo = await readRepoInfo(opts.cwd);
5349
5765
  const { edges, filesScanned, unreadable } = await collectFileEdges(repo.root);
@@ -5363,16 +5779,134 @@ async function runScanStructure(opts) {
5363
5779
  }
5364
5780
  process.stderr.write(
5365
5781
  `
5366
- ${pc17.bold(String(edges.length))} edge(s) from ${filesScanned} tracked .ts/.tsx/.js/.jsx file(s)
5782
+ ${pc17.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKED_EXTENSIONS} file(s)
5367
5783
  `
5368
5784
  );
5369
5785
  return 0;
5370
5786
  }
5371
5787
 
5788
+ // src/cli/commands/stale.ts
5789
+ import pc18 from "picocolors";
5790
+
5791
+ // src/slm/contradiction.ts
5792
+ var MAX_BODY_CHARS = 1500;
5793
+ var MAX_REASON_CHARS = 200;
5794
+ var CONTRADICTION_INSTRUCTIONS = `You are checking whether a NEWER memory replaces or contradicts an OLDER one, for an AI coding assistant's memory index.
5795
+
5796
+ Answer in exactly this shape:
5797
+ VERDICT: YES or NO
5798
+ REASON: <one line, under 20 words>
5799
+
5800
+ Say YES only if the NEWER memory states something that makes the OLDER one factually wrong or obsolete -- a decision reversed, a bug fixed, a plan abandoned. Say NO if they are about different things, or the newer one only adds detail without contradicting the older one. When unsure, say NO.`;
5801
+ function buildContradictionPrompt(older, newer) {
5802
+ const body = [
5803
+ `OLDER (${older.title}):`,
5804
+ truncate(older.body, MAX_BODY_CHARS),
5805
+ "",
5806
+ `NEWER (${newer.title}):`,
5807
+ truncate(newer.body, MAX_BODY_CHARS)
5808
+ ].join("\n");
5809
+ return `${CONTRADICTION_INSTRUCTIONS}
5810
+
5811
+ ---
5812
+
5813
+ ${body}
5814
+
5815
+ ---
5816
+
5817
+ Answer:`;
5818
+ }
5819
+ function parseContradictionVerdict(raw) {
5820
+ const lines = raw.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
5821
+ const verdictLine = lines.find((l) => /^VERDICT:/i.test(l));
5822
+ if (!verdictLine) return null;
5823
+ const verdict = /^VERDICT:\s*(YES|NO)\b/i.exec(verdictLine);
5824
+ if (!verdict) return null;
5825
+ const reasonLine = lines.find((l) => /^REASON:/i.test(l));
5826
+ const reason = reasonLine ? reasonLine.replace(/^REASON:\s*/i, "").trim() : "";
5827
+ return {
5828
+ contradicts: verdict[1].toUpperCase() === "YES",
5829
+ reason: truncate(reason, MAX_REASON_CHARS)
5830
+ };
5831
+ }
5832
+
5833
+ // src/retrieval/contradiction.ts
5834
+ var DEFAULT_LIMIT = 10;
5835
+ var DEFAULT_NEIGHBOR_LIMIT = 25;
5836
+ async function checkContradictions(store, embeddingProvider, slmProvider, projectId, candidates, opts = {}) {
5837
+ const limit = opts.limit ?? DEFAULT_LIMIT;
5838
+ const neighborLimit = opts.neighborLimit ?? DEFAULT_NEIGHBOR_LIMIT;
5839
+ const suggestions = [];
5840
+ for (const candidate of candidates.slice(0, limit)) {
5841
+ const full = store.getNodesByIds([candidate.id])[0];
5842
+ if (!full) continue;
5843
+ const embedding = await embeddingProvider.embed(`${full.title}
5844
+ ${full.body}`);
5845
+ if (!embedding) continue;
5846
+ const candidateEpoch = Date.parse(candidate.ts);
5847
+ const nearest = store.vectorSearch(projectId, embedding, neighborLimit + 1).find((hit) => hit.id !== candidate.id && Date.parse(hit.ts) > candidateEpoch);
5848
+ if (!nearest) continue;
5849
+ const reply = await slmProvider.complete(buildContradictionPrompt(full, nearest));
5850
+ if (!reply) continue;
5851
+ const verdict = parseContradictionVerdict(reply);
5852
+ if (!verdict?.contradicts) continue;
5853
+ suggestions.push({
5854
+ candidateId: candidate.id,
5855
+ againstId: nearest.id,
5856
+ againstTitle: nearest.title,
5857
+ reason: verdict.reason
5858
+ });
5859
+ }
5860
+ return suggestions;
5861
+ }
5862
+
5863
+ // src/cli/commands/stale.ts
5864
+ var STALE_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
5865
+ async function runStale(opts) {
5866
+ const { projectId, ws } = await loadContext(opts.cwd);
5867
+ const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
5868
+ const store = MemoryStore.open(ws.dbPath);
5869
+ try {
5870
+ const candidates = store.listStaleCandidates(projectId, { minAgeDays: opts.minAgeDays, limit: opts.limit });
5871
+ if (candidates.length === 0) {
5872
+ out(`${pc18.dim("no stale candidates")} -- no inferred node older than the threshold lacks a successor
5873
+ `);
5874
+ return 0;
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]));
5887
+ out(
5888
+ [
5889
+ `${pc18.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
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
+ }),
5896
+ "",
5897
+ `run ${pc18.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
5898
+ ].join("\n").concat("\n")
5899
+ );
5900
+ return 0;
5901
+ } finally {
5902
+ store.close();
5903
+ }
5904
+ }
5905
+
5372
5906
  // src/cli/commands/status.ts
5373
5907
  import { basename as basename4 } from "path";
5374
5908
  import { statSync } from "fs";
5375
- import pc18 from "picocolors";
5909
+ import pc19 from "picocolors";
5376
5910
  function daySpan(oldest, newest) {
5377
5911
  const oldestDay = Date.parse(oldest.slice(0, 10));
5378
5912
  const newestDay = Date.parse(newest.slice(0, 10));
@@ -5428,34 +5962,36 @@ async function runStatus(opts) {
5428
5962
  const otherProjectIds = store.listOtherProjectIds(projectId);
5429
5963
  const otherProjectNodes = store.countProjectNodes(otherProjectIds);
5430
5964
  const structure = store.fileEdgeStats(projectId);
5965
+ const staleCount = store.countStaleCandidates(projectId);
5431
5966
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
5432
5967
  const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
5433
- const staleProjectWarning = otherProjectIds.length ? `${pc18.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc18.bold(
5968
+ const staleProjectWarning = otherProjectIds.length ? `${pc19.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc19.bold(
5434
5969
  "nexusmem sync --prune-source <name>"
5435
5970
  )} to remove stale source data` : "";
5436
5971
  out(
5437
5972
  [
5438
- `${pc18.dim("repo ")} ${repo.root}`,
5439
- `${pc18.dim("branch ")} ${repo.branch ?? pc18.yellow("(detached)")}`,
5440
- `${pc18.dim("project ")} ${pc18.cyan(projectId)}`,
5441
- `${pc18.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc18.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
5442
- `${pc18.dim("database")} ${ws.dbPath} ${pc18.dim(`(${humanBytes(dbBytes)})`)}`,
5973
+ `${pc19.dim("repo ")} ${repo.root}`,
5974
+ `${pc19.dim("branch ")} ${repo.branch ?? pc19.yellow("(detached)")}`,
5975
+ `${pc19.dim("project ")} ${pc19.cyan(projectId)}`,
5976
+ `${pc19.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc19.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
5977
+ `${pc19.dim("database")} ${ws.dbPath} ${pc19.dim(`(${humanBytes(dbBytes)})`)}`,
5443
5978
  staleProjectWarning,
5444
5979
  "",
5445
- `${pc18.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc18.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
5980
+ `${pc19.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc19.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
5446
5981
  ...kinds,
5447
- stats2.total ? ` ${pc18.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
5982
+ stats2.total ? ` ${pc19.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
5448
5983
  "",
5449
- sources.length ? pc18.dim("sources") : pc18.yellow("no sources synced yet"),
5984
+ sources.length ? pc19.dim("sources") : pc19.yellow("no sources synced yet"),
5450
5985
  ...sources.map((s) => {
5451
5986
  const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
5452
5987
  const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
5453
- return ` ${s.source.padEnd(14)} ${pc18.dim(`last run ${when}`)} ${pc18.dim(`cursor ${cursorLabel}`)}`;
5988
+ return ` ${s.source.padEnd(14)} ${pc19.dim(`last run ${when}`)} ${pc19.dim(`cursor ${cursorLabel}`)}`;
5454
5989
  }),
5455
5990
  "",
5456
- gitCursor && gitCursor !== repo.head ? `${pc18.yellow("git behind HEAD")} \u2014 run ${pc18.bold("nexusmem sync")}` : "",
5457
- chains.failuresTotal ? `${pc18.dim("chains ")} ${pc18.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc18.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc18.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
5458
- structure.edges ? `${pc18.dim("structure")} ${pc18.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : ""
5991
+ gitCursor && gitCursor !== repo.head ? `${pc19.yellow("git behind HEAD")} \u2014 run ${pc19.bold("nexusmem sync")}` : "",
5992
+ chains.failuresTotal ? `${pc19.dim("chains ")} ${pc19.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc19.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc19.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
5993
+ structure.edges ? `${pc19.dim("structure")} ${pc19.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
5994
+ staleCount ? `${pc19.dim("aging ")} ${pc19.bold(String(staleCount))} inferred node(s) worth a look \u2014 run ${pc19.bold("nexusmem stale")}` : ""
5459
5995
  ].filter((line) => line !== "").join("\n").concat("\n")
5460
5996
  );
5461
5997
  return 0;
@@ -5478,7 +6014,7 @@ function guard(run) {
5478
6014
  process.exitCode = await run();
5479
6015
  } catch (err) {
5480
6016
  if (isExpected(err)) {
5481
- process.stderr.write(`${pc19.red("error")} ${err.message}
6017
+ process.stderr.write(`${pc20.red("error")} ${err.message}
5482
6018
  `);
5483
6019
  process.exitCode = 1;
5484
6020
  return;
@@ -5577,6 +6113,20 @@ program.command("mark-stale").description(
5577
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(
5578
6114
  (nodeId, options) => guard(() => runMarkStale({ cwd: options.cwd, nodeId, supersedesId: options.supersedes }))()
5579
6115
  );
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
+ )()
6129
+ );
5580
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 }))());
5581
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(
5582
6132
  (options) => guard(
@@ -5633,11 +6183,11 @@ program.command("precheck").description("Warn about staged files with unresolved
5633
6183
  })
5634
6184
  )()
5635
6185
  );
5636
- program.command("scan-structure").description("Preview the JS/TS 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 }))());
5637
6187
  program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
5638
6188
  program.parseAsync(process.argv).catch((err) => {
5639
6189
  const message = err instanceof Error ? err.message : String(err);
5640
- process.stderr.write(`${pc19.red("error")} ${message}
6190
+ process.stderr.write(`${pc20.red("error")} ${message}
5641
6191
  `);
5642
6192
  process.exitCode = 1;
5643
6193
  });