nexusmem 0.5.3 → 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 +35 -1
- package/README.md +20 -10
- package/dist/cli/index.js +323 -27
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,36 @@ built from, matched by publish timestamp: `v0.1.0` → `67a4776`, `v0.1.1` → `
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [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
|
+
|
|
35
|
+
## [0.5.4] — 2026-08-20
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- `nexusmem status --share`: a plain-text, no-color summary (node count, failure→fix chains
|
|
40
|
+
linked, days of history) meant to be pasted somewhere, not scraped by a script.
|
|
41
|
+
|
|
12
42
|
## [0.5.3] — 2026-08-19
|
|
13
43
|
|
|
14
44
|
No functional changes to the CLI, MCP server, or published package -- a test-coverage and
|
|
@@ -421,7 +451,11 @@ First public release.
|
|
|
421
451
|
there is no local-model summarization pass, and the conversation collector has never been audited
|
|
422
452
|
for the stale-node bug that was found and fixed in the docs collector.
|
|
423
453
|
|
|
424
|
-
[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
|
|
425
459
|
[0.5.1]: https://github.com/yaminbkk/NexusMem/compare/v0.5.0...v0.5.1
|
|
426
460
|
[0.5.0]: https://github.com/yaminbkk/NexusMem/compare/v0.4.0...v0.5.0
|
|
427
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
|
|
|
@@ -351,7 +360,8 @@ problem.
|
|
|
351
360
|
|
|
352
361
|
## Commands
|
|
353
362
|
|
|
354
|
-
`init`, `sync`, `query <text>`, `status
|
|
363
|
+
`init`, `sync`, `query <text>`, `status` (add `--share` for a plain-text summary worth pasting
|
|
364
|
+
somewhere), `projects`, `mcp`, `forget <value>`, `stale`,
|
|
355
365
|
`mark-stale <nodeId> --supersedes <newNodeId>`, and `hook install|remove|status`.
|
|
356
366
|
|
|
357
367
|
There are also five dry-run previews (`scan-git`, `scan-diff`, `scan-shell`, `scan-docs`,
|
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,15 +5594,71 @@ 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
|
|
5633
|
+
import { basename as basename4 } from "path";
|
|
5373
5634
|
import { statSync } from "fs";
|
|
5374
|
-
import
|
|
5635
|
+
import pc19 from "picocolors";
|
|
5636
|
+
function daySpan(oldest, newest) {
|
|
5637
|
+
const oldestDay = Date.parse(oldest.slice(0, 10));
|
|
5638
|
+
const newestDay = Date.parse(newest.slice(0, 10));
|
|
5639
|
+
return Math.round((newestDay - oldestDay) / 864e5) + 1;
|
|
5640
|
+
}
|
|
5641
|
+
function buildShareText(repo, stats2, chains) {
|
|
5642
|
+
if (stats2.total === 0) {
|
|
5643
|
+
return "Nothing synced yet in this repo -- run `nexusmem sync` first, then `nexusmem status --share`.\n";
|
|
5644
|
+
}
|
|
5645
|
+
const days = daySpan(stats2.oldest ?? stats2.newest ?? "", stats2.newest ?? stats2.oldest ?? "");
|
|
5646
|
+
const commits = stats2.byKind.git_commit ?? 0;
|
|
5647
|
+
const shellCommands = stats2.byKind.shell_command ?? 0;
|
|
5648
|
+
const docs = stats2.byKind.doc_section ?? 0;
|
|
5649
|
+
const parts = [`${commits} commit(s)`, `${shellCommands} shell command(s)`, `${docs} doc section(s)`].filter(
|
|
5650
|
+
(p) => !p.startsWith("0 ")
|
|
5651
|
+
);
|
|
5652
|
+
const lines = [
|
|
5653
|
+
`NexusMem has been watching ${basename4(repo.root)} for ${days} day(s):`,
|
|
5654
|
+
` ${stats2.total} memories${parts.length ? ` (${parts.join(", ")})` : ""}`
|
|
5655
|
+
];
|
|
5656
|
+
if (chains.failuresTotal) {
|
|
5657
|
+
lines.push(` ${chains.resolvedTotal}/${chains.failuresTotal} failure -> fix chain(s) linked`);
|
|
5658
|
+
}
|
|
5659
|
+
lines.push("", "Local-only SQLite, no cloud, no telemetry.", "https://github.com/yaminbkk/NexusMem");
|
|
5660
|
+
return lines.join("\n").concat("\n");
|
|
5661
|
+
}
|
|
5375
5662
|
function humanBytes(bytes) {
|
|
5376
5663
|
if (bytes < 1024) return `${bytes} B`;
|
|
5377
5664
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -5390,41 +5677,47 @@ async function runStatus(opts) {
|
|
|
5390
5677
|
const store = MemoryStore.open(ws.dbPath);
|
|
5391
5678
|
try {
|
|
5392
5679
|
const stats2 = store.stats(projectId);
|
|
5680
|
+
const chains = getChainStats(store, projectId);
|
|
5681
|
+
if (opts.share) {
|
|
5682
|
+
out(buildShareText(repo, stats2, chains));
|
|
5683
|
+
return 0;
|
|
5684
|
+
}
|
|
5393
5685
|
const sources = store.listSyncState(projectId);
|
|
5394
5686
|
const gitCursor = sources.find((s) => s.source === "git")?.cursor ?? null;
|
|
5395
5687
|
const schema = currentSchemaVersion(store.raw);
|
|
5396
|
-
const chains = getChainStats(store, projectId);
|
|
5397
5688
|
const otherProjectIds = store.listOtherProjectIds(projectId);
|
|
5398
5689
|
const otherProjectNodes = store.countProjectNodes(otherProjectIds);
|
|
5399
5690
|
const structure = store.fileEdgeStats(projectId);
|
|
5691
|
+
const staleCount = store.countStaleCandidates(projectId);
|
|
5400
5692
|
const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
|
|
5401
5693
|
const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
|
|
5402
|
-
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(
|
|
5403
5695
|
"nexusmem sync --prune-source <name>"
|
|
5404
5696
|
)} to remove stale source data` : "";
|
|
5405
5697
|
out(
|
|
5406
5698
|
[
|
|
5407
|
-
`${
|
|
5408
|
-
`${
|
|
5409
|
-
`${
|
|
5410
|
-
`${
|
|
5411
|
-
`${
|
|
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)})`)}`,
|
|
5412
5704
|
staleProjectWarning,
|
|
5413
5705
|
"",
|
|
5414
|
-
`${
|
|
5706
|
+
`${pc19.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc19.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
|
|
5415
5707
|
...kinds,
|
|
5416
|
-
stats2.total ? ` ${
|
|
5708
|
+
stats2.total ? ` ${pc19.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
|
|
5417
5709
|
"",
|
|
5418
|
-
sources.length ?
|
|
5710
|
+
sources.length ? pc19.dim("sources") : pc19.yellow("no sources synced yet"),
|
|
5419
5711
|
...sources.map((s) => {
|
|
5420
5712
|
const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
|
|
5421
5713
|
const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
|
|
5422
|
-
return ` ${s.source.padEnd(14)} ${
|
|
5714
|
+
return ` ${s.source.padEnd(14)} ${pc19.dim(`last run ${when}`)} ${pc19.dim(`cursor ${cursorLabel}`)}`;
|
|
5423
5715
|
}),
|
|
5424
5716
|
"",
|
|
5425
|
-
gitCursor && gitCursor !== repo.head ? `${
|
|
5426
|
-
chains.failuresTotal ? `${
|
|
5427
|
-
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")}` : ""
|
|
5428
5721
|
].filter((line) => line !== "").join("\n").concat("\n")
|
|
5429
5722
|
);
|
|
5430
5723
|
return 0;
|
|
@@ -5447,7 +5740,7 @@ function guard(run) {
|
|
|
5447
5740
|
process.exitCode = await run();
|
|
5448
5741
|
} catch (err) {
|
|
5449
5742
|
if (isExpected(err)) {
|
|
5450
|
-
process.stderr.write(`${
|
|
5743
|
+
process.stderr.write(`${pc20.red("error")} ${err.message}
|
|
5451
5744
|
`);
|
|
5452
5745
|
process.exitCode = 1;
|
|
5453
5746
|
return;
|
|
@@ -5509,7 +5802,7 @@ program.command("hook").description("Manage the opt-in PowerShell hook that logs
|
|
|
5509
5802
|
new Command("status").description("Show whether the git pre-commit hook is installed").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runHookGitStatus({ cwd: options.cwd }))())
|
|
5510
5803
|
)
|
|
5511
5804
|
);
|
|
5512
|
-
program.command("status").description("Show what is currently remembered for this repository").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runStatus({ cwd: options.cwd }))());
|
|
5805
|
+
program.command("status").description("Show what is currently remembered for this repository").option("-C, --cwd <path>", "repository path", process.cwd()).option("--share", "print a plain-text summary formatted for sharing, e.g. on X or Reddit").action((options) => guard(() => runStatus({ cwd: options.cwd, share: options.share }))());
|
|
5513
5806
|
program.command("query").description("Search remembered history and print a token-budgeted context block").argument("<text>", "free-text query").option("-C, --cwd <path>", "repository path", process.cwd()).option("-b, --budget <tokens>", "max tokens in the packed context", (v) => Number.parseInt(v, 10), 2e3).option("-n, --candidates <count>", "how many search hits to rank before packing", (v) => Number.parseInt(v, 10), 30).option("--half-life <days>", "days for a node's recency weight to halve", (v) => Number.parseFloat(v)).option("--no-vector", "BM25 only -- skip embedding the query and vector search").option("-a, --all-projects", "search every registered repository, not just this one", false).option("--json", "emit the packed result as JSON on stdout", false).action(
|
|
5514
5807
|
(text, options) => guard(
|
|
5515
5808
|
() => runQuery({
|
|
@@ -5546,6 +5839,9 @@ program.command("mark-stale").description(
|
|
|
5546
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(
|
|
5547
5840
|
(nodeId, options) => guard(() => runMarkStale({ cwd: options.cwd, nodeId, supersedesId: options.supersedes }))()
|
|
5548
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
|
+
);
|
|
5549
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 }))());
|
|
5550
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(
|
|
5551
5847
|
(options) => guard(
|
|
@@ -5602,11 +5898,11 @@ program.command("precheck").description("Warn about staged files with unresolved
|
|
|
5602
5898
|
})
|
|
5603
5899
|
)()
|
|
5604
5900
|
);
|
|
5605
|
-
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 }))());
|
|
5606
5902
|
program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
|
|
5607
5903
|
program.parseAsync(process.argv).catch((err) => {
|
|
5608
5904
|
const message = err instanceof Error ? err.message : String(err);
|
|
5609
|
-
process.stderr.write(`${
|
|
5905
|
+
process.stderr.write(`${pc20.red("error")} ${message}
|
|
5610
5906
|
`);
|
|
5611
5907
|
process.exitCode = 1;
|
|
5612
5908
|
});
|