nexusmem 0.5.4 → 0.6.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,29 @@ built from, matched by publish timestamp: `v0.1.0` → `67a4776`, `v0.1.1` → `
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.6.0] — 2026-08-20
13
+
14
+ ### Added
15
+
16
+ - Ranker: `inferred` nodes (summaries, doc snapshots) now decay twice as fast as `observed` ones
17
+ (git commits, diffs, shell commands) for retrieval purposes. A judgment call, not a measured
18
+ optimum — see `INFERRED_HALF_LIFE_RATIO` in `src/retrieval/rank.ts`.
19
+ - `nexusmem stale`: lists aging `inferred` nodes nothing has superseded yet, as candidates for
20
+ `mark-stale`. A heuristic on age and provenance, not real contradiction detection — writes nothing.
21
+ - `nexusmem status` now surfaces an `aging` line with the stale-candidate count when any exist.
22
+ - Import graph: Python relative imports (`from .foo import bar`, `from . import x`) now produce
23
+ file edges too, alongside the existing JS/TS support. Absolute Python imports are still skipped —
24
+ same "missed edge over wrong edge" reasoning as JS/TS's bare-specifier skip.
25
+ - Import graph: Go internal imports (resolved against `go.mod`'s module path, one edge per
26
+ non-test file in the imported package) and Rust `mod foo;` declarations (2018+ edition module
27
+ layout) now produce file edges too. External Go imports and Rust `use` paths are out of scope for
28
+ the same reason.
29
+ - Import graph: Java imports (`import a.b.C;` / `import a.b.*;`, resolved by unambiguous suffix
30
+ match against the tracked source tree) and `__DIR__`-anchored PHP `require`/`include` now produce
31
+ file edges too — all six of `nexusmem scan-structure`'s tracked languages. `import static`, PHP's
32
+ autoloaded `use Namespace\Class;`, and any unanchored PHP include are out of scope for the same
33
+ "missed edge over wrong edge" reason as everywhere else in the import graph.
34
+
12
35
  ## [0.5.4] — 2026-08-20
13
36
 
14
37
  ### Added
@@ -428,7 +451,11 @@ First public release.
428
451
  there is no local-model summarization pass, and the conversation collector has never been audited
429
452
  for the stale-node bug that was found and fixed in the docs collector.
430
453
 
431
- [Unreleased]: https://github.com/yaminbkk/NexusMem/compare/v0.5.1...HEAD
454
+ [Unreleased]: https://github.com/yaminbkk/NexusMem/compare/v0.6.0...HEAD
455
+ [0.6.0]: https://github.com/yaminbkk/NexusMem/compare/v0.5.4...v0.6.0
456
+ [0.5.4]: https://github.com/yaminbkk/NexusMem/compare/v0.5.3...v0.5.4
457
+ [0.5.3]: https://github.com/yaminbkk/NexusMem/compare/v0.5.2...v0.5.3
458
+ [0.5.2]: https://github.com/yaminbkk/NexusMem/compare/v0.5.1...v0.5.2
432
459
  [0.5.1]: https://github.com/yaminbkk/NexusMem/compare/v0.5.0...v0.5.1
433
460
  [0.5.0]: https://github.com/yaminbkk/NexusMem/compare/v0.4.0...v0.5.0
434
461
  [0.4.0]: https://github.com/yaminbkk/NexusMem/compare/v0.3.3...v0.4.0
package/README.md CHANGED
@@ -263,16 +263,25 @@ Latency on a ~530-node corpus, warm, p50 over 10 runs:
263
263
  All the SQLite work totals about 5 ms. The embedding call is the only thing on this path worth
264
264
  optimizing, and it is somebody else's process.
265
265
 
266
- ## Manual staleness & provenance
266
+ ## Staleness & provenance
267
267
 
268
268
  Two things a memory layer needs and this one only partly has: a way to tell an observed fact from a
269
- guess, and a way to retire a conclusion once something contradicts it. Neither is automatic here —
270
- this section is what exists and what doesn't.
269
+ guess, and a way to retire a conclusion once something contradicts it. This section is what exists
270
+ and what doesn't.
271
271
 
272
272
  Every node carries a `provenance`: `observed` (a commit that landed, a shell command's real exit
273
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, and shown as a `[observed]`
275
- / `[inferred]` tag on every query result.
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.
277
+
278
+ ```bash
279
+ nexusmem stale
280
+ ```
281
+
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.
276
285
 
277
286
  ```bash
