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 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.0...HEAD
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: MIT](https://img.shields.io/badge/license-MIT-informational)](LICENSE)
7
7
  ![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen)
8
8
 
9
- ![NexusMem: init, sync, status, and a query against this repo's own history](docs/demo.gif)
9
+ ![NexusMem: init, sync --github, and a query against this repo's own history — surfacing a real issue, the PR that closed it, and the commits it shipped](docs/demo.gif)
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 = [`$ ${entry.command}`];
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 titleLine = entry.command.split(/\r?\n/)[0] ?? entry.command;
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")} What already failed here (${risk.unresolvedFailures.length} unresolved):
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.yellow("WARN")} high churn: ${risk.commitsRecent} commits touched this file recently
6005
+ out(` ${pc8.dim("note")} high churn (untuned heuristic): ${risk.commitsRecent} commits touched this file recently
5892
6006
  `);
5893
6007
  }
5894
6008
  out("\n");