nexusmem 0.3.1 → 0.3.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
@@ -9,6 +9,46 @@ built from, matched by publish timestamp: `v0.1.0` → `67a4776`, `v0.1.1` → `
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.3.2] — 2026-08-16
13
+
14
+ ### Added
15
+
16
+ - **`mcpName` field in `package.json`**, required by the official MCP registry
17
+ (registry.modelcontextprotocol.io) to verify that whoever publishes `server.json` under
18
+ `io.github.yaminbkk/nexusmem` also controls the `nexusmem` npm package itself — the registry
19
+ rejects a publish attempt otherwise. No behavior change for CLI/MCP users; this is purely a
20
+ registry-ownership proof.
21
+ - **`list_recent_memory` MCP tool** — chronological listing of a repository's most recently
22
+ remembered nodes (git commits, diffs, shell commands, docs, conversation, session summaries),
23
+ newest first. Distinct from `search_memory`: no query, just "what has this project's memory
24
+ recorded lately" — built for the VS Code extension's sidebar view, which lists rather than
25
+ searches. Backed by `MemoryStore.listRecentNodes`, reusing the existing `idx_nodes_project_ts`
26
+ index.
27
+ - **`sync --prune-source <name>` and `sync --prune-stale-shell`** — drop one source's nodes without a
28
+ full `--rebuild`, which loses history that can't be re-read from disk (the shell tail window, older
29
+ conversation turns). `--prune-stale-shell` is a shortcut for the three dead pre-hook shell-scrape
30
+ sources (`shell:pwsh`, `shell:bash`, `shell:zsh`) at once. Dry-run by default — prints the matching
31
+ count and does nothing until `--yes` is also given, since this is an irreversible full wipe of the
32
+ named source(s), unlike `--rebuild`'s no-prompt full-project reset. Also sweeps any prior project
33
+ identity of this same repo (the id a renamed git remote leaves behind after
34
+ `fix(store): reconcile memory stranded by a changed git remote URL` migrates what it can) — a
35
+ live-id-only prune could not reach nodes reconciliation deliberately left in place. Exposed on both
36
+ the CLI and the MCP `sync_project` tool.
37
+ - **Discussion-heuristic failure→fix chains now surface**, tightened to an AND-joined significant-
38
+ token match instead of the original OR match. Re-verified against this repo's own real database:
39
+ 5/5 discussion links correct (was ~half wrong when it shipped unsurfaced in 0.3.0). Chains now
40
+ follow across projects in `query --all-projects` too.
41
+
42
+ ### Fixed
43
+
44
+ - **`sync_project`'s summary no longer contains raw ANSI color codes on Windows.** `runInit`/`runSync`
45
+ format their output with picocolors for terminal display, and picocolors treats `platform ===
46
+ 'win32'` as sufficient evidence of color support on its own, without checking `isTTY` — correct for
47
+ a real terminal, wrong for the MCP JSON-RPC channel, which is piped on every platform. Found live: a
48
+ real MCP client (the VS Code extension's Output channel) rendered the raw escape codes as literal
49
+ text instead of color. Stripped at the MCP boundary in `syncProject`, leaving the CLI's own terminal
50
+ output untouched.
51
+
12
52
  ## [0.3.1] — 2026-08-15
13
53
 
14
54
  ### Added
@@ -210,7 +250,9 @@ First public release.
210
250
  there is no local-model summarization pass, and the conversation collector has never been audited
211
251
  for the stale-node bug that was found and fixed in the docs collector.
212
252
 
213
- [Unreleased]: https://github.com/yaminbkk/NexusMem/compare/v0.3.0...HEAD
253
+ [Unreleased]: https://github.com/yaminbkk/NexusMem/compare/v0.3.2...HEAD
254
+ [0.3.2]: https://github.com/yaminbkk/NexusMem/compare/v0.3.1...v0.3.2
255
+ [0.3.1]: https://github.com/yaminbkk/NexusMem/compare/v0.3.0...v0.3.1
214
256
  [0.3.0]: https://github.com/yaminbkk/NexusMem/compare/v0.2.0...v0.3.0
215
257
  [0.2.0]: https://github.com/yaminbkk/NexusMem/compare/v0.1.2...v0.2.0
216
258
  [0.1.2]: https://github.com/yaminbkk/NexusMem/compare/v0.1.1...v0.1.2
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 NexusMem Contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NexusMem Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -87,6 +87,40 @@ It wraps your existing PowerShell prompt rather than replacing it, is idempotent
87
87
  Exit codes are what make this worth installing. A failed command is a stronger signal than a
88
88
  successful one, and without the hook there is no way to tell them apart.
89
89
 
90
+ ## Failure → fix chains (opt-in)
91
+
92
+ ```bash
93
+ nexusmem sync --link-failures
94
+ ```
95
+
96
+ After a normal sync, this walks every failed `shell_command` (non-zero exit code) and looks for
97
+ whatever later resolved it, using two independent heuristics: a later command in the same project
98
+ and working directory, exact same normalized text, that exited `0` within 24h (**same-command
99
+ retry**); and, separately, the best full-text match among nearby conversation turns or session
100
+ summaries, requiring every significant word of the failing command to appear, not just one
101
+ (**conversation bridge**). A failure can be linked by either, both, or neither.
102
+
103
+ Both links are surfaced in query results. The conversation-bridge heuristic originally matched on
104
+ any shared word, and dogfooding against this repo's own real history found it wrong on roughly half
105
+ its links — a shared word as generic as "npm" was enough to link an unrelated discussion. Requiring
106
+ every significant word fixed that: re-dogfooded against the same corpus, every resulting link (the
107
+ full set produced, not a sample) checked out correct on manual review of the full text, not just the
108
+ summary.
109
+
110
+ When a linked failure appears in a result set, its fix rides along immediately after it, inheriting
111
+ the failure's own relevance score rather than needing to match the query on its own merits. That is
112
+ the point: a query about why something failed shouldn't need to separately guess the words used in
113
+ whatever fixed it. This works across projects too — `query --all-projects` chains a failure to its
114
+ fix using whichever project's own database recorded the link, since links are always local to the
115
+ project they were found in.
116
+
117
+ ```
118
+ $ nexusmem query "why did npm whoami fail"
119
+
120
+ - 2026-08-12 shell: npm whoami (exit 1)
121
+ - 2026-08-12 shell: npm login (exit 0) -- linked as the fix
122
+ ```
123
+
90
124
  ## How retrieval works
91
125
 
92
126
  Every source normalizes to the same `MemoryNode` shape, so a commit, a shell command and a docs
@@ -293,8 +327,9 @@ is the intended way to tune scoring against a real repository before committing
293
327
  `--json` to pipe them somewhere.
294
328
 
295
329
  Every command takes `-C <path>` to target another repository. On `sync`, `--conversation` opts the
296
- transcript source in for one run without persisting it, `--no-embed` skips the vector pass, and
297
- `--rebuild` drops the project's nodes and re-ingests from scratch.
330
+ transcript source in for one run without persisting it, `--no-embed` skips the vector pass,
331
+ `--link-failures` builds the failure fix chains described above, and `--rebuild` drops the
332
+ project's nodes and re-ingests from scratch.
298
333
 
299
334
  ## Recall across projects
300
335
 
package/dist/cli/index.js CHANGED
@@ -699,13 +699,22 @@ import * as sqliteVec from "sqlite-vec";
699
699
  // src/store/fts.ts
700
700
  var FTS_SYNTAX = /["'()*:^{}[\]-]/g;
701
701
  var LOW_SIGNAL_TOKENS = /* @__PURE__ */ new Set(["id"]);
702
- function toMatchQuery(input) {
702
+ function significantTokens(input) {
703
703
  const tokens = input.replace(FTS_SYNTAX, " ").split(/\s+/).map((t) => t.trim()).filter((t) => t.length > 0);
704
- if (tokens.length === 0) return null;
704
+ if (tokens.length === 0) return [];
705
705
  const signal = tokens.filter((t) => !LOW_SIGNAL_TOKENS.has(t.toLowerCase()));
706
- const kept = signal.length > 0 ? signal : tokens;
706
+ return signal.length > 0 ? signal : tokens;
707
+ }
708
+ function toMatchQuery(input) {
709
+ const kept = significantTokens(input);
710
+ if (kept.length === 0) return null;
707
711
  return kept.map((t) => `"${t}"*`).join(" OR ");
708
712
  }
713
+ function toStrictMatchQuery(input) {
714
+ const kept = significantTokens(input);
715
+ if (kept.length === 0) return null;
716
+ return kept.map((t) => `"${t}"*`).join(" AND ");
717
+ }
709
718
 
710
719
  // src/store/schema.ts
711
720
  var V1 = `