278
287
  nexusmem mark-stale <oldNodeId> --supersedes <newNodeId>
@@ -281,10 +290,10 @@ nexusmem mark-stale <oldNodeId> --supersedes <newNodeId>
281
290
  Links `newNodeId` as the replacement for `oldNodeId`. The ranker down-weights the old node from then
282
291
  on (it stays queryable, just usually loses to its replacement) — nothing is deleted, unlike `forget`.
283
292
 
284
- **What this doesn't do:** nothing here detects staleness on its own. If a later commit contradicts an
285
- earlier doc section, NexusMem has no way to notice that and flag it a human or an agent has to spot
286
- the contradiction and run `mark-stale` themselves. Automatic staleness detection is still an open
287
- problem.
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.
288
297
 
289
298
  ## Where it breaks
290
299
 
@@ -352,8 +361,8 @@ problem.
352
361
  ## Commands
353
362
 
354
363
  `init`, `sync`, `query <text>`, `status` (add `--share` for a plain-text summary worth pasting
355
- somewhere), `projects`, `mcp`, `forget <value>`, `mark-stale <nodeId> --supersedes <newNodeId>`,
356
- and `hook install|remove|status`.
364
+ somewhere), `projects`, `mcp`, `forget <value>`, `stale`,
365
+ `mark-stale <nodeId> --supersedes <newNodeId>`, and `hook install|remove|status`.
357
366
 
358
367
  There are also five dry-run previews (`scan-git`, `scan-diff`, `scan-shell`, `scan-docs`,
359
368
  `scan-conversation`)
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,88 @@ 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
+ function cleanNames(raw) {
4220
+ 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
+ }
4222
+ function extractPythonImportSpecifiers(source) {
4223
+ const seen = /* @__PURE__ */ new Set();
4224
+ RELATIVE_IMPORT_PATTERN.lastIndex = 0;
4225
+ let match;
4226
+ while ((match = RELATIVE_IMPORT_PATTERN.exec(source)) !== null) {
4227
+ const dots = match[1];
4228
+ const module = match[2];
4229
+ if (module) {
4230
+ seen.add(dots + module);
4231
+ continue;
4232
+ }
4233
+ for (const name of cleanNames(match[3])) {
4234
+ seen.add(dots + name);
4235
+ }
4236
+ }
4237
+ return [...seen];
4238
+ }
4239
+
4240
+ // src/structure/extract-rust.ts
4241
+ var MOD_DECLARATION = /\b(?:pub(?:\([^)]*\))?\s+)?mod\s+([A-Za-z_]\w*)\s*;/g;
4242
+ function extractRustModSpecifiers(source) {
4243
+ const seen = /* @__PURE__ */ new Set();
4244
+ MOD_DECLARATION.lastIndex = 0;
4245
+ let match;
4246
+ while ((match = MOD_DECLARATION.exec(source)) !== null) {
4247
+ seen.add(match[1]);
4248
+ }
4249
+ return [...seen];
4250
+ }
4251
+
4127
4252
  // src/structure/resolve.ts
4128
4253
  import { posix } from "path";
