nexusmem 0.3.0 → 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/dist/cli/index.js CHANGED
@@ -698,10 +698,22 @@ import * as sqliteVec from "sqlite-vec";
698
698
 
699
699
  // src/store/fts.ts
700
700
  var FTS_SYNTAX = /["'()*:^{}[\]-]/g;
701
- function toMatchQuery(input) {
701
+ var LOW_SIGNAL_TOKENS = /* @__PURE__ */ new Set(["id"]);
702
+ function significantTokens(input) {
702
703
  const tokens = input.replace(FTS_SYNTAX, " ").split(/\s+/).map((t) => t.trim()).filter((t) => t.length > 0);
703
- if (tokens.length === 0) return null;
704
- return tokens.map((t) => `"${t}"*`).join(" OR ");
704
+ if (tokens.length === 0) return [];
705
+ const signal = tokens.filter((t) => !LOW_SIGNAL_TOKENS.has(t.toLowerCase()));
706
+ return signal.length > 0 ? signal : tokens;
707
+ }
708
+ function toMatchQuery(input) {
709
+ const kept = significantTokens(input);
710
+ if (kept.length === 0) return null;
711
+ return kept.map((t) => `"${t}"*`).join(" OR ");
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 ");
705
717
  }
706
718
 
707
719
  // src/store/schema.ts
@@ -795,9 +807,28 @@ CREATE VIRTUAL TABLE nodes_vec USING vec0 (
795
807
  embedding float[${EMBEDDING_DIM}]
796
808
  );
797
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
+ `;
798
828
  var MIGRATIONS = [
799
829
  { version: 1, up: (db) => db.exec(V1) },
800
- { version: 2, up: (db) => db.exec(V2) }
830
+ { version: 2, up: (db) => db.exec(V2) },
831
+ { version: 3, up: (db) => db.exec(V3) }
801
832
  ];
802
833
  var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
803
834
  function currentSchemaVersion(db) {
@@ -848,6 +879,20 @@ var MemoryStore = class _MemoryStore {
848
879
  markSynced(projectId) {
849
880
  this.db.prepare("UPDATE projects SET last_synced_at = ? WHERE id = ?").run(Date.now(), projectId);
850
881
  }
882
+ /**
883
+ * Every other project id ever recorded in THIS repo's own database.
884
+ *
885
+ * A repo's `.nexusmem/memory.db` is never shared with another repo (each
886
+ * gets its own, gitignored), so any id here besides `currentProjectId` is
887
+ * evidence of a prior identity for this same repo -- typically its git
888
+ * remote URL changed since the last sync. See `reconcileProjectId` in
889
+ * `store/reconcile.ts`.
890
+ */
891
+ listOtherProjectIds(currentProjectId) {
892
+ return this.db.prepare("SELECT id FROM projects WHERE id != ?").all(currentProjectId).map(
893
+ (r) => r.id
894
+ );
895
+ }
851
896
  /**
852
897
  * Write a batch of nodes in one transaction.
853
898
  *
@@ -955,6 +1000,62 @@ var MemoryStore = class _MemoryStore {
955
1000
  this.db.prepare("DELETE FROM sync_state WHERE project_id = ?").run(projectId);
956
1001
  return info.changes;
957
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
+ }
958
1059
  /**
959
1060
  * Delete the nodes of one source that its latest full scan did not produce.
960
1061
  *
@@ -1245,6 +1346,8 @@ function approxTokens(text) {
1245
1346
  var DEFAULT_SUMMARY_CHARS = 320;
1246
1347
  var NODE_OVERHEAD_TOKENS = 8;
1247
1348
  var CONVERSATION_ANSWER_MARKER = "\n\nA: ";
1349
+ var MAX_PER_FAMILY = 2;
1350
+ var CHUNKED_KINDS = /* @__PURE__ */ new Set(["conversation_turn", "doc_section"]);
1248
1351
  var HUNK_BOUNDARY = "\n@@ ";
1249
1352
  var STOPWORDS = /* @__PURE__ */ new Set([
1250
1353
  "the",
@@ -1363,7 +1466,14 @@ function packContext(ranked, tokensBudget, opts = {}) {
1363
1466
  const nodes = [];
1364
1467
  let tokensUsed = 0;
1365
1468
  let droppedForBudget = 0;
1469
+ let droppedForDiversity = 0;
1470
+ const familyCounts = /* @__PURE__ */ new Map();
1366
1471
  for (const hit of ranked) {
1472
+ const familyKey = CHUNKED_KINDS.has(hit.kind) ? `${hit.kind}:${hit.ts}` : null;
1473
+ if (familyKey && (familyCounts.get(familyKey) ?? 0) >= MAX_PER_FAMILY) {
1474
+ droppedForDiversity += 1;
1475
+ continue;
1476
+ }
1367
1477
  const summary = summarize(hit, summaryChars, query);
1368
1478
  const tokens = approxTokens(hit.title) + approxTokens(summary) + NODE_OVERHEAD_TOKENS;
1369
1479
  if (tokensUsed + tokens > tokensBudget) {
@@ -1382,8 +1492,9 @@ function packContext(ranked, tokensBudget, opts = {}) {
1382
1492
  ...hit.project ? { project: hit.project } : {}
1383
1493
  });
1384
1494
  tokensUsed += tokens;
1495
+ if (familyKey) familyCounts.set(familyKey, (familyCounts.get(familyKey) ?? 0) + 1);
1385
1496
  }
1386
- return { nodes, tokensUsed, tokensBudget, consideredNodes: ranked.length, droppedForBudget };
1497
+ return { nodes, tokensUsed, tokensBudget, consideredNodes: ranked.length, droppedForBudget, droppedForDiversity };
1387
1498
  }
1388
1499
  function renderContextBlock(query, result) {
1389
1500
  if (result.nodes.length === 0) return `No remembered context matched "${query}".`;
@@ -1402,6 +1513,70 @@ function renderContextBlock(query, result) {
1402
1513
  return lines.join("\n");
1403
1514
  }
1404
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
+
1405
1580
  // src/retrieval/fuse.ts
1406
1581
  var RRF_K = 60;
1407
1582
  function reciprocalRankFusion(lists) {
@@ -1477,6 +1652,42 @@ function rankHits(hits, opts = {}) {
1477
1652
  }
1478
1653
 
1479
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
+ }
1480
1691
  async function runCrossProjectQuery(sources, query, opts) {
1481
1692
  const queryVector = opts.embeddingProvider ? await opts.embeddingProvider.embed(query) : null;
1482
1693
  const lists = [];
@@ -1497,8 +1708,12 @@ async function runCrossProjectQuery(sources, query, opts) {
1497
1708
  }
1498
1709
  hits.push(...mergeSearchAndVectorHits(bm25Hits, vectorHits).map(label));
1499
1710
  }
1711
+ const storeByLabel = new Map(sources.map((source) => [source.label, source.store]));
1500
1712
  const relevanceScores = reciprocalRankFusion(lists);
1501
- 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
+ );
1502
1717
  const packed = packContext(ranked, opts.budget, { query });
1503
1718
  return { bm25Count, vectorCount, hits, packed, perProject };
1504
1719
  }
@@ -1511,7 +1726,10 @@ async function runHybridQuery(store, projectId, query, opts) {
1511
1726
  }
1512
1727
  const hits = vectorHits.length > 0 ? mergeSearchAndVectorHits(bm25Hits, vectorHits) : bm25Hits;
1513
1728
  const relevanceScores = vectorHits.length > 0 ? reciprocalRankFusion([bm25Hits, vectorHits]) : void 0;
1514
- const ranked = rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores });
1729
+ const ranked = pullLinkedResolutions(
1730
+ () => store,
1731
+ rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores })
1732
+ );
1515
1733
  const packed = packContext(ranked, opts.budget, { query });
1516
1734
  return { bm25Count: bm25Hits.length, vectorCount: vectorHits.length, hits, packed };
1517
1735
  }
@@ -2425,6 +2643,7 @@ function sessionFallbackTitle(session) {
2425
2643
  }
2426
2644
  var MAX_TITLE_CHARS5 = 200;
2427
2645
  var GENERIC_TITLE = /^(a |the )?(session |conversation |project |work )?(summary|update|overview|recap|status)\b/i;
2646
+ var ROLE_PREAMBLE_TITLE = /^(role\s*[::]|you\s*(?:'re|are)\s+(acting as|serving as|playing the role of|a\b)|i\s*(?:'m|am)\s+(acting as|a\b)|acting as\b|บทบาท\s*[::]|ในฐานะ|คุณ(กำลัง)?(ทำหน้าที่เป็น|เป็น|รับบทบาทเป็น)|(ผม|ฉัน)(กำลัง)?(ทำหน้าที่เป็น|เป็น))/i;
2428
2647
  function cleanTitle(line) {
2429
2648
  return line.replace(/^[-*#>\s]+/, "").replace(/\*+/g, "").replace(/\s*:\s*$/, "").trim();
2430
2649
  }
@@ -2441,7 +2660,7 @@ function parseSummary(raw, fallbackTitle) {
2441
2660
  const candidate = cleanTitle(labelled[1]);
2442
2661
  const rest = lines.slice(firstIndex + 1).join("\n").trim();
2443
2662
  return {
2444
- title: candidate.length > 0 && !GENERIC_TITLE.test(candidate) ? truncate(candidate, MAX_TITLE_CHARS5) : fallback,
2663
+ title: candidate.length > 0 && !GENERIC_TITLE.test(candidate) && !ROLE_PREAMBLE_TITLE.test(candidate) ? truncate(candidate, MAX_TITLE_CHARS5) : fallback,
2445
2664
  // A model that emitted only a title still gets a usable node: the title
2446
2665
  // doubles as the body rather than storing an empty one.
2447
2666
  body: rest.length > 0 ? rest : candidate
@@ -2921,6 +3140,100 @@ async function collectAvailableShellHistory(opts = {}) {
2921
3140
  return results;
2922
3141
  }
2923
3142
 
3143
+ // src/store/reconcile.ts
3144
+ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, computeNaturalKey) {
3145
+ const rows = source ? db.prepare("SELECT * FROM nodes WHERE project_id = ? AND kind = ? AND source = ?").all(oldProjectId, kind, source) : db.prepare("SELECT * FROM nodes WHERE project_id = ? AND kind = ?").all(oldProjectId, kind);
3146
+ const nodeExists = db.prepare("SELECT 1 FROM nodes WHERE id = ?");
3147
+ const insertNode = db.prepare(
3148
+ `INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, created_at)
3149
+ VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @createdAt)`
3150
+ );
3151
+ const readFiles = db.prepare("SELECT path, previous_path, insertions, deletions, is_binary FROM node_files WHERE node_id = ?");
3152
+ const insertFile = db.prepare(
3153
+ `INSERT INTO node_files (node_id, path, previous_path, insertions, deletions, is_binary)
3154
+ VALUES (@nodeId, @path, @previousPath, @insertions, @deletions, @isBinary)`
3155
+ );
3156
+ const dropEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
3157
+ const deleteNode = db.prepare("DELETE FROM nodes WHERE id = ?");
3158
+ let migrated = 0;
3159
+ let deduped = 0;
3160
+ let skipped = 0;
3161
+ for (const row of rows) {
3162
+ let meta;
3163
+ try {
3164
+ meta = JSON.parse(row.meta);
3165
+ } catch {
3166
+ skipped += 1;
3167
+ continue;
3168
+ }
3169
+ const naturalKey = computeNaturalKey(row, meta);
3170
+ if (naturalKey === null) {
3171
+ skipped += 1;
3172
+ continue;
3173
+ }
3174
+ const newId = makeNodeId(newProjectId, kind, naturalKey);
3175
+ if (nodeExists.get(newId)) {
3176
+ deduped += 1;
3177
+ } else {
3178
+ insertNode.run({
3179
+ id: newId,
3180
+ kind: row.kind,
3181
+ projectId: newProjectId,
3182
+ ts: row.ts,
3183
+ tsEpoch: row.ts_epoch,
3184
+ source: row.source,
3185
+ title: row.title,
3186
+ body: row.body,
3187
+ signal: row.signal,
3188
+ meta: row.meta,
3189
+ createdAt: row.created_at
3190
+ });
3191
+ for (const file of readFiles.all(row.id)) {
3192
+ insertFile.run({
3193
+ nodeId: newId,
3194
+ path: file.path,
3195
+ previousPath: file.previous_path,
3196
+ insertions: file.insertions,
3197
+ deletions: file.deletions,
3198
+ isBinary: file.is_binary
3199
+ });
3200
+ }
3201
+ migrated += 1;
3202
+ }
3203
+ dropEmbedding.run(row.id);
3204
+ deleteNode.run(row.id);
3205
+ }
3206
+ return { migrated, deduped, skipped };
3207
+ }
3208
+ function reconcileProjectId(db, oldProjectId, newProjectId) {
3209
+ return db.transaction(() => {
3210
+ const sessions = recomputeByNaturalKey(
3211
+ db,
3212
+ oldProjectId,
3213
+ newProjectId,
3214
+ "session_summary",
3215
+ null,
3216
+ (_row, meta) => typeof meta.sessionKey === "string" ? meta.sessionKey : null
3217
+ );
3218
+ const hookShell = recomputeByNaturalKey(
3219
+ db,
3220
+ oldProjectId,
3221
+ newProjectId,
3222
+ "shell_command",
3223
+ "shell:pwsh-hook",
3224
+ (row, meta) => typeof meta.command === "string" ? `pwsh-hook:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null
3225
+ );
3226
+ const reassigned = db.prepare(`UPDATE nodes SET project_id = ? WHERE project_id = ? AND kind = 'conversation_turn'`).run(newProjectId, oldProjectId).changes;
3227
+ return {
3228
+ oldProjectId,
3229
+ migrated: sessions.migrated + hookShell.migrated,
3230
+ reassigned,
3231
+ deduped: sessions.deduped + hookShell.deduped,
3232
+ skipped: sessions.skipped + hookShell.skipped
3233
+ };
3234
+ })();
3235
+ }
3236
+
2924
3237
  // src/vector/sync.ts