@@ -798,9 +807,28 @@ CREATE VIRTUAL TABLE nodes_vec USING vec0 (
798
807
  embedding float[${EMBEDDING_DIM}]
799
808
  );
800
809
  `;
810
+ var V3 = `
811
+ -- A relation between two existing nodes, not a new content node -- the
812
+ -- "failure -> fix" correlation is the relationship itself, and duplicating
813
+ -- either side's content into a third node would just be another
814
+ -- independently-ranked candidate instead of the link the feature needs.
815
+ -- One physical table, multiple relation kinds; 'resolved_by' is the first.
816
+ CREATE TABLE node_links (
817
+ from_node_id TEXT NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
818
+ to_node_id TEXT NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
819
+ relation TEXT NOT NULL,
820
+ created_at INTEGER NOT NULL,
821
+ PRIMARY KEY (from_node_id, to_node_id, relation)
822
+ );
823
+
824
+ -- Packing a failure node needs its resolutions; nothing needs the reverse
825
+ -- direction yet, so only the forward lookup gets an index.
826
+ CREATE INDEX idx_node_links_from ON node_links (from_node_id);
827
+ `;
801
828
  var MIGRATIONS = [
802
829
  { version: 1, up: (db) => db.exec(V1) },
803
- { version: 2, up: (db) => db.exec(V2) }
830
+ { version: 2, up: (db) => db.exec(V2) },
831
+ { version: 3, up: (db) => db.exec(V3) }
804
832
  ];
805
833
  var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
806
834
  function currentSchemaVersion(db) {
@@ -972,6 +1000,62 @@ var MemoryStore = class _MemoryStore {
972
1000
  this.db.prepare("DELETE FROM sync_state WHERE project_id = ?").run(projectId);
973
1001
  return info.changes;
974
1002
  }
1003
+ /**
1004
+ * Record a directed relationship between two existing nodes -- e.g. a
1005
+ * failed `shell_command` and whatever node later resolved it
1006
+ * (`relation = 'resolved_by'`). A relation, not a new content node: the
1007
+ * correlation *is* the relationship, and duplicating either side's content
1008
+ * into a third node would just be another independently-ranked candidate.
1009
+ *
1010
+ * Idempotent by design (`INSERT OR IGNORE` against the table's own primary
1011
+ * key) so re-running a correlation pass over already-linked nodes is a
1012
+ * no-op, not a duplicate-row error.
1013
+ */
1014
+ linkNodes(fromNodeId, toNodeId, relation) {
1015
+ this.db.prepare("INSERT OR IGNORE INTO node_links (from_node_id, to_node_id, relation, created_at) VALUES (?, ?, ?, ?)").run(fromNodeId, toNodeId, relation, Date.now());
1016
+ }
1017
+ /** Ids linked from `fromNodeId` under one relation, most recently linked first. Empty if none exist. */
1018
+ getLinkedNodeIds(fromNodeId, relation) {
1019
+ return this.db.prepare("SELECT to_node_id FROM node_links WHERE from_node_id = ? AND relation = ? ORDER BY created_at DESC").all(fromNodeId, relation).map((row) => row.to_node_id);
1020
+ }
1021
+ /**
1022
+ * Hydrate full content for a set of node ids, e.g. to pack a linked
1023
+ * resolution alongside the failure node that points at it. Order is not
1024
+ * guaranteed to match `ids`; ids with no matching row are silently omitted
1025
+ * rather than erroring. `node_links` has `ON DELETE CASCADE` on both
1026
+ * columns, so an individual node delete (e.g. `reconcile.ts` migrating a
1027
+ * node to a freshly-computed id) removes any link pointing at the old id
1028
+ * along with it -- correct as a safety default, though note that reconcile
1029
+ * does not currently re-create the link under the migrated node's new id;
1030
+ * that gap is not addressed here.
1031
+ */
1032
+ getNodesByIds(ids) {
1033
+ if (ids.length === 0) return [];
1034
+ return this.db.prepare(
1035
+ `SELECT id, kind, project_id AS projectId, ts, title, body, signal
1036
+ FROM nodes WHERE id IN (SELECT value FROM json_each(?))`
1037
+ ).all(JSON.stringify(ids));
1038
+ }
1039
+ /**
1040
+ * The most recently-remembered nodes for a project, newest event first --
1041
+ * chronology, not relevance. No `body`: a listing (e.g. a sidebar) needs
1042
+ * the title and enough metadata to label each row, not the full text.
1043
+ * `idx_nodes_project_ts` already exists for exactly this access pattern.
1044
+ */
1045
+ listRecentNodes(projectId, limit = 20) {
1046
+ return this.db.prepare(
1047
+ `SELECT id, kind, ts, source, title, signal
1048
+ FROM nodes
1049
+ WHERE project_id = ?
1050
+ ORDER BY ts_epoch DESC
1051
+ LIMIT ?`
1052
+ ).all(projectId, limit);
1053
+ }
1054
+ /** How many nodes of one source exist for a project. Used to preview a `pruneSourceNodes` wipe before running it. */
1055
+ countSourceNodes(projectId, source) {
1056
+ const row = this.db.prepare("SELECT COUNT(*) AS count FROM nodes WHERE project_id = ? AND source = ?").get(projectId, source);
1057
+ return row.count;
1058
+ }
975
1059
  /**
976
1060
  * Delete the nodes of one source that its latest full scan did not produce.
977
1061
  *
@@ -1429,6 +1513,70 @@ function renderContextBlock(query, result) {
1429
1513
  return lines.join("\n");
1430
1514
  }
1431
1515
 
1516
+ // src/correlate/failure-fix.ts
1517
+ var DEFAULT_RETRY_WINDOW_MS = 24 * 60 * 60 * 1e3;
1518
+ var DEFAULT_DISCUSSION_WINDOW_MS = 24 * 60 * 60 * 1e3;
1519
+ var RESOLVED_BY_RETRY = "resolved_by:retry";
1520
+ var RESOLVED_BY_DISCUSSION = "resolved_by:discussion";
1521
+ function normalizeCommand(command) {
1522
+ return command.trim().replace(/\s+/g, " ").toLowerCase();
1523
+ }
1524
+ function correlateFailures(store, projectId, opts = {}) {
1525
+ const retryWindowMs = opts.retryWindowMs ?? DEFAULT_RETRY_WINDOW_MS;
1526
+ const discussionWindowMs = opts.discussionWindowMs ?? DEFAULT_DISCUSSION_WINDOW_MS;
1527
+ const db = store.raw;
1528
+ const failures = db.prepare(
1529
+ `SELECT id, ts_epoch, json_extract(meta, '$.command') AS command, json_extract(meta, '$.cwd') AS cwd
1530
+ FROM nodes
1531
+ WHERE project_id = ? AND kind = 'shell_command'
1532
+ AND json_extract(meta, '$.exitCode') IS NOT NULL
1533
+ AND json_extract(meta, '$.exitCode') != 0`
1534
+ ).all(projectId);
1535
+ const findRetry = db.prepare(
1536
+ `SELECT id FROM nodes
1537
+ WHERE project_id = ? AND kind = 'shell_command'
1538
+ AND json_extract(meta, '$.exitCode') = 0
1539
+ AND ts_epoch > ? AND ts_epoch <= ?
1540
+ AND lower(trim(json_extract(meta, '$.command'))) = ?
1541
+ AND (json_extract(meta, '$.cwd') IS ? OR json_extract(meta, '$.cwd') = ?)
1542
+ ORDER BY ts_epoch ASC LIMIT 1`
1543
+ );
1544
+ const findDiscussion = db.prepare(
1545
+ `SELECT n.id FROM nodes_fts
1546
+ JOIN nodes n ON n.rowid = nodes_fts.rowid
1547
+ WHERE nodes_fts MATCH ? AND n.project_id = ? AND n.kind IN ('conversation_turn', 'session_summary')
1548
+ AND n.ts_epoch > ? AND n.ts_epoch <= ?
1549
+ ORDER BY bm25(nodes_fts, 10.0, 1.0)
1550
+ LIMIT 1`
1551
+ );
1552
+ let linkedByRetry = 0;
1553
+ let linkedByDiscussion = 0;
1554
+ for (const failure of failures) {
1555
+ if (!failure.command) continue;
1556
+ const retry = findRetry.get(
1557
+ projectId,
1558
+ failure.ts_epoch,
1559
+ failure.ts_epoch + retryWindowMs,
1560
+ normalizeCommand(failure.command),
1561
+ failure.cwd,
1562
+ failure.cwd
1563
+ );
1564
+ if (retry) {
1565
+ store.linkNodes(failure.id, retry.id, RESOLVED_BY_RETRY);
1566
+ linkedByRetry += 1;
1567
+ }
1568
+ const match = toStrictMatchQuery(failure.command);
1569
+ if (match) {
1570
+ const discussion = findDiscussion.get(match, projectId, failure.ts_epoch, failure.ts_epoch + discussionWindowMs);
1571
+ if (discussion) {
1572
+ store.linkNodes(failure.id, discussion.id, RESOLVED_BY_DISCUSSION);
1573
+ linkedByDiscussion += 1;
1574
+ }
1575
+ }
1576
+ }
1577
+ return { failuresExamined: failures.length, linkedByRetry, linkedByDiscussion };
1578
+ }
1579
+
1432
1580
  // src/retrieval/fuse.ts
1433
1581
  var RRF_K = 60;
1434
1582
  function reciprocalRankFusion(lists) {
@@ -1504,6 +1652,42 @@ function rankHits(hits, opts = {}) {
1504
1652
  }
1505
1653
 
1506
1654
  // src/retrieval/query-pipeline.ts
1655
+ var SURFACED_RELATIONS = [RESOLVED_BY_RETRY, RESOLVED_BY_DISCUSSION];
1656
+ function pullLinkedResolutions(resolveStore, ranked) {
1657
+ const present = new Set(ranked.map((hit) => hit.id));
1658
+ const withLinks = [];
1659
+ for (const hit of ranked) {
1660
+ withLinks.push(hit);
1661
+ if (hit.kind !== "shell_command") continue;
1662
+ const store = resolveStore(hit);
1663
+ if (!store) continue;
1664
+ for (const relation of SURFACED_RELATIONS) {
1665
+ for (const linkedId of store.getLinkedNodeIds(hit.id, relation)) {
1666
+ if (present.has(linkedId)) continue;
1667
+ const [resolution] = store.getNodesByIds([linkedId]);
1668
+ if (!resolution) continue;
1669
+ present.add(linkedId);
1670
+ withLinks.push({
1671
+ id: resolution.id,
1672
+ kind: resolution.kind,
1673
+ ts: resolution.ts,
1674
+ title: resolution.title,
1675
+ body: resolution.body,
1676
+ signal: resolution.signal,
1677
+ rank: 0,
1678
+ // no bm25/vector rank of its own -- never read again past this point
1679
+ relevance: hit.relevance,
1680
+ signalWeight: hit.signalWeight,
1681
+ recencyFactor: hit.recencyFactor,
1682
+ ageDays: hit.ageDays,
1683
+ score: hit.score,
1684
+ ...hit.project ? { project: hit.project } : {}
1685
+ });
1686
+ }
1687
+ }
1688
+ }
1689
+ return withLinks;
1690
+ }
1507
1691
  async function runCrossProjectQuery(sources, query, opts) {
1508
1692
  const queryVector = opts.embeddingProvider ? await opts.embeddingProvider.embed(query) : null;
1509
1693
  const lists = [];
@@ -1524,8 +1708,12 @@ async function runCrossProjectQuery(sources, query, opts) {
1524
1708
  }
1525
1709
  hits.push(...mergeSearchAndVectorHits(bm25Hits, vectorHits).map(label));
1526
1710
  }
1711
+ const storeByLabel = new Map(sources.map((source) => [source.label, source.store]));
1527
1712
  const relevanceScores = reciprocalRankFusion(lists);
1528
- const ranked = rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores });
1713
+ const ranked = pullLinkedResolutions(
1714
+ (hit) => hit.project ? storeByLabel.get(hit.project) : void 0,
1715
+ rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores })
1716
+ );
1529
1717
  const packed = packContext(ranked, opts.budget, { query });
1530
1718
  return { bm25Count, vectorCount, hits, packed, perProject };
1531
1719
  }
@@ -1538,7 +1726,10 @@ async function runHybridQuery(store, projectId, query, opts) {
1538
1726
  }
1539
1727
  const hits = vectorHits.length > 0 ? mergeSearchAndVectorHits(bm25Hits, vectorHits) : bm25Hits;
1540
1728
  const relevanceScores = vectorHits.length > 0 ? reciprocalRankFusion([bm25Hits, vectorHits]) : void 0;
1541
- const ranked = rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores });
1729
+ const ranked = pullLinkedResolutions(
1730
+ () => store,
1731
+ rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores })
1732
+ );
1542
1733
  const packed = packContext(ranked, opts.budget, { query });
1543
1734
  return { bm25Count: bm25Hits.length, vectorCount: vectorHits.length, hits, packed };
1544
1735
  }
@@ -3329,6 +3520,41 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
3329
3520
  }
3330
3521
  return { totals, seen: nodes.length };
3331
3522
  }
3523
+ var STALE_SHELL_SOURCES = ["shell:pwsh", "shell:bash", "shell:zsh"];
3524
+ function collectPruneSources(opts) {
3525
+ const sources = /* @__PURE__ */ new Set();
3526
+ if (opts.pruneStaleShell) {
3527
+ for (const source of STALE_SHELL_SOURCES) sources.add(source);
3528
+ }
3529
+ if (opts.pruneSource?.trim()) sources.add(opts.pruneSource.trim());
3530
+ return [...sources];
3531
+ }
3532
+ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
3533
+ const scopeIds = [projectId, ...otherProjectIds];
3534
+ const counts = sources.flatMap((source) => scopeIds.map((id) => ({ source, id, count: store.countSourceNodes(id, source) })));
3535
+ const total = counts.reduce((sum, c) => sum + c.count, 0);
3536
+ if (total === 0) {
3537
+ out(`${pc4.dim("prune-source")} no node(s) match ${sources.join(", ")} -- nothing to do
3538
+ `);
3539
+ return 0;
3540
+ }
3541
+ const describe = (c) => ` ${pc4.dim(c.source)}${c.id !== projectId ? pc4.dim(` (prior identity ${c.id.slice(0, 8)})`) : ""}: ${c.count} node(s)`;
3542
+ if (!yes) {
3543
+ const lines = counts.filter((c) => c.count > 0).map(describe);
3544
+ out(
3545
+ [`${pc4.yellow("would remove")} ${total} node(s):`, ...lines, pc4.dim("re-run with --yes to actually delete these -- this cannot be undone"), ""].join(
3546
+ "\n"
3547
+ )
3548
+ );
3549
+ return 0;
3550
+ }
3551
+ let removed = 0;
3552
+ for (const { source, id } of counts) removed += store.pruneSourceNodes(id, source, []);
3553
+ const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
3554
+ out(`${pc4.green("pruned")} ${removed} node(s) across ${sources.length} source(s)${identityPart}
3555
+ `);
3556
+ return 0;
3557
+ }
3332
3558
  async function runSync(opts) {
3333
3559
  const { repo, ws, projectId, config } = await loadContext(opts.cwd);
3334
3560
  const log = (line) => {
@@ -3367,6 +3593,10 @@ async function runSync(opts) {
3367
3593
  if (config.projectId !== projectId) await writeConfig(ws, { ...config, projectId });
3368
3594
  }
3369
3595
  await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
3596
+ const pruneSources = collectPruneSources(opts);
3597
+ if (pruneSources.length > 0) {
3598
+ return runPruneSources(store, projectId, staleProjectIds, pruneSources, opts.yes ?? false, out);
3599
+ }
3370
3600
  const git2 = await syncGit(store, projectId, opts, repo, config, log);
3371
3601
  const diffs = await syncDiffs(store, projectId, opts, repo, config, log);
3372
3602
  const shell = await syncShell(store, projectId, opts, repo.root, config, log);
@@ -3396,6 +3626,12 @@ async function runSync(opts) {
3396
3626
  log(`${pc4.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
3397
3627
  }
