nexusmem 0.3.1 → 0.3.3

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,72 @@ built from, matched by publish timestamp: `v0.1.0` → `67a4776`, `v0.1.1` → `
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.3.3] — 2026-08-16
13
+
14
+ ### Added
15
+
16
+ - **`nexusmem status` now shows failure→fix chain counts** — `N/M failure(s) resolved (X retry, Y
17
+ discussion)`, plus a hint to run `sync --link-failures` when failures remain unresolved. The
18
+ chain feature is this project's most distinctive capability, but was previously invisible to
19
+ anyone who didn't already know to query `node_links` directly. Backed by the new
20
+ `getChainStats` in `src/correlate/failure-fix.ts`, which dedupes failures resolved by both
21
+ heuristics rather than double-counting them.
22
+
23
+ ### Fixed
24
+
25
+ - **Discussion-bridge heuristic (`sync --link-failures`): corpus-relative boilerplate tokens no
26
+ longer produce false-positive failure→fix links.** Re-dogfooded at larger scale against a second
27
+ real project's history: a command made entirely of words that saturate a project's own corpus
28
+ (e.g. this repo's own name/verbs) could AND-match an unrelated turn that just happened to mention
29
+ the same words, and bm25 score alone could not separate that from a true positive (measured: the
30
+ false positive scored *stronger* than two real true positives). `filterBoilerplateTokens` in
31
+ `src/correlate/failure-fix.ts` drops any token that appears in over 20% of a project's own
32
+ `conversation_turn`/`session_summary` history before building the match query — measured against
33
+ real data, not guessed — and skips the discussion-match attempt entirely (rather than falling back
34
+ unfiltered) when every token turns out to be boilerplate, since a missed link is preferred over a
35
+ false one for this heuristic. Below 10 discussable nodes the filter is skipped, since frequency
36
+ isn't a meaningful signal yet on a young project.
37
+
38
+ ## [0.3.2] — 2026-08-16
39
+
40
+ ### Added
41
+
42
+ - **`mcpName` field in `package.json`**, required by the official MCP registry
43
+ (registry.modelcontextprotocol.io) to verify that whoever publishes `server.json` under
44
+ `io.github.yaminbkk/nexusmem` also controls the `nexusmem` npm package itself — the registry
45
+ rejects a publish attempt otherwise. No behavior change for CLI/MCP users; this is purely a
46
+ registry-ownership proof.
47
+ - **`list_recent_memory` MCP tool** — chronological listing of a repository's most recently
48
+ remembered nodes (git commits, diffs, shell commands, docs, conversation, session summaries),
49
+ newest first. Distinct from `search_memory`: no query, just "what has this project's memory
50
+ recorded lately" — built for the VS Code extension's sidebar view, which lists rather than
51
+ searches. Backed by `MemoryStore.listRecentNodes`, reusing the existing `idx_nodes_project_ts`
52
+ index.
53
+ - **`sync --prune-source <name>` and `sync --prune-stale-shell`** — drop one source's nodes without a
54
+ full `--rebuild`, which loses history that can't be re-read from disk (the shell tail window, older
55
+ conversation turns). `--prune-stale-shell` is a shortcut for the three dead pre-hook shell-scrape
56
+ sources (`shell:pwsh`, `shell:bash`, `shell:zsh`) at once. Dry-run by default — prints the matching
57
+ count and does nothing until `--yes` is also given, since this is an irreversible full wipe of the
58
+ named source(s), unlike `--rebuild`'s no-prompt full-project reset. Also sweeps any prior project
59
+ identity of this same repo (the id a renamed git remote leaves behind after
60
+ `fix(store): reconcile memory stranded by a changed git remote URL` migrates what it can) — a
61
+ live-id-only prune could not reach nodes reconciliation deliberately left in place. Exposed on both
62
+ the CLI and the MCP `sync_project` tool.
63
+ - **Discussion-heuristic failure→fix chains now surface**, tightened to an AND-joined significant-
64
+ token match instead of the original OR match. Re-verified against this repo's own real database:
65
+ 5/5 discussion links correct (was ~half wrong when it shipped unsurfaced in 0.3.0). Chains now
66
+ follow across projects in `query --all-projects` too.
67
+
68
+ ### Fixed
69
+
70
+ - **`sync_project`'s summary no longer contains raw ANSI color codes on Windows.** `runInit`/`runSync`
71
+ format their output with picocolors for terminal display, and picocolors treats `platform ===
72
+ 'win32'` as sufficient evidence of color support on its own, without checking `isTTY` — correct for
73
+ a real terminal, wrong for the MCP JSON-RPC channel, which is piped on every platform. Found live: a
74
+ real MCP client (the VS Code extension's Output channel) rendered the raw escape codes as literal
75
+ text instead of color. Stripped at the MCP boundary in `syncProject`, leaving the CLI's own terminal
76
+ output untouched.
77
+
12
78
  ## [0.3.1] — 2026-08-15
13
79
 
14
80
  ### Added
@@ -210,7 +276,10 @@ First public release.
210
276
  there is no local-model summarization pass, and the conversation collector has never been audited
211
277
  for the stale-node bug that was found and fixed in the docs collector.
212
278
 
213
- [Unreleased]: https://github.com/yaminbkk/NexusMem/compare/v0.3.0...HEAD
279
+ [Unreleased]: https://github.com/yaminbkk/NexusMem/compare/v0.3.3...HEAD
280
+ [0.3.3]: https://github.com/yaminbkk/NexusMem/compare/v0.3.2...v0.3.3
281
+ [0.3.2]: https://github.com/yaminbkk/NexusMem/compare/v0.3.1...v0.3.2
282
+ [0.3.1]: https://github.com/yaminbkk/NexusMem/compare/v0.3.0...v0.3.1
214
283
  [0.3.0]: https://github.com/yaminbkk/NexusMem/compare/v0.2.0...v0.3.0
215
284
  [0.2.0]: https://github.com/yaminbkk/NexusMem/compare/v0.1.2...v0.2.0
216
285
  [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,11 +699,15 @@ 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
  }
709
713
 
@@ -798,9 +802,28 @@ CREATE VIRTUAL TABLE nodes_vec USING vec0 (
798
802
  embedding float[${EMBEDDING_DIM}]
799
803
  );
800
804
  `;