2925
3238
  var EMBEDDING_IDENTITY_KEY = "embedding.identity";
2926
3239
  var DEFAULT_BATCH_SIZE = 32;
@@ -3207,6 +3520,41 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
3207
3520
  }
3208
3521
  return { totals, seen: nodes.length };
3209
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
+ }
3210
3558
  async function runSync(opts) {
3211
3559
  const { repo, ws, projectId, config } = await loadContext(opts.cwd);
3212
3560
  const log = (line) => {
@@ -3218,11 +3566,37 @@ async function runSync(opts) {
3218
3566
  const started = Date.now();
3219
3567
  try {
3220
3568
  store.upsertProject({ id: projectId, root: repo.root, originUrl: repo.originUrl });
3221
- await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
3222
3569
  if (opts.rebuild) {
3223
3570
  const removed = store.clearProject(projectId);
3224
3571
  log(`${pc4.dim("rebuild")} dropped ${removed} existing node(s)`);
3225
3572
  }
3573
+ const staleProjectIds = store.listOtherProjectIds(projectId);
3574
+ for (const staleId of staleProjectIds) {
3575
+ const result = reconcileProjectId(store.raw, staleId, projectId);
3576
+ const parts = [
3577
+ result.migrated > 0 ? `${result.migrated} migrated` : null,
3578
+ result.reassigned > 0 ? `${result.reassigned} reassigned` : null,
3579
+ result.deduped > 0 ? `${result.deduped} already up to date` : null,
3580
+ result.skipped > 0 ? `${result.skipped} left behind (not reconstructable)` : null
3581
+ ].filter((part) => part !== null);
3582
+ if (parts.length > 0) {
3583
+ log(
3584
+ `${pc4.yellow("reconciled")} previous project identity ${pc4.dim(staleId)} (remote URL likely changed): ${parts.join(", ")}`
3585
+ );
3586
+ }
3587
+ }
3588
+ if (staleProjectIds.length > 0) {
3589
+ if (opts.rebuild) {
3590
+ for (const staleId of staleProjectIds) store.clearProject(staleId);
3591
+ }
3592
+ await forgetProjects(staleProjectIds);
3593
+ if (config.projectId !== projectId) await writeConfig(ws, { ...config, projectId });
3594
+ }
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
+ }
3226
3600
  const git2 = await syncGit(store, projectId, opts, repo, config, log);
3227
3601
  const diffs = await syncDiffs(store, projectId, opts, repo, config, log);
3228
3602
  const shell = await syncShell(store, projectId, opts, repo.root, config, log);
@@ -3252,6 +3626,12 @@ async function runSync(opts) {
3252
3626
  log(`${pc4.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
3253
3627
  }
3254
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
+ }
3255
3635
  store.markSynced(projectId);
3256
3636
  const totals = { inserted: 0, updated: 0, unchanged: 0 };
3257
3637
  addStats(totals, git2.totals);
@@ -3272,7 +3652,7 @@ async function runSync(opts) {
3272
3652
  ` ${pc4.green(`+${totals.inserted} new`)} ${pc4.yellow(`~${totals.updated} updated`)} ${pc4.dim(`=${totals.unchanged} unchanged`)}`,
3273
3653
  ` ${pc4.dim(`${stats.total} node(s) total across ${stats.distinctFiles} file path(s)`)}`,
3274
3654
  ""
3275
- ].join("\n") + embedLine
3655
+ ].join("\n") + embedLine + linkLine
3276
3656
  );
3277
3657
  return 0;
3278
3658
  } finally {
@@ -3337,10 +3717,27 @@ async function syncProject(input) {
3337
3717
  rebuild: false,
3338
3718
  quiet: true,
3339
3719
  noEmbed: input.noEmbed,
3720
+ pruneSource: input.pruneSource,
3721
+ pruneStaleShell: input.pruneStaleShell,
3722
+ yes: input.yes,
3340
3723
  out
3341
3724
  };
3342
3725
  await runSync(opts);
3343
- 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
+ }
3344
3741
  }
3345
3742
  async function getStatus(input) {
3346
3743
  const repo = await readRepoInfo(input.projectRoot);
@@ -3392,13 +3789,16 @@ function createServer() {
3392
3789
  "sync_project",
3393
3790
  {
3394
3791
  title: "Sync remembered history",
3395
- 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.",
3396
3793
  inputSchema: {
3397
- 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.")
3398
3798
  }
3399
3799
  },
3400
- async ({ projectRoot }) => {
3401
- const result = await syncProject({ projectRoot });
3800
+ async ({ projectRoot, pruneSource, pruneStaleShell, yes }) => {
3801
+ const result = await syncProject({ projectRoot, pruneSource, pruneStaleShell, yes });
3402
3802
  return { content: [{ type: "text", text: result.summary }] };
3403
3803
  }
3404
3804
  );
@@ -3419,6 +3819,24 @@ function createServer() {
3419
3819
  };
3420
3820
  }
3421
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
+ );
3422
3840
  return server;
3423
3841
  }
3424
3842
  async function runMcpServer() {
@@ -3477,7 +3895,8 @@ async function runQuery(opts) {
3477
3895
  packed: packed.nodes,
3478
3896
  tokensUsed: packed.tokensUsed,
3479
3897
  tokensBudget: packed.tokensBudget,
3480
- droppedForBudget: packed.droppedForBudget
3898
+ droppedForBudget: packed.droppedForBudget,
3899
+ droppedForDiversity: packed.droppedForDiversity
3481
3900
  },
3482
3901
  null,
3483
3902
  2
@@ -3494,7 +3913,7 @@ async function runQuery(opts) {
3494
3913
  process.stderr.write(
3495
3914
  [
3496
3915
  `${pc5.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc5.bold(String(packed.nodes.length))} into budget`,
3497
- `${pc5.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc5.dim(` (${packed.droppedForBudget} dropped for budget)`) : ""),
3916
+ `${pc5.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc5.dim(` (${packed.droppedForBudget} dropped for budget)`) : "") + (packed.droppedForDiversity ? pc5.dim(` (${packed.droppedForDiversity} dropped for diversity)`) : ""),
3498
3917
  rawTokens > 0 ? `${pc5.dim("vs raw ")} ${rawTokens} tokens if these same matches were sent unpacked ${packerEfficiency >= 0 ? pc5.green(`(${(packerEfficiency * 100).toFixed(0)}% packer efficiency)`) : pc5.yellow(`(${(-packerEfficiency * 100).toFixed(0)}% larger -- overhead dominates on small result sets)`)}` : "",
3499
3918
  ""
3500
3919
  ].filter(Boolean).join("\n")
@@ -3938,6 +4357,14 @@ program.command("sync").description("Ingest new history into the local database"
3938
4357
  "--embed-limit <count>",
3939
4358
  "stop embedding after this many nodes (default: embed everything pending)",
3940
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
3941
4368
  ).option("-q, --quiet", "only print the final summary", false).action(
3942
4369
  (options) => guard(
3943
4370
  () => runSync({
@@ -3949,6 +4376,10 @@ program.command("sync").description("Ingest new history into the local database"
3949
4376
  conversationOverride: options.conversation ? true : void 0,
3950
4377
  noEmbed: !options.embed,
3951
4378
  embedLimit: options.embedLimit,
4379
+ pruneSource: options.pruneSource,
4380
+ pruneStaleShell: options.pruneStaleShell,
4381
+ yes: options.yes,
4382
+ linkFailures: options.linkFailures,
3952
4383
  quiet: options.quiet
3953
4384
  })
3954
4385
  )()