4129
4254
  var REWRITE_EXTENSIONS = {
@@ -4160,12 +4285,118 @@ function resolveSpecifier(fromPath, specifier, trackedPaths) {
4160
4285
  return null;
4161
4286
  }
4162
4287
 
4288
+ // src/structure/resolve-go.ts
4289
+ import { posix as posix2 } from "path";
4290
+ function parseGoModulePath(goModContent) {
4291
+ const match = goModContent.match(/^module\s+(\S+)/m);
4292
+ return match ? match[1] : null;
4293
+ }
4294
+ function resolveGoSpecifier(modulePath, importPath, trackedPaths) {
4295
+ let relDir;
4296
+ if (importPath === modulePath) {
4297
+ relDir = ".";
4298
+ } else if (importPath.startsWith(`${modulePath}/`)) {
4299
+ relDir = importPath.slice(modulePath.length + 1);
4300
+ } else {
4301
+ return [];
4302
+ }
4303
+ return [...trackedPaths].filter((p) => p.endsWith(".go") && !p.endsWith("_test.go") && posix2.dirname(p) === relDir).sort();
4304
+ }
4305
+
4306
+ // src/structure/resolve-java.ts
4307
+ import { posix as posix3 } from "path";
4308
+ function endsAtSegmentBoundary(path, suffix) {
4309
+ return path === suffix || path.endsWith(`/${suffix}`);
4310
+ }
4311
+ function resolveJavaSpecifier(specifier, trackedPaths) {
4312
+ const isWildcard = specifier.endsWith(".*");
4313
+ const segments = (isWildcard ? specifier.slice(0, -2) : specifier).split(".").filter(Boolean);
4314
+ if (segments.length === 0) return [];
4315
+ if (isWildcard) {
4316
+ const dirSuffix = segments.join("/");
4317
+ return [...trackedPaths].filter((p) => p.endsWith(".java") && endsAtSegmentBoundary(posix3.dirname(p), dirSuffix)).sort();
4318
+ }
4319
+ const fileSuffix = `${segments.join("/")}.java`;
4320
+ const matches = [...trackedPaths].filter((p) => endsAtSegmentBoundary(p, fileSuffix));
4321
+ return matches.length === 1 ? matches : [];
4322
+ }
4323
+
4324
+ // src/structure/resolve-php.ts
4325
+ import { posix as posix4 } from "path";
4326
+ function resolvePhpSpecifier(fromPath, specifier, trackedPaths) {
4327
+ const fromDir = posix4.dirname(fromPath);
4328
+ const relative2 = specifier.startsWith("/") ? specifier.slice(1) : specifier;
4329
+ const joined = posix4.normalize(posix4.join(fromDir, relative2));
4330
+ if (trackedPaths.has(joined)) return joined;
4331
+ const withExt = joined.endsWith(".php") ? joined : `${joined}.php`;
4332
+ if (trackedPaths.has(withExt)) return withExt;
4333
+ return null;
4334
+ }
4335
+
4336
+ // src/structure/resolve-python.ts
4337
+ import { posix as posix5 } from "path";
4338
+ function resolvePythonSpecifier(fromPath, specifier, trackedPaths) {
4339
+ const dotsMatch = specifier.match(/^\.+/);
4340
+ if (!dotsMatch) return null;
4341
+ const level = dotsMatch[0].length;
4342
+ const segments = specifier.slice(level).split(".").filter(Boolean);
4343
+ if (segments.length === 0) return null;
4344
+ let dir = posix5.dirname(fromPath);
4345
+ for (let i = 1; i < level; i++) {
4346
+ dir = posix5.dirname(dir);
4347
+ }
4348
+ const joined = posix5.join(dir, ...segments);
4349
+ const moduleFile = `${joined}.py`;
4350
+ if (trackedPaths.has(moduleFile)) return moduleFile;
4351
+ const packageInit = posix5.join(joined, "__init__.py");
4352
+ if (trackedPaths.has(packageInit)) return packageInit;
4353
+ return null;
4354
+ }
4355
+
4356
+ // src/structure/resolve-rust.ts
4357
+ import { posix as posix6 } from "path";
4358
+ function resolveRustModSpecifier(fromPath, name, trackedPaths) {
4359
+ const dir = posix6.dirname(fromPath);
4360
+ const base = posix6.basename(fromPath, ".rs");
4361
+ const moduleDir = base === "mod" || base === "lib" || base === "main" ? dir : posix6.join(dir, base);
4362
+ const sibling = posix6.join(moduleDir, `${name}.rs`);
4363
+ if (trackedPaths.has(sibling)) return sibling;
4364
+ const nested = posix6.join(moduleDir, name, "mod.rs");
4365
+ if (trackedPaths.has(nested)) return nested;
4366
+ return null;
4367
+ }
4368
+
4163
4369
  // src/structure/collect.ts
4164
- var TRACKED_PATHSPECS = ["*.ts", "*.tsx", "*.js", "*.jsx"];
4370
+ var TRACKED_PATHSPECS = ["*.ts", "*.tsx", "*.js", "*.jsx", "*.py", "*.go", "*.rs", "*.java", "*.php"];
4371
+ function resolveTargets(path, content, trackedPaths, goModulePath) {
4372
+ if (path.endsWith(".py")) {
4373
+ return extractPythonImportSpecifiers(content).map((s) => resolvePythonSpecifier(path, s, trackedPaths)).filter((t) => t !== null);
4374
+ }
4375
+ if (path.endsWith(".rs")) {
4376
+ return extractRustModSpecifiers(content).map((s) => resolveRustModSpecifier(path, s, trackedPaths)).filter((t) => t !== null);
4377
+ }
4378
+ if (path.endsWith(".go")) {
4379
+ if (!goModulePath) return [];
4380
+ return extractGoImportSpecifiers(content).flatMap((imp) => resolveGoSpecifier(goModulePath, imp, trackedPaths));
4381
+ }
4382
+ if (path.endsWith(".java")) {
4383
+ return extractJavaImportSpecifiers(content).flatMap((s) => resolveJavaSpecifier(s, trackedPaths));
4384
+ }
4385
+ if (path.endsWith(".php")) {
4386
+ return extractPhpIncludeSpecifiers(content).map((s) => resolvePhpSpecifier(path, s, trackedPaths)).filter((t) => t !== null);
4387
+ }
4388
+ return extractImportSpecifiers(content).map((s) => resolveSpecifier(path, s, trackedPaths)).filter((t) => t !== null);
4389
+ }
4165
4390
  async function collectFileEdges(repoRoot) {
4166
4391
  const out = await git(repoRoot, ["ls-files", "--", ...TRACKED_PATHSPECS]);
4167
4392
  const paths = out.split("\n").map((line) => line.trim().replace(/\\/g, "/")).filter(Boolean);
4168
4393
  const trackedPaths = new Set(paths);
4394
+ let goModulePath = null;
4395
+ try {
4396
+ goModulePath = parseGoModulePath(await readFile10(join8(repoRoot, "go.mod"), "utf8"));
4397
+ } catch {
4398
+ goModulePath = null;
4399
+ }
4169
4400
  const edges = [];
4170
4401
  const seenEdges = /* @__PURE__ */ new Set();
4171
4402
  const unreadable = [];
@@ -4177,10 +4408,9 @@ async function collectFileEdges(repoRoot) {
4177
4408
  unreadable.push(path);
4178
4409
  continue;
4179
4410
  }
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}`;
4411
+ for (const target of resolveTargets(path, content, trackedPaths, goModulePath)) {
4412
+ if (target === path) continue;
4413
+ const key = `${path} ${target}`;
4184
4414
  if (seenEdges.has(key)) continue;
4185
4415
  seenEdges.add(key);
4186
4416
  edges.push({ fromPath: path, toPath: target, kind: "import" });
@@ -5344,6 +5574,7 @@ function formatNode5(node) {
5344
5574
 
5345
5575
  // src/cli/commands/scan-structure.ts
5346
5576
  import pc17 from "picocolors";
5577
+ var TRACKED_EXTENSIONS = TRACKED_PATHSPECS.map((p) => p.replace("*", "")).join("/");
5347
5578
  async function runScanStructure(opts) {
5348
5579
  const repo = await readRepoInfo(opts.cwd);
5349
5580
  const { edges, filesScanned, unreadable } = await collectFileEdges(repo.root);
@@ -5363,16 +5594,45 @@ async function runScanStructure(opts) {
5363
5594
  }
5364
5595
  process.stderr.write(
5365
5596
  `
5366
- ${pc17.bold(String(edges.length))} edge(s) from ${filesScanned} tracked .ts/.tsx/.js/.jsx file(s)
5597
+ ${pc17.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKED_EXTENSIONS} file(s)
5367
5598
  `
5368
5599
  );
5369
5600
  return 0;
5370
5601
  }
5371
5602
 
5603
+ // src/cli/commands/stale.ts
5604
+ import pc18 from "picocolors";
5605
+ async function runStale(opts) {
5606
+ const { projectId, ws } = await loadContext(opts.cwd);
5607
+ const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
5608
+ const store = MemoryStore.open(ws.dbPath);
5609
+ try {
5610
+ const candidates = store.listStaleCandidates(projectId, { minAgeDays: opts.minAgeDays, limit: opts.limit });
5611
+ if (candidates.length === 0) {
5612
+ out(`${pc18.dim("no stale candidates")} -- no inferred node older than the threshold lacks a successor
5613
+ `);
5614
+ return 0;
5615
+ }
5616
+ out(
5617
+ [
5618
+ `${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
+ ),
5622
+ "",
5623
+ `run ${pc18.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
5624
+ ].join("\n").concat("\n")
5625
+ );
5626
+ return 0;
5627
+ } finally {
5628
+ store.close();
5629
+ }
5630
+ }
5631
+
5372
5632
  // src/cli/commands/status.ts
5373
5633
  import { basename as basename4 } from "path";
5374
5634
  import { statSync } from "fs";
5375
- import pc18 from "picocolors";
5635
+ import pc19 from "picocolors";
5376
5636
  function daySpan(oldest, newest) {
5377
5637
  const oldestDay = Date.parse(oldest.slice(0, 10));
5378
5638
  const newestDay = Date.parse(newest.slice(0, 10));
@@ -5428,34 +5688,36 @@ async function runStatus(opts) {
5428
5688
  const otherProjectIds = store.listOtherProjectIds(projectId);
5429
5689
  const otherProjectNodes = store.countProjectNodes(otherProjectIds);
5430
5690
  const structure = store.fileEdgeStats(projectId);
5691
+ const staleCount = store.countStaleCandidates(projectId);
5431
5692
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
5432
5693
  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(
5694
+ 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
5695
  "nexusmem sync --prune-source <name>"
5435
5696
  )} to remove stale source data` : "";
5436
5697
  out(
5437
5698
  [
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)})`)}`,
5699
+ `${pc19.dim("repo ")} ${repo.root}`,
5700
+ `${pc19.dim("branch ")} ${repo.branch ?? pc19.yellow("(detached)")}`,
5701
+ `${pc19.dim("project ")} ${pc19.cyan(projectId)}`,
5702
+ `${pc19.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc19.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
5703
+ `${pc19.dim("database")} ${ws.dbPath} ${pc19.dim(`(${humanBytes(dbBytes)})`)}`,
5443
5704
  staleProjectWarning,
5444
5705
  "",
5445
- `${pc18.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc18.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
5706
+ `${pc19.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc19.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
5446
5707
  ...kinds,
5447
- stats2.total ? ` ${pc18.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
5708
+ stats2.total ? ` ${pc19.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
5448
5709
  "",
5449
- sources.length ? pc18.dim("sources") : pc18.yellow("no sources synced yet"),
5710
+ sources.length ? pc19.dim("sources") : pc19.yellow("no sources synced yet"),
5450
5711
  ...sources.map((s) => {
5451
5712
  const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
5452
5713
  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}`)}`;
5714
+ return ` ${s.source.padEnd(14)} ${pc19.dim(`last run ${when}`)} ${pc19.dim(`cursor ${cursorLabel}`)}`;
5454
5715
  }),
5455
5716
  "",
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)` : ""
5717
+ gitCursor && gitCursor !== repo.head ? `${pc19.yellow("git behind HEAD")} \u2014 run ${pc19.bold("nexusmem sync")}` : "",
5718
+ 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
+ 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")}` : ""
5459
5721
  ].filter((line) => line !== "").join("\n").concat("\n")