3398
3628
  }
3629
+ let linkLine = "";
3630
+ if (opts.linkFailures) {
3631
+ const linkStats = correlateFailures(store, projectId);
3632
+ linkLine = ` ${pc4.dim(`chains: ${linkStats.failuresExamined} failure(s) examined, ${linkStats.linkedByRetry} linked by retry, ${linkStats.linkedByDiscussion} by discussion`)}
3633
+ `;
3634
+ }
3399
3635
  store.markSynced(projectId);
3400
3636
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
3401
3637
  addStats(totals, git2.totals);
@@ -3416,7 +3652,7 @@ async function runSync(opts) {
3416
3652
  ` ${pc4.green(`+${totals.inserted} new`)} ${pc4.yellow(`~${totals.updated} updated`)} ${pc4.dim(`=${totals.unchanged} unchanged`)}`,
3417
3653
  ` ${pc4.dim(`${stats.total} node(s) total across ${stats.distinctFiles} file path(s)`)}`,
3418
3654
  ""
3419
- ].join("\n") + embedLine
3655
+ ].join("\n") + embedLine + linkLine
3420
3656
  );
3421
3657
  return 0;
3422
3658
  } finally {
@@ -3481,10 +3717,27 @@ async function syncProject(input) {
3481
3717
  rebuild: false,
3482
3718
  quiet: true,
3483
3719
  noEmbed: input.noEmbed,
3720
+ pruneSource: input.pruneSource,
3721
+ pruneStaleShell: input.pruneStaleShell,
3722
+ yes: input.yes,
3484
3723
  out
3485
3724
  };
3486
3725
  await runSync(opts);
3487
- return { summary: chunks.join("").trim() };
3726
+ return { summary: stripAnsi(chunks.join("").trim()) };
3727
+ }
3728
+ function stripAnsi(text) {
3729
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
3730
+ }
3731
+ async function listRecentMemory(input) {
3732
+ const repo = await readRepoInfo(input.projectRoot);
3733
+ const ws = resolveWorkspace(repo.root);
3734
+ const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
3735
+ const store = MemoryStore.open(ws.dbPath);
3736
+ try {
3737
+ return { items: store.listRecentNodes(projectId, input.limit) };
3738
+ } finally {
3739
+ store.close();
3740
+ }
3488
3741
  }
