nexusmem 0.10.0 → 0.10.2
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 +37 -1
- package/README.md +1 -1
- package/dist/cli/index.js +121 -7
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,41 @@ 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.10.2] — 2026-08-30
|
|
15
|
+
|
|
16
|
+
### Security
|
|
17
|
+
|
|
18
|
+
- `shell_command` nodes never ran through the same secret-redaction pass `conversation_turn` and
|
|
19
|
+
`code_diff` nodes already get: a command like `export API_KEY=...` landed verbatim in `title`/`body`,
|
|
20
|
+
which is exactly what the FTS index and vector embeddings are built from — a secret typed at a
|
|
21
|
+
prompt could resurface later through `search_memory`/`nexusmem query`. Fixed by running `redact()`
|
|
22
|
+
over the command before it reaches those fields. `meta.command` is kept raw on purpose: project-id
|
|
23
|
+
reconciliation and failure/fix correlation both hash or exact-match against the real command text,
|
|
24
|
+
and redacting that copy too would have silently orphaned nodes on a project-id migration.
|
|
25
|
+
|
|
26
|
+
## [0.10.1] — 2026-08-30
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- `nodes.retrieved_count`/`nodes.last_retrieved_at` (schema V12): retrieval outcomes are now recorded
|
|
31
|
+
instead of silently discarded. Bumped once per completed `nexusmem query`/MCP `search_memory` call,
|
|
32
|
+
for every node actually packed into the returned context (both CLI and MCP share one pipeline, so
|
|
33
|
+
both are covered from a single call site). Not yet folded into `rank.ts`'s score formula — every
|
|
34
|
+
existing ranking factor was tuned against real dogfooded queries and validated with `npm run eval`
|
|
35
|
+
before being trusted, and a new factor needs the same treatment. This release is the instrumentation
|
|
36
|
+
half only; confirmed eval-neutral (MRR 0.943 / Recall@5 0.964, unchanged).
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- `nexusmem precheck`'s "What already failed here" warning implied the failure was located in the
|
|
41
|
+
flagged file, but the match is against basename word-tokens (`tokensForFile`) — a failing `npm run
|
|
42
|
+
precheck` flagged every file whose name contained that word, not just the one actually responsible.
|
|
43
|
+
Reworded to state what's actually true ("commands naming this file failed").
|
|
44
|
+
- The high-churn warning in `nexusmem precheck` was rendered as a `WARN`, the same severity as the
|
|
45
|
+
dogfooded failure-correlation warning, despite `HIGH_CHURN_THRESHOLD` being an admitted, untuned
|
|
46
|
+
guess. Demoted to a lower-severity `note`, explicitly labeled as an untuned heuristic, so it no
|
|
47
|
+
longer reads as equally trustworthy.
|
|
48
|
+
|
|
14
49
|
## [0.10.0] — 2026-08-29
|
|
15
50
|
|
|
16
51
|
### Added
|
|
@@ -575,7 +610,8 @@ First public release.
|
|
|
575
610
|
there is no local-model summarization pass, and the conversation collector has never been audited
|
|
576
611
|
for the stale-node bug that was found and fixed in the docs collector.
|
|
577
612
|
|
|
578
|
-
[Unreleased]: https://github.com/yaminbkk/NexusMem/compare/v0.10.
|
|
613
|
+
[Unreleased]: https://github.com/yaminbkk/NexusMem/compare/v0.10.1...HEAD
|
|
614
|
+
[0.10.1]: https://github.com/yaminbkk/NexusMem/compare/v0.10.0...v0.10.1
|
|
579
615
|
[0.10.0]: https://github.com/yaminbkk/NexusMem/compare/v0.9.1...v0.10.0
|
|
580
616
|
[0.9.1]: https://github.com/yaminbkk/NexusMem/compare/v0.9.0...v0.9.1
|
|
581
617
|
[0.9.0]: https://github.com/yaminbkk/NexusMem/compare/v0.8.0...v0.9.0
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](LICENSE)
|
|
7
7
|

