nexusmem 0.7.0 → 0.8.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 +25 -1
- package/README.md +25 -16
- package/dist/cli/index.js +246 -96
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,29 @@ built from, matched by publish timestamp: `v0.1.0` → `67a4776`, `v0.1.1` → `
|
|
|
11
11
|
|
|
12
12
|
No unreleased changes yet.
|
|
13
13
|
|
|
14
|
+
## [0.8.0] — 2026-08-22
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Provenance widened from 2 tiers to a 4-tier trust hierarchy: `observed` (commits, diffs, shell
|
|
19
|
+
exit codes) > `authored` (doc sections — human-written claims) > `recorded` (conversation turns —
|
|
20
|
+
verbatim discourse) > `derived` (session summaries — a model's distillation). Schema V7 backfills
|
|
21
|
+
existing databases by kind. The ranker decays each tier at its own rate (lower trust fades
|
|
22
|
+
faster); the ordering is the design claim, the exact ratios are documented judgment calls.
|
|
23
|
+
- Contradiction checking now runs automatically during `sync`: at most 3 new SLM judgments per run
|
|
24
|
+
(`contradictions.maxPerSync`; `contradictions.autoCheck: false` turns it off), gated on the
|
|
25
|
+
embedding provider already being reachable. Every judgment — either verdict — is memoized in a new
|
|
26
|
+
`contradiction_checks` table (schema V8), so a judged pair is never sent to the model again and
|
|
27
|
+
repeat syncs converge to zero model calls. Measured live against this repo's own database: 5.4s
|
|
28
|
+
for the first 10 judgments, 0.5s for the identical re-run. Still suggest-only: nothing ever writes
|
|
29
|
+
`supersedes` automatically.
|
|
30
|
+
- Standing suggestions now surface everywhere without a model in the loop: plain `nexusmem stale`
|
|
31
|
+
decorates flagged candidates from the memoized judgments (instant, offline), `nexusmem status`
|
|
32
|
+
gains a `flagged` line, and the sync summary reports new/open suggestion counts.
|
|
33
|
+
- `stale --check-contradictions` reuses each candidate's stored embedding instead of re-embedding
|
|
34
|
+
it, and stops after two consecutive null SLM replies so a down provider costs at most two timeouts
|
|
35
|
+
rather than one per candidate.
|
|
36
|
+
|
|
14
37
|
## [0.7.0] — 2026-08-21
|
|
15
38
|
|
|
16
39
|
### Added
|
|
@@ -481,7 +504,8 @@ First public release.
|
|
|
481
504
|
there is no local-model summarization pass, and the conversation collector has never been audited
|
|
482
505
|
for the stale-node bug that was found and fixed in the docs collector.
|
|
483
506
|
|
|
484
|
-
[Unreleased]: https://github.com/yaminbkk/NexusMem/compare/v0.
|
|
507
|
+
[Unreleased]: https://github.com/yaminbkk/NexusMem/compare/v0.8.0...HEAD
|
|
508
|
+
[0.8.0]: https://github.com/yaminbkk/NexusMem/compare/v0.7.0...v0.8.0
|
|
485
509
|
[0.7.0]: https://github.com/yaminbkk/NexusMem/compare/v0.6.0...v0.7.0
|
|
486
510
|
[0.6.0]: https://github.com/yaminbkk/NexusMem/compare/v0.5.4...v0.6.0
|
|
487
511
|
[0.5.4]: https://github.com/yaminbkk/NexusMem/compare/v0.5.3...v0.5.4
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ exists nowhere else, and it disappears when your terminal scrollback rolls over.
|
|
|
24
24
|
**Contents:** [Try it](#try-it) · [Exact shell capture](#optional-exact-shell-capture) ·
|
|
25
25
|
[Failure → fix chains](#failure--fix-chains-opt-in) · [How retrieval works](#how-retrieval-works) ·
|
|
26
26
|
[Session summaries](#session-summaries-optional-local-model) · [Use it from an agent](#use-it-from-an-agent)
|
|
27
|
-
· [What it costs you](#what-it-costs-you) · [
|
|
27
|
+
· [What it costs you](#what-it-costs-you) · [Staleness & provenance](#staleness--provenance) ·
|
|
28
28
|
[Where it breaks](#where-it-breaks) · [Commands](#commands) · [Cross-project recall](#recall-across-projects)
|
|
29
29
|
· [On disk](#on-disk) · [Development](#development)
|
|
30
30
|
|
|
@@ -48,11 +48,11 @@ Relevant history for: windows spawn failure
|
|
|
48
48
|
readRepoInfo collapsed three unrelated failures into one error: git running and reporting
|
|
49
49
|
the path is not a work tree, git not being installed, and the process failing to spawn at
|
|
50
50
|
all. Dogfooding hit the third case in two separate sessions...
|
|
51
|
-
- 2026-08-09 [
|
|
51
|
+
- 2026-08-09 [authored] README.md — Before a tagged release
|
|
52
52
|
- [ ] Retry on transient process-spawn failures on Windows
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
`[observed]`/`[
|
|
55
|
+
`[observed]`/`[authored]` is the provenance tag (see [Staleness & provenance](#staleness--provenance))
|
|
56
56
|
— a commit is a directly observed event, a doc section is a written claim that could go stale.
|
|
57
57
|
|
|
58
58
|
A commit and a docs section, ranked against each other, inside whatever token budget you gave it.
|
|
@@ -269,19 +269,23 @@ Two things a memory layer needs and this one only partly has: a way to tell an o
|
|
|
269
269
|
guess, and a way to retire a conclusion once something contradicts it. This section is what exists
|
|
270
270
|
and what doesn't.
|
|
271
271
|
|
|
272
|
-
Every node carries a `provenance
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
272
|
+
Every node carries a `provenance`, a four-tier trust hierarchy set once per collector at ingest
|
|
273
|
+
time: `observed` (a commit that landed, a shell command's real exit code) > `authored` (a doc
|
|
274
|
+
section — a human's own written claim) > `recorded` (a conversation turn — verbatim, but talk about
|
|
275
|
+
events rather than the events) > `derived` (a session summary — a model's distillation). The tier is
|
|
276
|
+
shown as a tag on every query result and decays retrieval weight — the lower the trust, the faster a
|
|
277
|
+
node fades from ranking as it ages. The ordering is the design claim; the exact decay ratios are
|
|
278
|
+
judgment calls, not measured optima.
|
|
277
279
|
|
|
278
280
|
```bash
|
|
279
281
|
nexusmem stale
|
|
280
282
|
```
|
|
281
283
|
|
|
282
|
-
Lists `
|
|
283
|
-
a heuristic on age and provenance, not on content. It writes nothing; you decide which
|
|
284
|
-
actually wrong.
|
|
284
|
+
Lists non-`observed` nodes old enough (45+ days by default) that nothing has confirmed they still
|
|
285
|
+
hold — a heuristic on age and provenance, not on content. It writes nothing; you decide which
|
|
286
|
+
candidates are actually wrong. Any candidate the SLM has already flagged (see below) is decorated
|
|
287
|
+
with its standing `likely superseded by` suggestion — reading those costs nothing, so the plain
|
|
288
|
+
command stays instant and offline.
|
|
285
289
|
|
|
286
290
|
```bash
|
|
287
291
|
nexusmem mark-stale <oldNodeId> --supersedes <newNodeId>
|
|
@@ -297,15 +301,20 @@ nexusmem stale --check-contradictions
|
|
|
297
301
|
For each candidate, finds the most similar newer node (local embedding search) and asks a local SLM
|
|
298
302
|
(Ollama, `qwen2.5:3b` by default) whether it actually contradicts the older one — real content
|
|
299
303
|
comparison, not just age. A match is printed as `likely superseded by <id> <title> -- <reason>`
|
|
300
|
-
under the candidate
|
|
301
|
-
|
|
302
|
-
|
|
304
|
+
under the candidate. Every judgment (either verdict) is memoized, so a judged pair is never sent to
|
|
305
|
+
the model again; nothing else is written — `supersedes` stays yours to set via `mark-stale`.
|
|
306
|
+
|
|
307
|
+
**This also runs automatically during `sync`** — at most 3 new judgments per run (configurable via
|
|
308
|
+
the `contradictions` block in `.nexusmem/config.json`; set `autoCheck: false` to turn it off), only
|
|
309
|
+
when the embedding provider was reachable anyway, and free on repeat syncs thanks to the
|
|
310
|
+
memoization. New and open suggestions show up in the sync summary, `nexusmem status` (a `flagged`
|
|
311
|
+
line), and plain `nexusmem stale`.
|
|
303
312
|
|
|
304
313
|
**What this doesn't do:** it is one small model's yes/no judgment on one older/newer pair, not a
|
|
305
314
|
verified fact — treat a match as a lead to check, not a conclusion. It also only ever compares a
|
|
306
315
|
candidate against nodes *found by embedding similarity*; a contradiction from an unrelated-sounding
|
|
307
|
-
node would never surface.
|
|
308
|
-
|
|
316
|
+
node would never surface. Comprehensive contradiction detection (not just for the pair the vector
|
|
317
|
+
search happens to surface) is still an open problem, and nothing here supersedes a node on its own.
|
|
309
318
|
|
|
310
319
|
## Where it breaks
|
|
311
320
|
|
package/dist/cli/index.js
CHANGED
|
@@ -174,7 +174,20 @@ var ConfigSchema = z.object({
|
|
|
174
174
|
limits: z.object({
|
|
175
175
|
maxFilesPerNode: z.number().int().positive().default(40),
|
|
176
176
|
maxBodyChars: z.number().int().positive().default(4e3)
|
|
177
|
-
}).default({ maxFilesPerNode: 40, maxBodyChars: 4e3 })
|
|
177
|
+
}).default({ maxFilesPerNode: 40, maxBodyChars: 4e3 }),
|
|
178
|
+
/**
|
|
179
|
+
* Automatic contradiction checking during sync. On by default -- unlike the
|
|
180
|
+
* opt-in transcript sources, this reads nothing new, writes only suggestions
|
|
181
|
+
* (never `supersedes`), and stays affordable by construction: at most
|
|
182
|
+
* `maxPerSync` new SLM judgments per run, judged pairs memoized and never
|
|
183
|
+
* re-asked, and an unreachable model degrades to skipping quietly.
|
|
184
|
+
*/
|
|
185
|
+
contradictions: z.object({
|
|
186
|
+
autoCheck: z.boolean().default(true),
|
|
187
|
+
maxPerSync: z.number().int().nonnegative().default(3),
|
|
188
|
+
/** Ollama model tag. Must be pulled locally, same as `sources.session.model`. */
|
|
189
|
+
model: z.string().default(DEFAULT_SLM_MODEL)
|
|
190
|
+
}).default({ autoCheck: true, maxPerSync: 3, model: DEFAULT_SLM_MODEL })
|
|
178
191
|
});
|
|
179
192
|
function defaultConfig(projectId) {
|
|
180
193
|
return ConfigSchema.parse({ version: 1, projectId });
|
|
@@ -944,13 +957,32 @@ UPDATE nodes SET provenance = 'observed' WHERE kind IN ('git_commit', 'code_diff
|
|
|
944
957
|
|
|
945
958
|
CREATE INDEX idx_nodes_supersedes ON nodes (supersedes) WHERE supersedes IS NOT NULL;
|
|
946
959
|
`;
|
|
960
|
+
var V7 = `
|
|
961
|
+
UPDATE nodes SET provenance = 'authored' WHERE kind IN ('doc_section', 'note');
|
|
962
|
+
UPDATE nodes SET provenance = 'recorded' WHERE kind = 'conversation_turn';
|
|
963
|
+
UPDATE nodes SET provenance = 'derived' WHERE kind = 'session_summary';
|
|
964
|
+
UPDATE nodes SET provenance = 'recorded' WHERE provenance = 'inferred';
|
|
965
|
+
`;
|
|
966
|
+
var V8 = `
|
|
967
|
+
CREATE TABLE contradiction_checks (
|
|
968
|
+
candidate_id TEXT NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
|
|
969
|
+
against_id TEXT NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
|
|
970
|
+
contradicts INTEGER NOT NULL,
|
|
971
|
+
reason TEXT,
|
|
972
|
+
model TEXT NOT NULL,
|
|
973
|
+
checked_at INTEGER NOT NULL,
|
|
974
|
+
PRIMARY KEY (candidate_id, against_id)
|
|
975
|
+
);
|
|
976
|
+
`;
|
|
947
977
|
var MIGRATIONS = [
|
|
948
978
|
{ version: 1, up: (db) => db.exec(V1) },
|
|
949
979
|
{ version: 2, up: (db) => db.exec(V2) },
|
|
950
980
|
{ version: 3, up: (db) => db.exec(V3) },
|
|
951
981
|
{ version: 4, up: (db) => db.exec(V4) },
|
|
952
982
|
{ version: 5, up: (db) => db.exec(V5) },
|
|
953
|
-
{ version: 6, up: (db) => db.exec(V6) }
|
|
983
|
+
{ version: 6, up: (db) => db.exec(V6) },
|
|
984
|
+
{ version: 7, up: (db) => db.exec(V7) },
|
|
985
|
+
{ version: 8, up: (db) => db.exec(V8) }
|
|
954
986
|
];
|
|
955
987
|
var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
|
|
956
988
|
function currentSchemaVersion(db) {
|
|
@@ -1012,11 +1044,13 @@ function defaultProvenanceForKind(kind) {
|
|
|
1012
1044
|
case "code_diff":
|
|
1013
1045
|
case "shell_command":
|
|
1014
1046
|
return "observed";
|
|
1015
|
-
case "conversation_turn":
|
|
1016
|
-
case "session_summary":
|
|
1017
1047
|
case "doc_section":
|
|
1018
1048
|
case "note":
|
|
1019
|
-
return "
|
|
1049
|
+
return "authored";
|
|
1050
|
+
case "conversation_turn":
|
|
1051
|
+
return "recorded";
|
|
1052
|
+
case "session_summary":
|
|
1053
|
+
return "derived";
|
|
1020
1054
|
}
|
|
1021
1055
|
}
|
|
1022
1056
|
|
|
@@ -1171,7 +1205,7 @@ function listStaleCandidates(db, projectId, opts = {}) {
|
|
|
1171
1205
|
const rows = db.prepare(
|
|
1172
1206
|
`SELECT id, kind, ts, ts_epoch AS tsEpoch, source, title
|
|
1173
1207
|
FROM nodes
|
|
1174
|
-
WHERE project_id = @projectId AND provenance
|
|
1208
|
+
WHERE project_id = @projectId AND provenance != 'observed' AND ts_epoch < @cutoff
|
|
1175
1209
|
AND id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)
|
|
1176
1210
|
ORDER BY ts_epoch ASC
|
|
1177
1211
|
LIMIT @limit`
|
|
@@ -1191,7 +1225,7 @@ function countStaleCandidates(db, projectId, opts = {}) {
|
|
|
1191
1225
|
const cutoff = now.getTime() - minAgeDays * 864e5;
|
|
1192
1226
|
const row = db.prepare(
|
|
1193
1227
|
`SELECT COUNT(*) AS count FROM nodes
|
|
1194
|
-
WHERE project_id = @projectId AND provenance
|
|
1228
|
+
WHERE project_id = @projectId AND provenance != 'observed' AND ts_epoch < @cutoff
|
|
1195
1229
|
AND id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)`
|
|
1196
1230
|
).get({ projectId, cutoff });
|
|
1197
1231
|
return row.count;
|
|
@@ -1372,6 +1406,11 @@ function countNodesNeedingEmbedding(db, projectId) {
|
|
|
1372
1406
|
).get(projectId);
|
|
1373
1407
|
return row.n;
|
|
1374
1408
|
}
|
|
1409
|
+
function getEmbedding(db, nodeId) {
|
|
1410
|
+
const row = db.prepare("SELECT v.embedding AS embedding FROM nodes_vec v JOIN nodes n ON n.rowid = v.rowid WHERE n.id = ?").get(nodeId);
|
|
1411
|
+
if (!row) return null;
|
|
1412
|
+
return new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4);
|
|
1413
|
+
}
|
|
1375
1414
|
function upsertEmbedding(db, rowid, embedding) {
|
|
1376
1415
|
db.prepare("INSERT OR REPLACE INTO nodes_vec (rowid, embedding) VALUES (?, ?)").run(BigInt(rowid), embedding);
|
|
1377
1416
|
}
|
|
@@ -1449,6 +1488,49 @@ function setMeta(db, key, value) {
|
|
|
1449
1488
|
);
|
|
1450
1489
|
}
|
|
1451
1490
|
|
|
1491
|
+
// src/store/contradictions.ts
|
|
1492
|
+
function recordContradictionCheck(db, input) {
|
|
1493
|
+
db.prepare(
|
|
1494
|
+
`INSERT OR REPLACE INTO contradiction_checks (candidate_id, against_id, contradicts, reason, model, checked_at)
|
|
1495
|
+
VALUES (@candidateId, @againstId, @contradicts, @reason, @model, @now)`
|
|
1496
|
+
).run({
|
|
1497
|
+
candidateId: input.candidateId,
|
|
1498
|
+
againstId: input.againstId,
|
|
1499
|
+
contradicts: input.contradicts ? 1 : 0,
|
|
1500
|
+
reason: input.reason,
|
|
1501
|
+
model: input.model,
|
|
1502
|
+
now: Date.now()
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
function hasContradictionCheck(db, candidateId, againstId) {
|
|
1506
|
+
const row = db.prepare("SELECT 1 FROM contradiction_checks WHERE candidate_id = ? AND against_id = ?").get(candidateId, againstId);
|
|
1507
|
+
return row !== void 0;
|
|
1508
|
+
}
|
|
1509
|
+
function listContradictionSuggestions(db, projectId, opts = {}) {
|
|
1510
|
+
return db.prepare(
|
|
1511
|
+
`SELECT c.candidate_id AS candidateId, n.title AS candidateTitle,
|
|
1512
|
+
c.against_id AS againstId, a.title AS againstTitle,
|
|
1513
|
+
c.reason AS reason, c.checked_at AS checkedAt
|
|
1514
|
+
FROM contradiction_checks c
|
|
1515
|
+
JOIN nodes n ON n.id = c.candidate_id
|
|
1516
|
+
JOIN nodes a ON a.id = c.against_id
|
|
1517
|
+
WHERE n.project_id = @projectId AND c.contradicts = 1
|
|
1518
|
+
AND c.candidate_id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)
|
|
1519
|
+
ORDER BY c.checked_at DESC
|
|
1520
|
+
LIMIT @limit`
|
|
1521
|
+
).all({ projectId, limit: opts.limit ?? 50 });
|
|
1522
|
+
}
|
|
1523
|
+
function countContradictionSuggestions(db, projectId) {
|
|
1524
|
+
const row = db.prepare(
|
|
1525
|
+
`SELECT COUNT(*) AS count
|
|
1526
|
+
FROM contradiction_checks c
|
|
1527
|
+
JOIN nodes n ON n.id = c.candidate_id
|
|
1528
|
+
WHERE n.project_id = @projectId AND c.contradicts = 1
|
|
1529
|
+
AND c.candidate_id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)`
|
|
1530
|
+
).get({ projectId });
|
|
1531
|
+
return row.count;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1452
1534
|
// src/store/store.ts
|
|
1453
1535
|
var MemoryStore = class _MemoryStore {
|
|
1454
1536
|
constructor(db) {
|
|
@@ -1608,6 +1690,10 @@ var MemoryStore = class _MemoryStore {
|
|
|
1608
1690
|
upsertEmbedding(rowid, embedding) {
|
|
1609
1691
|
upsertEmbedding(this.db, rowid, embedding);
|
|
1610
1692
|
}
|
|
1693
|
+
/** The stored vector for one node, or null if it has not been embedded yet. */
|
|
1694
|
+
getEmbedding(nodeId) {
|
|
1695
|
+
return getEmbedding(this.db, nodeId);
|
|
1696
|
+
}
|
|
1611
1697
|
dropAllEmbeddings() {
|
|
1612
1698
|
return dropAllEmbeddings(this.db);
|
|
1613
1699
|
}
|
|
@@ -1638,7 +1724,7 @@ var MemoryStore = class _MemoryStore {
|
|
|
1638
1724
|
setSupersedes(newNodeId, staleNodeId) {
|
|
1639
1725
|
setSupersedes(this.db, newNodeId, staleNodeId);
|
|
1640
1726
|
}
|
|
1641
|
-
/** Aging `
|
|
1727
|
+
/** Aging non-`observed` nodes nothing supersedes yet -- candidates for `nexusmem mark-stale`, not auto-applied. */
|
|
1642
1728
|
listStaleCandidates(projectId, opts = {}) {
|
|
1643
1729
|
return listStaleCandidates(this.db, projectId, opts);
|
|
1644
1730
|
}
|
|
@@ -1646,6 +1732,20 @@ var MemoryStore = class _MemoryStore {
|
|
|
1646
1732
|
countStaleCandidates(projectId, opts = {}) {
|
|
1647
1733
|
return countStaleCandidates(this.db, projectId, opts);
|
|
1648
1734
|
}
|
|
1735
|
+
/** Memoize one SLM contradiction judgment (either verdict). Suggest-only: never writes `supersedes`. */
|
|
1736
|
+
recordContradictionCheck(input) {
|
|
1737
|
+
recordContradictionCheck(this.db, input);
|
|
1738
|
+
}
|
|
1739
|
+
hasContradictionCheck(candidateId, againstId) {
|
|
1740
|
+
return hasContradictionCheck(this.db, candidateId, againstId);
|
|
1741
|
+
}
|
|
1742
|
+
/** Open YES verdicts awaiting a human's `mark-stale`, newest judgment first. */
|
|
1743
|
+
listContradictionSuggestions(projectId, opts = {}) {
|
|
1744
|
+
return listContradictionSuggestions(this.db, projectId, opts);
|
|
1745
|
+
}
|
|
1746
|
+
countContradictionSuggestions(projectId) {
|
|
1747
|
+
return countContradictionSuggestions(this.db, projectId);
|
|
1748
|
+
}
|
|
1649
1749
|
/** Escape hatch for tests and future modules. */
|
|
1650
1750
|
get raw() {
|
|
1651
1751
|
return this.db;
|
|
@@ -2458,7 +2558,12 @@ var SIGNAL_FLOOR = 0.2;
|
|
|
2458
2558
|
var RECENCY_FLOOR = 0.3;
|
|
2459
2559
|
var DEFAULT_HALF_LIFE_DAYS = 30;
|
|
2460
2560
|
var MS_PER_DAY = 864e5;
|
|
2461
|
-
var
|
|
2561
|
+
var HALF_LIFE_RATIO = {
|
|
2562
|
+
observed: 1,
|
|
2563
|
+
authored: 0.75,
|
|
2564
|
+
recorded: 0.5,
|
|
2565
|
+
derived: 0.35
|
|
2566
|
+
};
|
|
2462
2567
|
var SUPERSEDED_PENALTY = 0.5;
|
|
2463
2568
|
var MAX_PRIOR_OVERTURN = 2;
|
|
2464
2569
|
var PRIOR_COUNT = 2;
|
|
@@ -2493,14 +2598,14 @@ function ageDaysOf(ts, now) {
|
|
|
2493
2598
|
function rankHits(hits, opts = {}) {
|
|
2494
2599
|
if (hits.length === 0) return [];
|
|
2495
2600
|
const halfLife = opts.halfLifeDays ?? DEFAULT_HALF_LIFE_DAYS;
|
|
2496
|
-
const
|
|
2601
|
+
const ratios = { ...HALF_LIFE_RATIO, ...opts.halfLifeRatios };
|
|
2497
2602
|
const now = opts.now ?? /* @__PURE__ */ new Date();
|
|
2498
2603
|
const relevances = opts.relevanceScores ? normalizeExternalRelevance(hits, opts.relevanceScores) : normalizeRelevance(hits);
|
|
2499
2604
|
const ranked = hits.map((hit, i) => {
|
|
2500
2605
|
const relevance = relevances[i] ?? RELEVANCE_FLOOR;
|
|
2501
2606
|
const signalWeight = SIGNAL_FLOOR + (1 - SIGNAL_FLOOR) * hit.signal;
|
|
2502
2607
|
const ageDays = ageDaysOf(hit.ts, now);
|
|
2503
|
-
const effectiveHalfLife = hit.provenance
|
|
2608
|
+
const effectiveHalfLife = halfLife * (ratios[hit.provenance] ?? 1);
|
|
2504
2609
|
const recencyFactor = RECENCY_FLOOR + (1 - RECENCY_FLOOR) * 2 ** (-ageDays / effectiveHalfLife);
|
|
2505
2610
|
const rawScore = relevance * signalWeight ** SIGNAL_EXPONENT * recencyFactor ** RECENCY_EXPONENT;
|
|
2506
2611
|
const score = opts.supersededIds?.has(hit.id) ? rawScore * SUPERSEDED_PENALTY : rawScore;
|
|
@@ -2830,8 +2935,8 @@ function toMemoryNodes(turn, projectId, opts = {}) {
|
|
|
2830
2935
|
files: extractMentionedFiles(`${userRedacted.text}
|
|
2831
2936
|
${chunk2.text}`),
|
|
2832
2937
|
signal: scoreConversationTurn(userRedacted.text, chunk2.text),
|
|
2833
|
-
provenance: "
|
|
2834
|
-
// discourse about what happened, not the event itself
|
|
2938
|
+
provenance: "recorded",
|
|
2939
|
+
// verbatim discourse about what happened, not the event itself
|
|
2835
2940
|
meta: {
|
|
2836
2941
|
cwd: turn.cwd,
|
|
2837
2942
|
source: turn.source,
|
|
@@ -3403,8 +3508,8 @@ function toMemoryNodes3(file, projectId, opts = {}) {
|
|
|
3403
3508
|
body: truncate(chunk2.text, maxBody),
|
|
3404
3509
|
files: [{ path: file.path, insertions: null, deletions: null, binary: false }],
|
|
3405
3510
|
signal: scoreDocSection(file.path, chunk2.heading, chunk2.text),
|
|
3406
|
-
provenance: "
|
|
3407
|
-
// a written claim
|
|
3511
|
+
provenance: "authored",
|
|
3512
|
+
// a human's own written claim -- deliberate, but can still go stale
|
|
3408
3513
|
meta: {
|
|
3409
3514
|
path: file.path,
|
|
3410
3515
|
heading: chunk2.heading,
|
|
@@ -3566,7 +3671,7 @@ ${summary.body}`;
|
|
|
3566
3671
|
files: extractMentionedFiles(session.turns.map((t) => `${t.userText}
|
|
3567
3672
|
${t.assistantText}`).join("\n")),
|
|
3568
3673
|
signal: scoreSession(session.turns.length),
|
|
3569
|
-
provenance: "
|
|
3674
|
+
provenance: "derived",
|
|
3570
3675
|
// a model's distillation, not a directly observed event
|
|
3571
3676
|
meta: {
|
|
3572
3677
|
sessionKey: session.sessionKey,
|
|
@@ -3820,6 +3925,97 @@ async function readDocFiles(repoRoot, opts = {}) {
|
|
|
3820
3925
|
return { files, unreadable };
|
|
3821
3926
|
}
|
|
3822
3927
|
|
|
3928
|
+
// src/slm/contradiction.ts
|
|
3929
|
+
var MAX_BODY_CHARS = 1500;
|
|
3930
|
+
var MAX_REASON_CHARS = 200;
|
|
3931
|
+
var CONTRADICTION_INSTRUCTIONS = `You are checking whether a NEWER memory replaces or contradicts an OLDER one, for an AI coding assistant's memory index.
|
|
3932
|
+
|
|
3933
|
+
Answer in exactly this shape:
|
|
3934
|
+
VERDICT: YES or NO
|
|
3935
|
+
REASON: <one line, under 20 words>
|
|
3936
|
+
|
|
3937
|
+
Say YES only if the NEWER memory states something that makes the OLDER one factually wrong or obsolete -- a decision reversed, a bug fixed, a plan abandoned. Say NO if they are about different things, or the newer one only adds detail without contradicting the older one. When unsure, say NO.`;
|
|
3938
|
+
function buildContradictionPrompt(older, newer) {
|
|
3939
|
+
const body = [
|
|
3940
|
+
`OLDER (${older.title}):`,
|
|
3941
|
+
truncate(older.body, MAX_BODY_CHARS),
|
|
3942
|
+
"",
|
|
3943
|
+
`NEWER (${newer.title}):`,
|
|
3944
|
+
truncate(newer.body, MAX_BODY_CHARS)
|
|
3945
|
+
].join("\n");
|
|
3946
|
+
return `${CONTRADICTION_INSTRUCTIONS}
|
|
3947
|
+
|
|
3948
|
+
---
|
|
3949
|
+
|
|
3950
|
+
${body}
|
|
3951
|
+
|
|
3952
|
+
---
|
|
3953
|
+
|
|
3954
|
+
Answer:`;
|
|
3955
|
+
}
|
|
3956
|
+
function parseContradictionVerdict(raw) {
|
|
3957
|
+
const lines = raw.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
|
3958
|
+
const verdictLine = lines.find((l) => /^VERDICT:/i.test(l));
|
|
3959
|
+
if (!verdictLine) return null;
|
|
3960
|
+
const verdict = /^VERDICT:\s*(YES|NO)\b/i.exec(verdictLine);
|
|
3961
|
+
if (!verdict) return null;
|
|
3962
|
+
const reasonLine = lines.find((l) => /^REASON:/i.test(l));
|
|
3963
|
+
const reason = reasonLine ? reasonLine.replace(/^REASON:\s*/i, "").trim() : "";
|
|
3964
|
+
return {
|
|
3965
|
+
contradicts: verdict[1].toUpperCase() === "YES",
|
|
3966
|
+
reason: truncate(reason, MAX_REASON_CHARS)
|
|
3967
|
+
};
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3970
|
+
// src/retrieval/contradiction.ts
|
|
3971
|
+
var DEFAULT_LIMIT = 10;
|
|
3972
|
+
var DEFAULT_NEIGHBOR_LIMIT = 25;
|
|
3973
|
+
async function checkContradictions(store, embeddingProvider, slmProvider, projectId, candidates, opts = {}) {
|
|
3974
|
+
const limit = opts.limit ?? DEFAULT_LIMIT;
|
|
3975
|
+
const neighborLimit = opts.neighborLimit ?? DEFAULT_NEIGHBOR_LIMIT;
|
|
3976
|
+
const model = opts.model ?? DEFAULT_SLM_MODEL;
|
|
3977
|
+
const suggestions = [];
|
|
3978
|
+
let judgments = 0;
|
|
3979
|
+
let consecutiveNullReplies = 0;
|
|
3980
|
+
for (const candidate of candidates.slice(0, limit)) {
|
|
3981
|
+
if (opts.maxJudgments !== void 0 && judgments >= opts.maxJudgments) break;
|
|
3982
|
+
const full = store.getNodesByIds([candidate.id])[0];
|
|
3983
|
+
if (!full) continue;
|
|
3984
|
+
const embedding = store.getEmbedding(candidate.id) ?? await embeddingProvider.embed(`${full.title}
|
|
3985
|
+
${full.body}`);
|
|
3986
|
+
if (!embedding) continue;
|
|
3987
|
+
const candidateEpoch = Date.parse(candidate.ts);
|
|
3988
|
+
const nearest = store.vectorSearch(projectId, embedding, neighborLimit + 1).find((hit) => hit.id !== candidate.id && Date.parse(hit.ts) > candidateEpoch);
|
|
3989
|
+
if (!nearest) continue;
|
|
3990
|
+
if (store.hasContradictionCheck(candidate.id, nearest.id)) continue;
|
|
3991
|
+
const reply = await slmProvider.complete(buildContradictionPrompt(full, nearest));
|
|
3992
|
+
if (!reply) {
|
|
3993
|
+
consecutiveNullReplies += 1;
|
|
3994
|
+
if (consecutiveNullReplies >= 2) break;
|
|
3995
|
+
continue;
|
|
3996
|
+
}
|
|
3997
|
+
consecutiveNullReplies = 0;
|
|
3998
|
+
const verdict = parseContradictionVerdict(reply);
|
|
3999
|
+
if (!verdict) continue;
|
|
4000
|
+
judgments += 1;
|
|
4001
|
+
store.recordContradictionCheck({
|
|
4002
|
+
candidateId: candidate.id,
|
|
4003
|
+
againstId: nearest.id,
|
|
4004
|
+
contradicts: verdict.contradicts,
|
|
4005
|
+
reason: verdict.contradicts ? verdict.reason : null,
|
|
4006
|
+
model
|
|
4007
|
+
});
|
|
4008
|
+
if (!verdict.contradicts) continue;
|
|
4009
|
+
suggestions.push({
|
|
4010
|
+
candidateId: candidate.id,
|
|
4011
|
+
againstId: nearest.id,
|
|
4012
|
+
againstTitle: nearest.title,
|
|
4013
|
+
reason: verdict.reason
|
|
4014
|
+
});
|
|
4015
|
+
}
|
|
4016
|
+
return suggestions;
|
|
4017
|
+
}
|
|
4018
|
+
|
|
3823
4019
|
// src/shell/detect.ts
|
|
3824
4020
|
import { existsSync as existsSync4 } from "fs";
|
|
3825
4021
|
import { readFile as readFile9, stat as stat2 } from "fs/promises";
|
|
@@ -4929,6 +5125,21 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
|
|
|
4929
5125
|
`);
|
|
4930
5126
|
return 0;
|
|
4931
5127
|
}
|
|
5128
|
+
async function runAutoContradictionCheck(store, config, projectId, providers) {
|
|
5129
|
+
if (!config.contradictions.autoCheck) return "";
|
|
5130
|
+
const candidates = store.listStaleCandidates(projectId);
|
|
5131
|
+
if (candidates.length === 0) return "";
|
|
5132
|
+
const fresh = await checkContradictions(store, providers.embedder, providers.slm, projectId, candidates, {
|
|
5133
|
+
limit: candidates.length,
|
|
5134
|
+
maxJudgments: config.contradictions.maxPerSync,
|
|
5135
|
+
model: config.contradictions.model
|
|
5136
|
+
});
|
|
5137
|
+
const open = store.countContradictionSuggestions(projectId);
|
|
5138
|
+
if (fresh.length === 0 && open === 0) return "";
|
|
5139
|
+
const freshPart = fresh.length > 0 ? pc7.yellow(`${fresh.length} new`) : `${fresh.length} new`;
|
|
5140
|
+
return ` ${pc7.dim("contradictions:")} ${freshPart}${pc7.dim(`, ${open} open suggestion(s) -- run`)} ${pc7.bold("nexusmem stale")} ${pc7.dim("for detail")}
|
|
5141
|
+
`;
|
|
5142
|
+
}
|
|
4932
5143
|
async function runSync(opts) {
|
|
4933
5144
|
const { repo, ws, projectId, config } = await loadContext(opts.cwd);
|
|
4934
5145
|
const log = (line) => {
|
|
@@ -4982,6 +5193,7 @@ async function runSync(opts) {
|
|
|
4982
5193
|
const docs = await syncDocs(store, projectId, repo.root, config, log);
|
|
4983
5194
|
const structure = await syncStructure(store, projectId, repo.root, config, log);
|
|
4984
5195
|
let embedLine = "";
|
|
5196
|
+
let embeddingAvailable = false;
|
|
4985
5197
|
if (!opts.noEmbed) {
|
|
4986
5198
|
let lastLogged = 0;
|
|
4987
5199
|
const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId, {
|
|
@@ -4993,6 +5205,7 @@ async function runSync(opts) {
|
|
|
4993
5205
|
log(` ${pc7.dim(`vector: ${attempted}/${total} embedded`)}`);
|
|
4994
5206
|
}
|
|
4995
5207
|
});
|
|
5208
|
+
embeddingAvailable = !result.providerUnavailable;
|
|
4996
5209
|
if (result.embedded > 0) {
|
|
4997
5210
|
const skippedPart = result.skipped > 0 ? pc7.dim(`, ${result.skipped} skipped`) : "";
|
|
4998
5211
|
const remainingPart = result.remaining > 0 ? pc7.yellow(`, ${result.remaining} still pending`) : "";
|
|
@@ -5002,6 +5215,10 @@ async function runSync(opts) {
|
|
|
5002
5215
|
log(`${pc7.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
|
|
5003
5216
|
}
|
|
5004
5217
|
}
|
|
5218
|
+
const contradictionLine = !opts.noEmbed && embeddingAvailable ? await runAutoContradictionCheck(store, config, projectId, {
|
|
5219
|
+
embedder: new OllamaEmbeddingProvider(),
|
|
5220
|
+
slm: new OllamaChatProvider({ model: config.contradictions.model })
|
|
5221
|
+
}) : "";
|
|
5005
5222
|
let linkLine = "";
|
|
5006
5223
|
if (opts.linkFailures) {
|
|
5007
5224
|
const linkStats = correlateFailures(store, projectId);
|
|
@@ -5030,7 +5247,7 @@ async function runSync(opts) {
|
|
|
5030
5247
|
` ${pc7.green(`+${totals.inserted} new`)} ${pc7.yellow(`~${totals.updated} updated`)} ${pc7.dim(`=${totals.unchanged} unchanged`)}${deniedPart}`,
|
|
5031
5248
|
` ${pc7.dim(`${stats2.total} node(s) total across ${stats2.distinctFiles} file path(s)`)}`,
|
|
5032
5249
|
""
|
|
5033
|
-
].join("\n") + embedLine + linkLine
|
|
5250
|
+
].join("\n") + embedLine + linkLine + contradictionLine
|
|
5034
5251
|
);
|
|
5035
5252
|
return 0;
|
|
5036
5253
|
} finally {
|
|
@@ -5787,80 +6004,6 @@ ${pc17.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKE
|
|
|
5787
6004
|
|
|
5788
6005
|
// src/cli/commands/stale.ts
|
|
5789
6006
|
import pc18 from "picocolors";
|
|
5790
|
-
|
|
5791
|
-
// src/slm/contradiction.ts
|
|
5792
|
-
var MAX_BODY_CHARS = 1500;
|
|
5793
|
-
var MAX_REASON_CHARS = 200;
|
|
5794
|
-
var CONTRADICTION_INSTRUCTIONS = `You are checking whether a NEWER memory replaces or contradicts an OLDER one, for an AI coding assistant's memory index.
|
|
5795
|
-
|
|
5796
|
-
Answer in exactly this shape:
|
|
5797
|
-
VERDICT: YES or NO
|
|
5798
|
-
REASON: <one line, under 20 words>
|
|
5799
|
-
|
|
5800
|
-
Say YES only if the NEWER memory states something that makes the OLDER one factually wrong or obsolete -- a decision reversed, a bug fixed, a plan abandoned. Say NO if they are about different things, or the newer one only adds detail without contradicting the older one. When unsure, say NO.`;
|
|
5801
|
-
function buildContradictionPrompt(older, newer) {
|
|
5802
|
-
const body = [
|
|
5803
|
-
`OLDER (${older.title}):`,
|
|
5804
|
-
truncate(older.body, MAX_BODY_CHARS),
|
|
5805
|
-
"",
|
|
5806
|
-
`NEWER (${newer.title}):`,
|
|
5807
|
-
truncate(newer.body, MAX_BODY_CHARS)
|
|
5808
|
-
].join("\n");
|
|
5809
|
-
return `${CONTRADICTION_INSTRUCTIONS}
|
|
5810
|
-
|
|
5811
|
-
---
|
|
5812
|
-
|
|
5813
|
-
${body}
|
|
5814
|
-
|
|
5815
|
-
---
|
|
5816
|
-
|
|
5817
|
-
Answer:`;
|
|
5818
|
-
}
|
|
5819
|
-
function parseContradictionVerdict(raw) {
|
|
5820
|
-
const lines = raw.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
|
5821
|
-
const verdictLine = lines.find((l) => /^VERDICT:/i.test(l));
|
|
5822
|
-
if (!verdictLine) return null;
|
|
5823
|
-
const verdict = /^VERDICT:\s*(YES|NO)\b/i.exec(verdictLine);
|
|
5824
|
-
if (!verdict) return null;
|
|
5825
|
-
const reasonLine = lines.find((l) => /^REASON:/i.test(l));
|
|
5826
|
-
const reason = reasonLine ? reasonLine.replace(/^REASON:\s*/i, "").trim() : "";
|
|
5827
|
-
return {
|
|
5828
|
-
contradicts: verdict[1].toUpperCase() === "YES",
|
|
5829
|
-
reason: truncate(reason, MAX_REASON_CHARS)
|
|
5830
|
-
};
|
|
5831
|
-
}
|
|
5832
|
-
|
|
5833
|
-
// src/retrieval/contradiction.ts
|
|
5834
|
-
var DEFAULT_LIMIT = 10;
|
|
5835
|
-
var DEFAULT_NEIGHBOR_LIMIT = 25;
|
|
5836
|
-
async function checkContradictions(store, embeddingProvider, slmProvider, projectId, candidates, opts = {}) {
|
|
5837
|
-
const limit = opts.limit ?? DEFAULT_LIMIT;
|
|
5838
|
-
const neighborLimit = opts.neighborLimit ?? DEFAULT_NEIGHBOR_LIMIT;
|
|
5839
|
-
const suggestions = [];
|
|
5840
|
-
for (const candidate of candidates.slice(0, limit)) {
|
|
5841
|
-
const full = store.getNodesByIds([candidate.id])[0];
|
|
5842
|
-
if (!full) continue;
|
|
5843
|
-
const embedding = await embeddingProvider.embed(`${full.title}
|
|
5844
|
-
${full.body}`);
|
|
5845
|
-
if (!embedding) continue;
|
|
5846
|
-
const candidateEpoch = Date.parse(candidate.ts);
|
|
5847
|
-
const nearest = store.vectorSearch(projectId, embedding, neighborLimit + 1).find((hit) => hit.id !== candidate.id && Date.parse(hit.ts) > candidateEpoch);
|
|
5848
|
-
if (!nearest) continue;
|
|
5849
|
-
const reply = await slmProvider.complete(buildContradictionPrompt(full, nearest));
|
|
5850
|
-
if (!reply) continue;
|
|
5851
|
-
const verdict = parseContradictionVerdict(reply);
|
|
5852
|
-
if (!verdict?.contradicts) continue;
|
|
5853
|
-
suggestions.push({
|
|
5854
|
-
candidateId: candidate.id,
|
|
5855
|
-
againstId: nearest.id,
|
|
5856
|
-
againstTitle: nearest.title,
|
|
5857
|
-
reason: verdict.reason
|
|
5858
|
-
});
|
|
5859
|
-
}
|
|
5860
|
-
return suggestions;
|
|
5861
|
-
}
|
|
5862
|
-
|
|
5863
|
-
// src/cli/commands/stale.ts
|
|
5864
6007
|
var STALE_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
|
|
5865
6008
|
async function runStale(opts) {
|
|
5866
6009
|
const { projectId, ws } = await loadContext(opts.cwd);
|
|
@@ -5869,7 +6012,7 @@ async function runStale(opts) {
|
|
|
5869
6012
|
try {
|
|
5870
6013
|
const candidates = store.listStaleCandidates(projectId, { minAgeDays: opts.minAgeDays, limit: opts.limit });
|
|
5871
6014
|
if (candidates.length === 0) {
|
|
5872
|
-
out(`${pc18.dim("no stale candidates")} -- no
|
|
6015
|
+
out(`${pc18.dim("no stale candidates")} -- no unconfirmed node older than the threshold lacks a successor
|
|
5873
6016
|
`);
|
|
5874
6017
|
return 0;
|
|
5875
6018
|
}
|
|
@@ -5880,10 +6023,15 @@ async function runStale(opts) {
|
|
|
5880
6023
|
new OllamaEmbeddingProvider(),
|
|
5881
6024
|
new OllamaChatProvider({ model: opts.model ?? DEFAULT_SLM_MODEL }),
|
|
5882
6025
|
projectId,
|
|
5883
|
-
candidates
|
|
6026
|
+
candidates,
|
|
6027
|
+
{ model: opts.model ?? DEFAULT_SLM_MODEL }
|
|
5884
6028
|
);
|
|
5885
6029
|
}
|
|
5886
|
-
const byCandidateId = new Map(
|
|
6030
|
+
const byCandidateId = /* @__PURE__ */ new Map();
|
|
6031
|
+
for (const s of store.listContradictionSuggestions(projectId)) {
|
|
6032
|
+
byCandidateId.set(s.candidateId, { againstId: s.againstId, againstTitle: s.againstTitle, reason: s.reason ?? "" });
|
|
6033
|
+
}
|
|
6034
|
+
for (const s of suggestions) byCandidateId.set(s.candidateId, s);
|
|
5887
6035
|
out(
|
|
5888
6036
|
[
|
|
5889
6037
|
`${pc18.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
|
|
@@ -5963,6 +6111,7 @@ async function runStatus(opts) {
|
|
|
5963
6111
|
const otherProjectNodes = store.countProjectNodes(otherProjectIds);
|
|
5964
6112
|
const structure = store.fileEdgeStats(projectId);
|
|
5965
6113
|
const staleCount = store.countStaleCandidates(projectId);
|
|
6114
|
+
const flaggedCount = store.countContradictionSuggestions(projectId);
|
|
5966
6115
|
const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
|
|
5967
6116
|
const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
|
|
5968
6117
|
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(
|
|
@@ -5991,7 +6140,8 @@ async function runStatus(opts) {
|
|
|
5991
6140
|
gitCursor && gitCursor !== repo.head ? `${pc19.yellow("git behind HEAD")} \u2014 run ${pc19.bold("nexusmem sync")}` : "",
|
|
5992
6141
|
chains.failuresTotal ? `${pc19.dim("chains ")} ${pc19.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc19.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc19.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
|
|
5993
6142
|
structure.edges ? `${pc19.dim("structure")} ${pc19.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
|
|
5994
|
-
staleCount ? `${pc19.dim("aging ")} ${pc19.bold(String(staleCount))}
|
|
6143
|
+
staleCount ? `${pc19.dim("aging ")} ${pc19.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc19.bold("nexusmem stale")}` : "",
|
|
6144
|
+
flaggedCount ? `${pc19.dim("flagged ")} ${pc19.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc19.bold("nexusmem stale")} for detail` : ""
|
|
5995
6145
|
].filter((line) => line !== "").join("\n").concat("\n")
|
|
5996
6146
|
);
|
|
5997
6147
|
return 0;
|
|
@@ -6113,7 +6263,7 @@ program.command("mark-stale").description(
|
|
|
6113
6263
|
).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(
|
|
6114
6264
|
(nodeId, options) => guard(() => runMarkStale({ cwd: options.cwd, nodeId, supersedesId: options.supersedes }))()
|
|
6115
6265
|
);
|
|
6116
|
-
program.command("stale").description("List
|
|
6266
|
+
program.command("stale").description("List unconfirmed (non-observed) nodes old enough to be worth double-checking (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--min-age-days <days>", "only nodes at least this old", (v) => Number.parseFloat(v)).option("-n, --limit <count>", "stop after N candidates", (v) => Number.parseInt(v, 10)).option(
|
|
6117
6267
|
"--check-contradictions",
|
|
6118
6268
|
"ask the local SLM whether a similar newer node actually contradicts each candidate (needs Ollama)"
|
|
6119
6269
|
).option("--model <name>", "Ollama chat model for --check-contradictions", STALE_DEFAULT_MODEL).action(
|