3489
3742
  async function getStatus(input) {
3490
3743
  const repo = await readRepoInfo(input.projectRoot);
@@ -3536,13 +3789,16 @@ function createServer() {
3536
3789
  "sync_project",
3537
3790
  {
3538
3791
  title: "Sync remembered history",
3539
- description: "Ingest new git, diff, shell, docs and (if enabled) conversation history for a NexusMem-tracked repository into its local database.",
3792
+ description: "Ingest new git, diff, shell, docs and (if enabled) conversation history for a NexusMem-tracked repository into its local database. Pass pruneSource or pruneStaleShell instead to delete a dead source's nodes (e.g. the pre-hook shell scrape) rather than syncing -- dry-run unless yes is also true, since this is an irreversible full wipe of that source.",
3540
3793
  inputSchema: {
3541
- projectRoot: z3.string().describe("Absolute path to the repository root")
3794
+ projectRoot: z3.string().describe("Absolute path to the repository root"),
3795
+ pruneSource: z3.string().optional().describe('Delete every node from this exact source (e.g. "shell:pwsh") instead of syncing'),
3796
+ pruneStaleShell: z3.boolean().optional().describe("Shortcut for pruneSource on shell:pwsh, shell:bash and shell:zsh at once -- the dead pre-hook scrape sources"),
3797
+ yes: z3.boolean().optional().describe("Confirms the delete. Without it, pruneSource/pruneStaleShell only report the matching count.")
3542
3798
  }
3543
3799
  },
3544
- async ({ projectRoot }) => {
3545
- const result = await syncProject({ projectRoot });
3800
+ async ({ projectRoot, pruneSource, pruneStaleShell, yes }) => {
3801
+ const result = await syncProject({ projectRoot, pruneSource, pruneStaleShell, yes });
3546
3802
  return { content: [{ type: "text", text: result.summary }] };
3547
3803
  }
3548
3804
  );
@@ -3563,6 +3819,24 @@ function createServer() {
3563
3819
  };
3564
3820
  }
3565
3821
  );