|
|
8
8
|
|
|
9
|
-

|
|
10
10
|
|
|
11
11
|
Your coding agent can read `git log`. It cannot read the four things you tried last Tuesday that
|
|
12
12
|
didn't work.
|
package/dist/cli/index.js
CHANGED
|
@@ -1033,6 +1033,10 @@ INSERT INTO nodes_vec (rowid, project_id, embedding)
|
|
|
1033
1033
|
|
|
1034
1034
|
DROP TABLE nodes_vec_stage;
|
|
1035
1035
|
`;
|
|
1036
|
+
var V12 = `
|
|
1037
|
+
ALTER TABLE nodes ADD COLUMN retrieved_count INTEGER NOT NULL DEFAULT 0;
|
|
1038
|
+
ALTER TABLE nodes ADD COLUMN last_retrieved_at INTEGER;
|
|
1039
|
+
`;
|
|
1036
1040
|
var MIGRATIONS = [
|
|
1037
1041
|
{ version: 1, up: (db) => db.exec(V1) },
|
|
1038
1042
|
{ version: 2, up: (db) => db.exec(V2) },
|
|
@@ -1044,7 +1048,8 @@ var MIGRATIONS = [
|
|
|
1044
1048
|
{ version: 8, up: (db) => db.exec(V8) },
|
|
1045
1049
|
{ version: 9, up: (db) => db.exec(V9) },
|
|
1046
1050
|
{ version: 10, up: (db) => db.exec(V10) },
|
|
1047
|
-
{ version: 11, up: (db) => db.exec(V11) }
|
|
1051
|
+
{ version: 11, up: (db) => db.exec(V11) },
|
|
1052
|
+
{ version: 12, up: (db) => db.exec(V12) }
|
|
1048
1053
|
];
|
|
1049
1054
|
var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
|
|
1050
1055
|
function currentSchemaVersion(db) {
|
|
@@ -1316,6 +1321,19 @@ function listStaleCandidates(db, projectId, opts = {}) {
|
|
|
1316
1321
|
ageDays: Math.round((now.getTime() - r.tsEpoch) / 864e5)
|
|
1317
1322
|
}));
|
|
1318
1323
|
}
|
|
1324
|
+
function recordRetrievals(db, ids) {
|
|
1325
|
+
if (ids.length === 0) return;
|
|
1326
|
+
db.prepare(
|
|
1327
|
+
`UPDATE nodes SET retrieved_count = retrieved_count + 1, last_retrieved_at = @now
|
|
1328
|
+
WHERE id IN (SELECT value FROM json_each(@ids))`
|
|
1329
|
+
).run({ now: Date.now(), ids: JSON.stringify(ids) });
|
|
1330
|
+
}
|
|
1331
|
+
function getRetrievalStats(db, id) {
|
|
1332
|
+
const row = db.prepare("SELECT retrieved_count AS retrievedCount, last_retrieved_at AS lastRetrievedAt FROM nodes WHERE id = ?").get(
|
|
1333
|
+
id
|
|
1334
|
+
);
|
|
1335
|
+
return row ?? null;
|
|
1336
|
+
}
|
|
1319
1337
|
function countStaleCandidates(db, projectId, opts = {}) {
|
|
1320
1338
|
const now = opts.now ?? /* @__PURE__ */ new Date();
|
|
1321
1339
|
const minAgeDays = opts.minAgeDays ?? 45;
|
|
@@ -1845,6 +1863,14 @@ var MemoryStore = class _MemoryStore {
|
|
|
1845
1863
|
countStaleCandidates(projectId, opts = {}) {
|
|
1846
1864
|
return countStaleCandidates(this.db, projectId, opts);
|
|
1847
1865
|
}
|
|
1866
|
+
/** Bump retrieval bookkeeping for nodes actually packed into a query result. See `nodes.ts`'s `recordRetrievals` for why this doesn't touch ranking. */
|
|
1867
|
+
recordRetrievals(ids) {
|
|
1868
|
+
recordRetrievals(this.db, ids);
|
|
1869
|
+
}
|
|
1870
|
+
/** Retrieval bookkeeping for one node, or null if it doesn't exist. Mainly for tests. */
|
|
1871
|
+
getRetrievalStats(id) {
|
|
1872
|
+
return getRetrievalStats(this.db, id);
|
|
1873
|
+
}
|
|
1848
1874
|
/** Memoize one SLM contradiction judgment (either verdict). Suggest-only: never writes `supersedes`. */
|
|
1849
1875
|
recordContradictionCheck(input) {
|
|
1850
1876
|
recordContradictionCheck(this.db, input);
|
|
@@ -2812,8 +2838,21 @@ async function runCrossProjectQuery(sources, query, opts) {
|
|
|
2812
2838
|
rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores, supersededIds })
|
|
2813
2839
|
);
|
|
2814
2840
|
const packed = packContext(ranked, opts.budget, { query });
|
|
2841
|
+
recordRetrievalsByProject(storeByLabel, packed.nodes);
|
|
2815
2842
|
return { bm25Count, vectorCount, hits, packed, perProject };
|
|
2816
2843
|
}
|
|
2844
|
+
function recordRetrievalsByProject(storeByLabel, nodes) {
|
|
2845
|
+
const idsByLabel = /* @__PURE__ */ new Map();
|
|
2846
|
+
for (const node of nodes) {
|
|
2847
|
+
if (!node.project) continue;
|
|
2848
|
+
const ids = idsByLabel.get(node.project) ?? [];
|
|
2849
|
+
ids.push(node.id);
|
|
2850
|
+
idsByLabel.set(node.project, ids);
|
|
2851
|
+
}
|
|
2852
|
+
for (const [label, ids] of idsByLabel) {
|
|
2853
|
+
storeByLabel.get(label)?.recordRetrievals(ids);
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2817
2856
|
async function runHybridQuery(store, projectId, query, opts) {
|
|
2818
2857
|
const bm25Hits = store.search(projectId, query, opts.candidates, { asOfEpoch: opts.asOfEpoch });
|
|
2819
2858
|
let vectorHits = [];
|
|
@@ -2829,6 +2868,7 @@ async function runHybridQuery(store, projectId, query, opts) {
|
|
|
2829
2868
|
rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores, supersededIds })
|
|
2830
2869
|
);
|
|
2831
2870
|
const packed = packContext(ranked, opts.budget, { query });
|
|
2871
|
+
store.recordRetrievals(packed.nodes.map((n) => n.id));
|
|
2832
2872
|
return { bm25Count: bm25Hits.length, vectorCount: vectorHits.length, hits, packed };
|
|
2833
2873
|
}
|
|
2834
2874
|
|
|
@@ -3949,8 +3989,8 @@ function scoreShellCommand(entry) {
|
|
|
3949
3989
|
else if (cmd.length <= 3) score -= 0.05;
|
|
3950
3990
|
return Number(Math.min(1, Math.max(0.05, score)).toFixed(3));
|
|
3951
3991
|
}
|
|
3952
|
-
function renderBody2(entry, maxChars) {
|
|
3953
|
-
const parts = [`$ ${
|
|
3992
|
+
function renderBody2(entry, command, maxChars) {
|
|
3993
|
+
const parts = [`$ ${command}`];
|
|
3954
3994
|
const metaLine = [];
|
|
3955
3995
|
if (entry.cwd) metaLine.push(`cwd: ${entry.cwd}`);
|
|
3956
3996
|
if (entry.exitCode !== null) metaLine.push(`exit: ${entry.exitCode}`);
|
|
@@ -3960,7 +4000,8 @@ function renderBody2(entry, maxChars) {
|
|
|
3960
4000
|
}
|
|
3961
4001
|
function toMemoryNode3(entry, projectId, opts = {}) {
|
|
3962
4002
|
const maxBody = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS5;
|
|
3963
|
-
const
|
|
4003
|
+
const { text: redactedCommand } = redact(entry.command);
|
|
4004
|
+
const titleLine = redactedCommand.split(/\r?\n/)[0] ?? redactedCommand;
|
|
3964
4005
|
return {
|
|
3965
4006
|
id: makeNodeId(projectId, "shell_command", entry.naturalKey),
|
|
3966
4007
|
kind: "shell_command",
|
|
@@ -3968,7 +4009,7 @@ function toMemoryNode3(entry, projectId, opts = {}) {
|
|
|
3968
4009
|
ts: entry.ts,
|
|
3969
4010
|
source: `shell:${entry.shell}`,
|
|
3970
4011
|
title: truncate(titleLine, MAX_TITLE_CHARS7),
|
|
3971
|
-
body: renderBody2(entry, maxBody),
|
|
4012
|
+
body: renderBody2(entry, redactedCommand, maxBody),
|
|
3972
4013
|
files: [],
|
|
3973
4014
|
signal: scoreShellCommand(entry),
|
|
3974
4015
|
provenance: "observed",
|
|
@@ -5695,6 +5736,44 @@ async function getStatus(input) {
|
|
|
5695
5736
|
store.close();
|
|
5696
5737
|
}
|
|
5697
5738
|
}
|
|
5739
|
+
async function listStaleSuggestions(input) {
|
|
5740
|
+
const repo = await readRepoInfo(input.projectRoot);
|
|
5741
|
+
const ws = resolveWorkspace(repo.root);
|
|
5742
|
+
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
5743
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
5744
|
+
try {
|
|
5745
|
+
return { suggestions: store.listContradictionSuggestions(projectId, { limit: input.limit }) };
|
|
5746
|
+
} finally {
|
|
5747
|
+
store.close();
|
|
5748
|
+
}
|
|
5749
|
+
}
|
|
5750
|
+
async function resolveStaleSuggestion(input) {
|
|
5751
|
+
if (input.action === "dismiss") {
|
|
5752
|
+
const repo = await readRepoInfo(input.projectRoot);
|
|
5753
|
+
const ws = resolveWorkspace(repo.root);
|
|
5754
|
+
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
5755
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
5756
|
+
try {
|
|
5757
|
+
const dismissed = store.dismissContradictionSuggestion(projectId, input.candidateId);
|
|
5758
|
+
return {
|
|
5759
|
+
summary: dismissed > 0 ? `dismissed the contradiction suggestion for ${input.candidateId}` : `no open contradiction suggestion for ${input.candidateId}`
|
|
5760
|
+
};
|
|
5761
|
+
} finally {
|
|
5762
|
+
store.close();
|
|
5763
|
+
}
|
|
5764
|
+
}
|
|
5765
|
+
if (!input.againstId) {
|
|
5766
|
+
throw new Error("againstId is required to accept a stale suggestion");
|
|
5767
|
+
}
|
|
5768
|
+
const chunks = [];
|
|
5769
|
+
await runMarkStale({
|
|
5770
|
+
cwd: input.projectRoot,
|
|
5771
|
+
nodeId: input.candidateId,
|
|
5772
|
+
supersedesId: input.againstId,
|
|
5773
|
+
out: (chunk2) => chunks.push(chunk2)
|
|
5774
|
+
});
|
|
5775
|
+
return { summary: stripAnsi(chunks.join("").trim()) };
|
|
5776
|
+
}
|
|
5698
5777
|
|
|
5699
5778
|
// src/mcp/server.ts
|
|
5700
5779
|
function createServer() {
|
|
@@ -5784,6 +5863,41 @@ function createServer() {
|
|
|
5784
5863
|
};
|
|
5785
5864
|
}
|
|
5786
5865
|
);
|
|
5866
|
+
server.registerTool(
|
|
5867
|
+
"list_stale_suggestions",
|
|
5868
|
+
{
|
|
5869
|
+
title: "List open contradiction suggestions",
|
|
5870
|
+
description: 'List open contradiction verdicts for a NexusMem-tracked repository -- candidates flagged by "nexusmem stale --check-contradictions" (or automatically during sync) as likely superseded by a newer, similar node. Nothing has been written yet; use resolve_stale_suggestion to act on one.',
|
|
5871
|
+
inputSchema: {
|
|
5872
|
+
projectRoot: z4.string().describe("Absolute path to the repository root"),
|
|
5873
|
+
limit: z4.number().int().positive().optional().describe("Max suggestions to return, most recently judged first. Default 50.")
|
|
5874
|
+
}
|
|
5875
|
+
},
|
|
5876
|
+
async ({ projectRoot, limit }) => {
|
|
5877
|
+
const result = await listStaleSuggestions({ projectRoot, limit });
|
|
5878
|
+
return {
|
|
5879
|
+
content: [{ type: "text", text: JSON.stringify(result.suggestions, null, 2) }],
|
|
5880
|
+
structuredContent: { suggestions: result.suggestions }
|
|
5881
|
+
};
|
|
5882
|
+
}
|
|
5883
|
+
);
|
|
5884
|
+
server.registerTool(
|
|
5885
|
+
"resolve_stale_suggestion",
|
|
5886
|
+
{
|
|
5887
|
+
title: "Accept or dismiss a contradiction suggestion",
|
|
5888
|
+
description: '"accept" writes a supersede link from candidateId to againstId (the same effect as "nexusmem mark-stale") -- the ranker down-weights candidateId from then on but never deletes it. "dismiss" silences the suggestion without changing ranking, so it stops resurfacing on future stale/sync runs.',
|
|
5889
|
+
inputSchema: {
|
|
5890
|
+
projectRoot: z4.string().describe("Absolute path to the repository root"),
|
|
5891
|
+
candidateId: z4.string().describe("Id of the stale/superseded node"),
|
|
5892
|
+
action: z4.enum(["accept", "dismiss"]).describe("Whether to write the supersede link or silence the suggestion"),
|
|
5893
|
+
againstId: z4.string().optional().describe('Id of the superseding node. Required for "accept", ignored for "dismiss".')
|
|
5894
|
+
}
|
|
5895
|
+
},
|
|
5896
|
+
async ({ projectRoot, candidateId, action, againstId }) => {
|
|
5897
|
+
const result = await resolveStaleSuggestion({ projectRoot, candidateId, action, againstId });
|
|
5898
|
+
return { content: [{ type: "text", text: result.summary }] };
|
|
5899
|
+
}
|
|
5900
|
+
);
|
|
5787
5901
|
return server;
|
|
5788
5902
|
}
|
|
5789
5903
|
async function runMcpServer() {
|
|
@@ -5876,7 +5990,7 @@ ${pc8.dim("-".repeat(40))}
|
|
|
5876
5990
|
out(` ${pc8.bold(risk.path)}
|
|
5877
5991
|
`);
|
|
5878
5992
|
if (risk.unresolvedFailures.length > 0) {
|
|
5879
|
-
out(` ${pc8.yellow("WARN")}
|
|
5993
|
+
out(` ${pc8.yellow("WARN")} commands naming this file failed, still unresolved (${risk.unresolvedFailures.length}):
|
|
5880
5994
|
`);
|
|
5881
5995
|
for (const f of risk.unresolvedFailures.slice(0, 3)) {
|
|
5882
5996
|
out(` ${pc8.dim(`${f.ts.slice(0, 10)} ${f.command.slice(0, 90)}`)}
|
|
@@ -5888,7 +6002,7 @@ ${pc8.dim("-".repeat(40))}
|
|
|
5888
6002
|
}
|
|
5889
6003
|
}
|
|
5890
6004
|
if (risk.commitsRecent >= HIGH_CHURN_THRESHOLD) {
|
|
5891
|
-
out(` ${pc8.
|
|
6005
|
+
out(` ${pc8.dim("note")} high churn (untuned heuristic): ${risk.commitsRecent} commits touched this file recently
|
|
5892
6006
|
`);
|
|
5893
6007
|
}
|
|
5894
6008
|
out("\n");
|