5460
5722
  );
5461
5723
  return 0;
@@ -5478,7 +5740,7 @@ function guard(run) {
5478
5740
  process.exitCode = await run();
5479
5741
  } catch (err) {
5480
5742
  if (isExpected(err)) {
5481
- process.stderr.write(`${pc19.red("error")} ${err.message}
5743
+ process.stderr.write(`${pc20.red("error")} ${err.message}
5482
5744
  `);
5483
5745
  process.exitCode = 1;
5484
5746
  return;
@@ -5577,6 +5839,9 @@ program.command("mark-stale").description(
5577
5839
  ).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
5840
  (nodeId, options) => guard(() => runMarkStale({ cwd: options.cwd, nodeId, supersedesId: options.supersedes }))()
5579
5841
  );
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 }))()
5844
+ );
5580
5845
  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
5846
  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
5847
  (options) => guard(
@@ -5633,11 +5898,11 @@ program.command("precheck").description("Warn about staged files with unresolved
5633
5898
  })
5634
5899
  )()
5635
5900
  );
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 }))());
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 }))());
5637
5902
  program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
5638
5903
  program.parseAsync(process.argv).catch((err) => {
5639
5904
  const message = err instanceof Error ? err.message : String(err);
5640
- process.stderr.write(`${pc19.red("error")} ${message}
5905
+ process.stderr.write(`${pc20.red("error")} ${message}
5641
5906
  `);
5642
5907
  process.exitCode = 1;
5643
5908
  });