805
+ var V3 = `
806
+ -- A relation between two existing nodes, not a new content node -- the
807
+ -- "failure -> fix" correlation is the relationship itself, and duplicating
808
+ -- either side's content into a third node would just be another
809
+ -- independently-ranked candidate instead of the link the feature needs.
810
+ -- One physical table, multiple relation kinds; 'resolved_by' is the first.
811
+ CREATE TABLE node_links (
812
+ from_node_id TEXT NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
813
+ to_node_id TEXT NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
814
+ relation TEXT NOT NULL,
815
+ created_at INTEGER NOT NULL,
816
+ PRIMARY KEY (from_node_id, to_node_id, relation)
817
+ );
818
+
819
+ -- Packing a failure node needs its resolutions; nothing needs the reverse
820
+ -- direction yet, so only the forward lookup gets an index.
821
+ CREATE INDEX idx_node_links_from ON node_links (from_node_id);
822
+ `;
801
823
  var MIGRATIONS = [
802
824
  { version: 1, up: (db) => db.exec(V1) },
803
- { version: 2, up: (db) => db.exec(V2) }
825
+ { version: 2, up: (db) => db.exec(V2) },
826
+ { version: 3, up: (db) => db.exec(V3) }
804
827
  ];
805
828
  var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
806
829
  function currentSchemaVersion(db) {
@@ -972,6 +995,62 @@ var MemoryStore = class _MemoryStore {
972
995
  this.db.prepare("DELETE FROM sync_state WHERE project_id = ?").run(projectId);
973
996
  return info.changes;
974
997
  }
998
+ /**
999
+ * Record a directed relationship between two existing nodes -- e.g. a
1000
+ * failed `shell_command` and whatever node later resolved it
1001
+ * (`relation = 'resolved_by'`). A relation, not a new content node: the
1002
+ * correlation *is* the relationship, and duplicating either side's content
1003
+ * into a third node would just be another independently-ranked candidate.
1004
+ *
1005
+ * Idempotent by design (`INSERT OR IGNORE` against the table's own primary
1006
+ * key) so re-running a correlation pass over already-linked nodes is a
1007
+ * no-op, not a duplicate-row error.
1008
+ */
1009
+ linkNodes(fromNodeId, toNodeId, relation) {
1010
+ 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());
1011
+ }
1012
+ /** Ids linked from `fromNodeId` under one relation, most recently linked first. Empty if none exist. */
1013
+ getLinkedNodeIds(fromNodeId, relation) {
1014
+ 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);
1015
+ }
1016
+ /**
1017
+ * Hydrate full content for a set of node ids, e.g. to pack a linked
1018
+ * resolution alongside the failure node that points at it. Order is not
1019
+ * guaranteed to match `ids`; ids with no matching row are silently omitted
1020
+ * rather than erroring. `node_links` has `ON DELETE CASCADE` on both
1021
+ * columns, so an individual node delete (e.g. `reconcile.ts` migrating a
1022
+ * node to a freshly-computed id) removes any link pointing at the old id
1023
+ * along with it -- correct as a safety default, though note that reconcile
1024
+ * does not currently re-create the link under the migrated node's new id;
1025
+ * that gap is not addressed here.
1026
+ */
1027
+ getNodesByIds(ids) {
1028
+ if (ids.length === 0) return [];
1029
+ return this.db.prepare(
1030
+ `SELECT id, kind, project_id AS projectId, ts, title, body, signal
1031
+ FROM nodes WHERE id IN (SELECT value FROM json_each(?))`
1032
+ ).all(JSON.stringify(ids));
1033
+ }
1034
+ /**
1035
+ * The most recently-remembered nodes for a project, newest event first --
1036
+ * chronology, not relevance. No `body`: a listing (e.g. a sidebar) needs
1037
+ * the title and enough metadata to label each row, not the full text.
1038
+ * `idx_nodes_project_ts` already exists for exactly this access pattern.
1039
+ */
1040
+ listRecentNodes(projectId, limit = 20) {
1041
+ return this.db.prepare(
1042
+ `SELECT id, kind, ts, source, title, signal
1043
+ FROM nodes
1044
+ WHERE project_id = ?
1045
+ ORDER BY ts_epoch DESC
1046
+ LIMIT ?`
1047
+ ).all(projectId, limit);
1048
+ }
1049
+ /** How many nodes of one source exist for a project. Used to preview a `pruneSourceNodes` wipe before running it. */
1050
+ countSourceNodes(projectId, source) {
1051
+ const row = this.db.prepare("SELECT COUNT(*) AS count FROM nodes WHERE project_id = ? AND source = ?").get(projectId, source);
1052
+ return row.count;
1053
+ }
975
1054
  /**
976
1055
  * Delete the nodes of one source that its latest full scan did not produce.
977
1056
  *
@@ -1429,6 +1508,105 @@ function renderContextBlock(query, result) {
1429
1508
  return lines.join("\n");
1430
1509
  }
1431
1510
 
1511
+ // src/correlate/failure-fix.ts
1512
+ var DEFAULT_RETRY_WINDOW_MS = 24 * 60 * 60 * 1e3;
1513
+ var DEFAULT_DISCUSSION_WINDOW_MS = 24 * 60 * 60 * 1e3;
1514
+ var RESOLVED_BY_RETRY = "resolved_by:retry";
1515
+ var RESOLVED_BY_DISCUSSION = "resolved_by:discussion";
1516
+ function normalizeCommand(command) {
1517
+ return command.trim().replace(/\s+/g, " ").toLowerCase();
1518
+ }
1519
+ var MAX_TOKEN_DOC_FREQUENCY = 0.2;
1520
+ var MIN_CORPUS_FOR_FREQUENCY_FILTER = 10;
1521
+ function filterBoilerplateTokens(db, projectId, tokens) {
1522
+ if (tokens.length === 0) return tokens;
1523
+ const total = db.prepare(`SELECT COUNT(*) AS c FROM nodes WHERE project_id = ? AND kind IN ('conversation_turn', 'session_summary')`).get(projectId).c;
1524
+ if (total < MIN_CORPUS_FOR_FREQUENCY_FILTER) return tokens;
1525
+ const countMatching = db.prepare(
1526
+ `SELECT COUNT(*) AS c FROM nodes_fts JOIN nodes n ON n.rowid = nodes_fts.rowid
1527
+ WHERE nodes_fts MATCH ? AND n.project_id = ? AND n.kind IN ('conversation_turn', 'session_summary')`
1528
+ );
1529
+ return tokens.filter((t) => {
1530
+ const matching = countMatching.get(`"${t}"*`, projectId).c;
1531
+ return matching / total <= MAX_TOKEN_DOC_FREQUENCY;
1532
+ });
1533
+ }
1534
+ function correlateFailures(store, projectId, opts = {}) {
1535
+ const retryWindowMs = opts.retryWindowMs ?? DEFAULT_RETRY_WINDOW_MS;
1536
+ const discussionWindowMs = opts.discussionWindowMs ?? DEFAULT_DISCUSSION_WINDOW_MS;
1537
+ const db = store.raw;
1538
+ const failures = db.prepare(
1539
+ `SELECT id, ts_epoch, json_extract(meta, '$.command') AS command, json_extract(meta, '$.cwd') AS cwd
1540
+ FROM nodes
1541
+ WHERE project_id = ? AND kind = 'shell_command'
1542
+ AND json_extract(meta, '$.exitCode') IS NOT NULL
1543
+ AND json_extract(meta, '$.exitCode') != 0`
1544
+ ).all(projectId);
1545
+ const findRetry = db.prepare(
1546
+ `SELECT id FROM nodes
1547
+ WHERE project_id = ? AND kind = 'shell_command'
1548
+ AND json_extract(meta, '$.exitCode') = 0
1549
+ AND ts_epoch > ? AND ts_epoch <= ?
1550
+ AND lower(trim(json_extract(meta, '$.command'))) = ?
1551
+ AND (json_extract(meta, '$.cwd') IS ? OR json_extract(meta, '$.cwd') = ?)
1552
+ ORDER BY ts_epoch ASC LIMIT 1`
1553
+ );
1554
+ const findDiscussion = db.prepare(
1555
+ `SELECT n.id FROM nodes_fts
1556
+ JOIN nodes n ON n.rowid = nodes_fts.rowid
1557
+ WHERE nodes_fts MATCH ? AND n.project_id = ? AND n.kind IN ('conversation_turn', 'session_summary')
1558
+ AND n.ts_epoch > ? AND n.ts_epoch <= ?
1559
+ ORDER BY bm25(nodes_fts, 10.0, 1.0)
1560
+ LIMIT 1`
1561
+ );
1562
+ let linkedByRetry = 0;
1563
+ let linkedByDiscussion = 0;
1564
+ for (const failure of failures) {
1565
+ if (!failure.command) continue;
1566
+ const retry = findRetry.get(
1567
+ projectId,
1568
+ failure.ts_epoch,
1569
+ failure.ts_epoch + retryWindowMs,
1570
+ normalizeCommand(failure.command),
1571
+ failure.cwd,
1572
+ failure.cwd
1573
+ );
1574
+ if (retry) {
1575
+ store.linkNodes(failure.id, retry.id, RESOLVED_BY_RETRY);
1576
+ linkedByRetry += 1;
1577
+ }
1578
+ const tokens = filterBoilerplateTokens(db, projectId, significantTokens(failure.command));
1579
+ const match = tokens.length > 0 ? tokens.map((t) => `"${t}"*`).join(" AND ") : null;
1580
+ if (match) {
1581
+ const discussion = findDiscussion.get(match, projectId, failure.ts_epoch, failure.ts_epoch + discussionWindowMs);
1582
+ if (discussion) {
1583
+ store.linkNodes(failure.id, discussion.id, RESOLVED_BY_DISCUSSION);
1584
+ linkedByDiscussion += 1;
1585
+ }
1586
+ }
1587
+ }
1588
+ return { failuresExamined: failures.length, linkedByRetry, linkedByDiscussion };
1589
+ }
1590
+ function getChainStats(store, projectId) {
1591
+ const db = store.raw;
1592
+ const failuresTotal = db.prepare(
1593
+ `SELECT COUNT(*) AS c FROM nodes
1594
+ WHERE project_id = ? AND kind = 'shell_command'
1595
+ AND json_extract(meta, '$.exitCode') IS NOT NULL AND json_extract(meta, '$.exitCode') != 0`
1596
+ ).get(projectId).c;
1597
+ const countDistinctLinked = (relations) => db.prepare(
1598
+ `SELECT COUNT(DISTINCT nl.from_node_id) AS c
1599
+ FROM node_links nl JOIN nodes n ON n.id = nl.from_node_id
1600
+ WHERE n.project_id = ? AND nl.relation IN (${relations.map(() => "?").join(", ")})`
1601
+ ).get(projectId, ...relations).c;
1602
+ return {
1603
+ failuresTotal,
1604
+ resolvedByRetry: countDistinctLinked([RESOLVED_BY_RETRY]),
1605
+ resolvedByDiscussion: countDistinctLinked([RESOLVED_BY_DISCUSSION]),
1606
+ resolvedTotal: countDistinctLinked([RESOLVED_BY_RETRY, RESOLVED_BY_DISCUSSION])
1607
+ };
1608
+ }
1609
+
1432
1610
  // src/retrieval/fuse.ts
1433
1611
  var RRF_K = 60;
1434
1612
  function reciprocalRankFusion(lists) {
@@ -1504,6 +1682,42 @@ function rankHits(hits, opts = {}) {
1504
1682
  }
1505
1683
 
1506
1684
  // src/retrieval/query-pipeline.ts
1685
+ var SURFACED_RELATIONS = [RESOLVED_BY_RETRY, RESOLVED_BY_DISCUSSION];
1686
+ function pullLinkedResolutions(resolveStore, ranked) {
1687
+ const present = new Set(ranked.map((hit) => hit.id));
1688
+ const withLinks = [];
1689
+ for (const hit of ranked) {
1690
+ withLinks.push(hit);
1691
+ if (hit.kind !== "shell_command") continue;
1692
+ const store = resolveStore(hit);
1693
+ if (!store) continue;
1694
+ for (const relation of SURFACED_RELATIONS) {
1695
+ for (const linkedId of store.getLinkedNodeIds(hit.id, relation)) {
1696
+ if (present.has(linkedId)) continue;
1697
+ const [resolution] = store.getNodesByIds([linkedId]);
1698
+ if (!resolution) continue;
1699
+ present.add(linkedId);
1700
+ withLinks.push({
1701
+ id: resolution.id,
1702
+ kind: resolution.kind,
1703
+ ts: resolution.ts,
1704
+ title: resolution.title,
1705
+ body: resolution.body,
1706
+ signal: resolution.signal,
1707
+ rank: 0,
1708
+ // no bm25/vector rank of its own -- never read again past this point
1709
+ relevance: hit.relevance,
1710
+ signalWeight: hit.signalWeight,
1711
+ recencyFactor: hit.recencyFactor,
1712
+ ageDays: hit.ageDays,
1713
+ score: hit.score,
1714
+ ...hit.project ? { project: hit.project } : {}
1715
+ });
1716
+ }
1717
+ }
1718
+ }
1719
+ return withLinks;
1720
+ }
1507
1721
  async function runCrossProjectQuery(sources, query, opts) {
1508
1722
  const queryVector = opts.embeddingProvider ? await opts.embeddingProvider.embed(query) : null;
1509
1723
  const lists = [];
@@ -1524,8 +1738,12 @@ async function runCrossProjectQuery(sources, query, opts) {
1524
1738
  }
1525
1739
  hits.push(...mergeSearchAndVectorHits(bm25Hits, vectorHits).map(label));
1526
1740
  }
1741
+ const storeByLabel = new Map(sources.map((source) => [source.label, source.store]));
1527
1742
  const relevanceScores = reciprocalRankFusion(lists);
1528
- const ranked = rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores });
1743
+ const ranked = pullLinkedResolutions(
1744
+ (hit) => hit.project ? storeByLabel.get(hit.project) : void 0,
1745
+ rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores })
1746
+ );
1529
1747
  const packed = packContext(ranked, opts.budget, { query });
1530
1748
  return { bm25Count, vectorCount, hits, packed, perProject };
1531
1749
  }
@@ -1538,7 +1756,10 @@ async function runHybridQuery(store, projectId, query, opts) {
1538
1756
  }
1539
1757
  const hits = vectorHits.length > 0 ? mergeSearchAndVectorHits(bm25Hits, vectorHits) : bm25Hits;
1540
1758
  const relevanceScores = vectorHits.length > 0 ? reciprocalRankFusion([bm25Hits, vectorHits]) : void 0;
1541
- const ranked = rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores });
1759
+ const ranked = pullLinkedResolutions(
1760
+ () => store,
1761
+ rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores })
1762
+ );
1542
1763
  const packed = packContext(ranked, opts.budget, { query });
1543
1764
  return { bm25Count: bm25Hits.length, vectorCount: vectorHits.length, hits, packed };
1544
1765
  }
@@ -3329,6 +3550,41 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
3329
3550
  }
3330
3551
  return { totals, seen: nodes.length };
3331
3552
  }
3553
+ var STALE_SHELL_SOURCES = ["shell:pwsh", "shell:bash", "shell:zsh"];
3554
+ function collectPruneSources(opts) {
3555
+ const sources = /* @__PURE__ */ new Set();
3556
+ if (opts.pruneStaleShell) {
3557
+ for (const source of STALE_SHELL_SOURCES) sources.add(source);
3558
+ }
3559
+ if (opts.pruneSource?.trim()) sources.add(opts.pruneSource.trim());
3560
+ return [...sources];
3561
+ }
3562
+ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
3563
+ const scopeIds = [projectId, ...otherProjectIds];
3564
+ const counts = sources.flatMap((source) => scopeIds.map((id) => ({ source, id, count: store.countSourceNodes(id, source) })));
3565
+ const total = counts.reduce((sum, c) => sum + c.count, 0);
3566
+ if (total === 0) {
3567
+ out(`${pc4.dim("prune-source")} no node(s) match ${sources.join(", ")} -- nothing to do
3568
+ `);
3569
+ return 0;
3570
+ }
3571
+ const describe = (c) => ` ${pc4.dim(c.source)}${c.id !== projectId ? pc4.dim(` (prior identity ${c.id.slice(0, 8)})`) : ""}: ${c.count} node(s)`;
3572
+ if (!yes) {
3573
+ const lines = counts.filter((c) => c.count > 0).map(describe);
3574
+ out(
3575
+ [`${pc4.yellow("would remove")} ${total} node(s):`, ...lines, pc4.dim("re-run with --yes to actually delete these -- this cannot be undone"), ""].join(
3576
+ "\n"
3577
+ )
3578
+ );
3579
+ return 0;
3580
+ }
3581
+ let removed = 0;
3582
+ for (const { source, id } of counts) removed += store.pruneSourceNodes(id, source, []);
3583
+ const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
3584
+ out(`${pc4.green("pruned")} ${removed} node(s) across ${sources.length} source(s)${identityPart}
3585
+ `);
3586
+ return 0;
3587
+ }
3332
3588
  async function runSync(opts) {
3333
3589
  const { repo, ws, projectId, config } = await loadContext(opts.cwd);
3334
3590
  const log = (line) => {
@@ -3367,6 +3623,10 @@ async function runSync(opts) {
3367
3623
  if (config.projectId !== projectId) await writeConfig(ws, { ...config, projectId });
3368
3624
  }
3369
3625
  await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
3626
+ const pruneSources = collectPruneSources(opts);
3627
+ if (pruneSources.length > 0) {
3628
+ return runPruneSources(store, projectId, staleProjectIds, pruneSources, opts.yes ?? false, out);
3629
+ }
3370
3630
  const git2 = await syncGit(store, projectId, opts, repo, config, log);
3371
3631
  const diffs = await syncDiffs(store, projectId, opts, repo, config, log);
3372
3632
  const shell = await syncShell(store, projectId, opts, repo.root, config, log);
@@ -3396,6 +3656,12 @@ async function runSync(opts) {
3396
3656
  log(`${pc4.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
3397
3657
  }
3398
3658
  }
3659
+ let linkLine = "";
3660
+ if (opts.linkFailures) {
3661
+ const linkStats = correlateFailures(store, projectId);
3662
+ linkLine = ` ${pc4.dim(`chains: ${linkStats.failuresExamined} failure(s) examined, ${linkStats.linkedByRetry} linked by retry, ${linkStats.linkedByDiscussion} by discussion`)}
3663
+ `;
3664
+ }
3399
3665
  store.markSynced(projectId);
3400
3666
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
3401
3667
  addStats(totals, git2.totals);
@@ -3416,7 +3682,7 @@ async function runSync(opts) {
3416
3682
  ` ${pc4.green(`+${totals.inserted} new`)} ${pc4.yellow(`~${totals.updated} updated`)} ${pc4.dim(`=${totals.unchanged} unchanged`)}`,
3417
3683
  ` ${pc4.dim(`${stats.total} node(s) total across ${stats.distinctFiles} file path(s)`)}`,
3418
3684
  ""
3419
- ].join("\n") + embedLine
3685
+ ].join("\n") + embedLine + linkLine
3420
3686
  );
3421
3687
  return 0;
3422
3688
  } finally {
@@ -3481,10 +3747,27 @@ async function syncProject(input) {
3481
3747
  rebuild: false,
3482
3748
  quiet: true,
3483
3749
  noEmbed: input.noEmbed,
3750
+ pruneSource: input.pruneSource,
3751
+ pruneStaleShell: input.pruneStaleShell,
3752
+ yes: input.yes,
3484
3753
  out
3485
3754
  };
3486
3755
  await runSync(opts);
3487
- return { summary: chunks.join("").trim() };
3756
+ return { summary: stripAnsi(chunks.join("").trim()) };
3757
+ }
3758
+ function stripAnsi(text) {
3759
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
3760
+ }
3761
+ async function listRecentMemory(input) {
3762
+ const repo = await readRepoInfo(input.projectRoot);
3763
+ const ws = resolveWorkspace(repo.root);
3764
+ const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
3765
+ const store = MemoryStore.open(ws.dbPath);
3766
+ try {
3767
+ return { items: store.listRecentNodes(projectId, input.limit) };
3768
+ } finally {
3769
+ store.close();
3770
+ }
3488
3771
  }
3489
3772
  async function getStatus(input) {
3490
3773
  const repo = await readRepoInfo(input.projectRoot);
@@ -3536,13 +3819,16 @@ function createServer() {
3536
3819
  "sync_project",
3537
3820
  {
3538
3821
  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.",
3822
+ 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
3823
  inputSchema: {
3541
- projectRoot: z3.string().describe("Absolute path to the repository root")
3824
+ projectRoot: z3.string().describe("Absolute path to the repository root"),
3825
+ pruneSource: z3.string().optional().describe('Delete every node from this exact source (e.g. "shell:pwsh") instead of syncing'),
3826
+ pruneStaleShell: z3.boolean().optional().describe("Shortcut for pruneSource on shell:pwsh, shell:bash and shell:zsh at once -- the dead pre-hook scrape sources"),
3827
+ yes: z3.boolean().optional().describe("Confirms the delete. Without it, pruneSource/pruneStaleShell only report the matching count.")
3542
3828
  }
3543
3829
  },
3544
- async ({ projectRoot }) => {
3545
- const result = await syncProject({ projectRoot });
3830
+ async ({ projectRoot, pruneSource, pruneStaleShell, yes }) => {
3831
+ const result = await syncProject({ projectRoot, pruneSource, pruneStaleShell, yes });
3546
3832
  return { content: [{ type: "text", text: result.summary }] };
3547
3833
  }
3548
3834
  );
@@ -3563,6 +3849,24 @@ function createServer() {
3563
3849
  };
3564
3850
  }
3565
3851
  );
3852
+ server.registerTool(
3853
+ "list_recent_memory",
3854
+ {
3855
+ title: "List recently remembered items",
3856
+ 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.",
3857
+ inputSchema: {
3858
+ projectRoot: z3.string().describe("Absolute path to the repository root"),
3859
+ limit: z3.number().int().positive().optional().describe("Max items to return, newest first. Default 20.")
3860
+ }
3861
+ },
3862
+ async ({ projectRoot, limit }) => {
3863
+ const result = await listRecentMemory({ projectRoot, limit });
3864
+ return {
3865
+ content: [{ type: "text", text: JSON.stringify(result.items, null, 2) }],
3866
+ structuredContent: { items: result.items }
3867
+ };
3868
+ }
3869
+ );
3566
3870
  return server;
3567
3871
  }
3568
3872
  async function runMcpServer() {
@@ -4019,6 +4323,7 @@ async function runStatus(opts) {
4019
4323
  const sources = store.listSyncState(projectId);
4020
4324
  const gitCursor = sources.find((s) => s.source === "git")?.cursor ?? null;
4021
4325
  const schema = currentSchemaVersion(store.raw);
4326
+ const chains = getChainStats(store, projectId);
4022
4327
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
4023
4328
  const kinds = Object.entries(stats.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
4024
4329
  process.stdout.write(
@@ -4040,7 +4345,8 @@ async function runStatus(opts) {
4040
4345
  return ` ${s.source.padEnd(14)} ${pc13.dim(`last run ${when}`)} ${pc13.dim(`cursor ${cursorLabel}`)}`;
4041
4346
  }),
4042
4347
  gitCursor && gitCursor !== repo.head ? `${pc13.yellow("git behind HEAD")} \u2014 run ${pc13.bold("nexusmem sync")}` : "",
4043
- ""
4348
+ "",
4349
+ chains.failuresTotal ? `${pc13.dim("chains ")} ${pc13.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc13.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc13.bold("nexusmem sync --link-failures")} to link more` : ""}` : ""
4044
4350
  ].filter((line) => line !== "").join("\n").concat("\n")
4045
4351
  );
4046
4352
  return 0;
@@ -4083,6 +4389,14 @@ program.command("sync").description("Ingest new history into the local database"
4083
4389
  "--embed-limit <count>",
4084
4390
  "stop embedding after this many nodes (default: embed everything pending)",
4085
4391
  (v) => Number.parseInt(v, 10)
4392
+ ).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(
4393
+ "--prune-stale-shell",
4394
+ "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",
4395
+ false
4396
+ ).option("--yes", "confirm an irreversible --prune-source/--prune-stale-shell delete", false).option(
4397
+ "--link-failures",
4398
+ "opt-in (experimental): after ingest, link failed shell commands to whatever later resolved them",
4399
+ false
4086
4400
  ).option("-q, --quiet", "only print the final summary", false).action(
4087
4401
  (options) => guard(
4088
4402
  () => runSync({
@@ -4094,6 +4408,10 @@ program.command("sync").description("Ingest new history into the local database"
4094
4408
  conversationOverride: options.conversation ? true : void 0,
4095
4409
  noEmbed: !options.embed,
4096
4410
  embedLimit: options.embedLimit,
4411
+ pruneSource: options.pruneSource,
4412
+ pruneStaleShell: options.pruneStaleShell,
4413
+ yes: options.yes,
4414
+ linkFailures: options.linkFailures,
4097
4415
  quiet: options.quiet
4098
4416
  })
4099
4417
  )()