nexusmem 0.10.1 → 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,18 @@ 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
+
14
26
  ## [0.10.1] — 2026-08-30
15
27
 
16
28
  ### Added
package/dist/cli/index.js CHANGED
@@ -3989,8 +3989,8 @@ function scoreShellCommand(entry) {
3989
3989
  else if (cmd.length <= 3) score -= 0.05;
3990
3990
  return Number(Math.min(1, Math.max(0.05, score)).toFixed(3));
3991
3991
  }
3992
- function renderBody2(entry, maxChars) {
3993
- const parts = [`$ ${entry.command}`];
3992
+ function renderBody2(entry, command, maxChars) {
3993
+ const parts = [`$ ${command}`];
3994
3994
  const metaLine = [];
3995
3995
  if (entry.cwd) metaLine.push(`cwd: ${entry.cwd}`);
3996
3996
  if (entry.exitCode !== null) metaLine.push(`exit: ${entry.exitCode}`);
@@ -4000,7 +4000,8 @@ function renderBody2(entry, maxChars) {
4000
4000
  }
4001
4001
  function toMemoryNode3(entry, projectId, opts = {}) {
4002
4002
  const maxBody = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS5;
4003
- 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;
4004
4005
  return {
4005
4006
  id: makeNodeId(projectId, "shell_command", entry.naturalKey),
4006
4007
  kind: "shell_command",
@@ -4008,7 +4009,7 @@ function toMemoryNode3(entry, projectId, opts = {}) {
4008
4009
  ts: entry.ts,
4009
4010
  source: `shell:${entry.shell}`,
4010
4011
  title: truncate(titleLine, MAX_TITLE_CHARS7),
4011
- body: renderBody2(entry, maxBody),
4012
+ body: renderBody2(entry, redactedCommand, maxBody),
4012
4013
  files: [],
4013
4014
  signal: scoreShellCommand(entry),
4014
4015
  provenance: "observed",
@@ -5735,6 +5736,44 @@ async function getStatus(input) {
5735
5736
  store.close();
5736
5737
  }
5737
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
+ }
5738
5777
 
5739
5778
  // src/mcp/server.ts
5740
5779
  function createServer() {
@@ -5824,6 +5863,41 @@ function createServer() {
5824
5863
  };
5825
5864
  }
5826
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
+ );
5827
5901
  return server;
5828
5902
  }
5829
5903
  async function runMcpServer() {