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 +28 -1
- package/README.md +20 -11
- package/dist/cli/index.js +290 -25
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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
|
-
##
|
|
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.
|
|
270
|
-
|
|
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,
|
|
275
|
-
|
|
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
|
|
285
|
-
earlier doc section,
|
|
286
|
-
|
|
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>`, `
|
|
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
|
|
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
|
|
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
|
|
4181
|
-
|
|
4182
|
-
|
|
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
|
|
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
|
|
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 ? `${
|
|
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
|
-
`${
|
|
5439
|
-
`${
|
|
5440
|
-
`${
|
|
5441
|
-
`${
|
|
5442
|
-
`${
|
|
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
|
-
`${
|
|
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 ? ` ${
|
|
5708
|
+
stats2.total ? ` ${pc19.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
|
|
5448
5709
|
"",
|
|
5449
|
-
sources.length ?
|
|
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)} ${
|
|
5714
|
+
return ` ${s.source.padEnd(14)} ${pc19.dim(`last run ${when}`)} ${pc19.dim(`cursor ${cursorLabel}`)}`;
|
|
5454
5715
|
}),
|
|
5455
5716
|
"",
|
|
5456
|
-
gitCursor && gitCursor !== repo.head ? `${
|
|
5457
|
-
chains.failuresTotal ? `${
|
|
5458
|
-
structure.edges ? `${
|
|
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(`${
|
|
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(`${
|
|
5905
|
+
process.stderr.write(`${pc20.red("error")} ${message}
|
|
5641
5906
|
`);
|
|
5642
5907
|
process.exitCode = 1;
|
|
5643
5908
|
});
|