3822
+ server.registerTool(
3823
+ "list_recent_memory",
3824
+ {
3825
+ title: "List recently remembered items",
3826
+ description: "List the most recently remembered items for a NexusMem-tracked repository -- git commits, code diffs, shell commands, tracked docs, and (if enabled) conversation transcripts and session summaries -- newest first. Chronological, not relevance-ranked: use search_memory instead for a specific question.",
3827
+ inputSchema: {
3828
+ projectRoot: z3.string().describe("Absolute path to the repository root"),
3829
+ limit: z3.number().int().positive().optional().describe("Max items to return, newest first. Default 20.")
3830
+ }
3831
+ },
3832
+ async ({ projectRoot, limit }) => {
3833
+ const result = await listRecentMemory({ projectRoot, limit });
3834
+ return {
3835
+ content: [{ type: "text", text: JSON.stringify(result.items, null, 2) }],
3836
+ structuredContent: { items: result.items }
3837
+ };
3838
+ }
3839
+ );
3566
3840
  return server;
3567
3841
  }
3568
3842
  async function runMcpServer() {
@@ -4083,6 +4357,14 @@ program.command("sync").description("Ingest new history into the local database"
4083
4357
  "--embed-limit <count>",
4084
4358
  "stop embedding after this many nodes (default: embed everything pending)",
4085
4359
  (v) => Number.parseInt(v, 10)
4360
+ ).option("--prune-source <name>", "delete every node from this exact source (e.g. shell:pwsh) instead of syncing -- dry-run unless --yes is also given").option(
4361
+ "--prune-stale-shell",
4362
+ "shortcut for --prune-source on shell:pwsh, shell:bash and shell:zsh at once -- the dead pre-hook scrape sources -- dry-run unless --yes is also given",
4363
+ false
4364
+ ).option("--yes", "confirm an irreversible --prune-source/--prune-stale-shell delete", false).option(
4365
+ "--link-failures",
4366
+ "opt-in (experimental): after ingest, link failed shell commands to whatever later resolved them",
4367
+ false
4086
4368
  ).option("-q, --quiet", "only print the final summary", false).action(
4087
4369
  (options) => guard(
4088
4370
  () => runSync({
@@ -4094,6 +4376,10 @@ program.command("sync").description("Ingest new history into the local database"
4094
4376
  conversationOverride: options.conversation ? true : void 0,
4095
4377
  noEmbed: !options.embed,
4096
4378
  embedLimit: options.embedLimit,
4379
+ pruneSource: options.pruneSource,
4380
+ pruneStaleShell: options.pruneStaleShell,
4381
+ yes: options.yes,
4382
+ linkFailures: options.linkFailures,
4097
4383
  quiet: options.quiet
4098
4384
  })
4099
4385
  )()