nexusmem 0.8.0 → 0.9.1

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli/index.ts
4
4
  import { Command } from "commander";
5
- import pc20 from "picocolors";
5
+ import pc21 from "picocolors";
6
6
 
7
7
  // src/config/workspace.ts
8
8
  import { existsSync } from "fs";
@@ -290,6 +290,7 @@ function crashStatus(code, signal) {
290
290
  return null;
291
291
  }
292
292
  var TRANSIENT_SPAWN_CODES = /* @__PURE__ */ new Set(["EAGAIN", "EPERM", "EACCES", "EMFILE", "ENFILE", "ENOMEM", "EBUSY", "ETXTBSY"]);
293
+ var GIT_LAUNCH_FAILURE = /error launching git/i;
293
294
  function toSpawnError(err, cwd, args) {
294
295
  const code = err?.code;
295
296
  if (code === "ENOENT") {
@@ -366,6 +367,15 @@ async function* runGitOnce(cwd, args, opts) {
366
367
  }
367
368
  if (code !== 0) {
368
369
  const trimmed = stderr.trim();
370
+ if (GIT_LAUNCH_FAILURE.test(trimmed)) {
371
+ throw new GitSpawnError(
372
+ `Could not start git (${trimmed.split("\n")[0]}) in ${cwd}. This is usually transient on Windows -- retrying the same command often succeeds.`,
373
+ fullArgs,
374
+ void 0,
375
+ true,
376
+ null
377
+ );
378
+ }
369
379
  const detail = trimmed.split("\n")[0];
370
380
  throw new GitError(
371
381
  `git ${args.join(" ")} exited with code ${code}${detail ? `: ${detail}` : ""}`,
@@ -491,6 +501,9 @@ function renderHookSnippet(logPath) {
491
501
  "$global:__ssd_last_history_id = -1",
492
502
  `$global:__ssd_log_path = ${toPowerShellLiteral(logPath)}`,
493
503
  "function global:prompt {",
504
+ // Must be first: Get-History (or anything else) below would overwrite $?.
505
+ " $__ssd_ok = $?",
506
+ " $__ssd_exit = $LASTEXITCODE",
494
507
  " $__ssd_h = Get-History -Count 1 -ErrorAction SilentlyContinue",
495
508
  " if ($__ssd_h -and $__ssd_h.Id -ne $global:__ssd_last_history_id) {",
496
509
  " $global:__ssd_last_history_id = $__ssd_h.Id",
@@ -498,7 +511,8 @@ function renderHookSnippet(logPath) {
498
511
  " $__ssd_entry = [ordered]@{",
499
512
  ' ts = (Get-Date).ToString("o")',
500
513
  " cwd = (Get-Location).Path",
501
- " exitCode = $LASTEXITCODE",
514
+ // $LASTEXITCODE alone misses cmdlet failures and goes stale after them; $? catches both.
515
+ " exitCode = if ($__ssd_ok) { 0 } elseif ($__ssd_exit) { $__ssd_exit } else { 1 }",
502
516
  " durationMs = [int](($__ssd_h.EndExecutionTime - $__ssd_h.StartExecutionTime).TotalMilliseconds)",
503
517
  " command = $__ssd_h.CommandLine",
504
518
  " }",
@@ -974,6 +988,31 @@ CREATE TABLE contradiction_checks (
974
988
  PRIMARY KEY (candidate_id, against_id)
975
989
  );
976
990
  `;
991
+ var V9 = `
992
+ ALTER TABLE contradiction_checks ADD COLUMN dismissed INTEGER NOT NULL DEFAULT 0;
993
+ `;
994
+ var V10 = `
995
+ ALTER TABLE nodes ADD COLUMN trust_state TEXT NOT NULL DEFAULT 'candidate';
996
+ CREATE INDEX idx_nodes_trust_state ON nodes (project_id, trust_state) WHERE trust_state != 'candidate';
997
+ `;
998
+ var V11 = `
999
+ CREATE TEMP TABLE nodes_vec_stage AS
1000
+ SELECT v.rowid AS rowid, n.project_id AS project_id, v.embedding AS embedding
1001
+ FROM nodes_vec v
1002
+ JOIN nodes n ON n.rowid = v.rowid;
1003
+
1004
+ DROP TABLE nodes_vec;
1005
+
1006
+ CREATE VIRTUAL TABLE nodes_vec USING vec0 (
1007
+ project_id TEXT PARTITION KEY,
1008
+ embedding float[${EMBEDDING_DIM}]
1009
+ );
1010
+
1011
+ INSERT INTO nodes_vec (rowid, project_id, embedding)
1012
+ SELECT rowid, project_id, embedding FROM nodes_vec_stage;
1013
+
1014
+ DROP TABLE nodes_vec_stage;
1015
+ `;
977
1016
  var MIGRATIONS = [
978
1017
  { version: 1, up: (db) => db.exec(V1) },
979
1018
  { version: 2, up: (db) => db.exec(V2) },
@@ -982,7 +1021,10 @@ var MIGRATIONS = [
982
1021
  { version: 5, up: (db) => db.exec(V5) },
983
1022
  { version: 6, up: (db) => db.exec(V6) },
984
1023
  { version: 7, up: (db) => db.exec(V7) },
985
- { version: 8, up: (db) => db.exec(V8) }
1024
+ { version: 8, up: (db) => db.exec(V8) },
1025
+ { version: 9, up: (db) => db.exec(V9) },
1026
+ { version: 10, up: (db) => db.exec(V10) },
1027
+ { version: 11, up: (db) => db.exec(V11) }
986
1028
  ];
987
1029
  var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
988
1030
  function currentSchemaVersion(db) {
@@ -1000,6 +1042,36 @@ function migrate(db) {
1000
1042
  return { from, to: currentSchemaVersion(db) };
1001
1043
  }
1002
1044
 
1045
+ // src/store/audit.ts
1046
+ function recordMutationAudit(db, input) {
1047
+ return Number(
1048
+ db.prepare(
1049
+ `INSERT INTO mutation_audit (action, project_id, detail, affected_count, succeeded, error, started_at, finished_at)
1050
+ VALUES (@action, @projectId, @detail, @affectedCount, @succeeded, @error, @startedAt, @finishedAt)`
1051
+ ).run({
1052
+ action: input.action,
1053
+ projectId: input.projectId,
1054
+ detail: JSON.stringify(input.detail),
1055
+ affectedCount: input.affectedCount,
1056
+ succeeded: input.succeeded ? 1 : 0,
1057
+ error: input.error ?? null,
1058
+ startedAt: input.startedAt,
1059
+ finishedAt: input.finishedAt
1060
+ }).lastInsertRowid
1061
+ );
1062
+ }
1063
+ function listMutationAudit(db, projectId, opts = {}) {
1064
+ const rows = db.prepare(
1065
+ `SELECT id, action, project_id AS projectId, detail, affected_count AS affectedCount,
1066
+ succeeded, error, started_at AS startedAt, finished_at AS finishedAt
1067
+ FROM mutation_audit
1068
+ WHERE project_id = @projectId
1069
+ ORDER BY started_at DESC
1070
+ LIMIT @limit`
1071
+ ).all({ projectId, limit: opts.limit ?? 50 });
1072
+ return rows.map((r) => ({ ...r, succeeded: r.succeeded === 1 }));
1073
+ }
1074
+
1003
1075
  // src/store/projects.ts
1004
1076
  function upsertProject(db, project) {
1005
1077
  db.prepare(
@@ -1152,7 +1224,7 @@ function clearProject(db, projectId) {
1152
1224
  function getNodesByIds(db, ids) {
1153
1225
  if (ids.length === 0) return [];
1154
1226
  return db.prepare(
1155
- `SELECT id, kind, project_id AS projectId, ts, title, body, signal, provenance
1227
+ `SELECT id, kind, project_id AS projectId, ts, title, body, signal, provenance, trust_state AS trustState
1156
1228
  FROM nodes WHERE id IN (SELECT value FROM json_each(?))`
1157
1229
  ).all(JSON.stringify(ids));
1158
1230
  }
@@ -1198,6 +1270,9 @@ function getSupersededIds(db, projectId) {
1198
1270
  function setSupersedes(db, newNodeId, staleNodeId) {
1199
1271
  db.prepare("UPDATE nodes SET supersedes = ? WHERE id = ?").run(staleNodeId, newNodeId);
1200
1272
  }
1273
+ function setTrustState(db, nodeId, state) {
1274
+ return db.prepare("UPDATE nodes SET trust_state = ? WHERE id = ?").run(state, nodeId).changes > 0;
1275
+ }
1201
1276
  function listStaleCandidates(db, projectId, opts = {}) {
1202
1277
  const now = opts.now ?? /* @__PURE__ */ new Date();
1203
1278
  const minAgeDays = opts.minAgeDays ?? 45;
@@ -1411,22 +1486,24 @@ function getEmbedding(db, nodeId) {
1411
1486
  if (!row) return null;
1412
1487
  return new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4);
1413
1488
  }
1414
- function upsertEmbedding(db, rowid, embedding) {
1415
- db.prepare("INSERT OR REPLACE INTO nodes_vec (rowid, embedding) VALUES (?, ?)").run(BigInt(rowid), embedding);
1489
+ function upsertEmbedding(db, rowid, projectId, embedding) {
1490
+ db.prepare("INSERT OR REPLACE INTO nodes_vec (rowid, project_id, embedding) VALUES (?, ?, ?)").run(BigInt(rowid), projectId, embedding);
1416
1491
  }
1417
1492
  function dropAllEmbeddings(db) {
1418
1493
  return db.prepare("DELETE FROM nodes_vec").run().changes;
1419
1494
  }
1420
- function vectorSearch(db, projectId, embedding, limit = 20) {
1421
- const overfetch = Math.max(limit * 8, 50);
1495
+ function vectorSearch(db, projectId, embedding, limit = 20, opts = {}) {
1496
+ const asOfEpoch = opts.asOfEpoch ?? null;
1497
+ const k = asOfEpoch === null ? limit : Math.max(limit * 8, 50);
1422
1498
  return db.prepare(
1423
- `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance, v.distance AS distance
1499
+ `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance, n.trust_state AS trustState, v.distance AS distance
1424
1500
  FROM nodes_vec v
1425
1501
  JOIN nodes n ON n.rowid = v.rowid
1426
- WHERE v.embedding MATCH ? AND k = ? AND n.project_id = ?
1502
+ WHERE v.embedding MATCH ? AND k = ? AND v.project_id = ?
1503
+ AND (? IS NULL OR n.created_at <= ?)
1427
1504
  ORDER BY v.distance
1428
1505
  LIMIT ?`
1429
- ).all(embedding, overfetch, projectId, limit);
1506
+ ).all(embedding, k, projectId, asOfEpoch, asOfEpoch, limit);
1430
1507
  }
1431
1508
 
1432
1509
  // src/store/fts.ts
@@ -1445,18 +1522,20 @@ function toMatchQuery(input) {
1445
1522
  }
1446
1523
 
1447
1524
  // src/store/search.ts
1448
- function search(db, projectId, query, limit = 20) {
1525
+ function search(db, projectId, query, limit = 20, opts = {}) {
1449
1526
  const match = toMatchQuery(query);
1450
1527
  if (!match) return [];
1528
+ const asOfEpoch = opts.asOfEpoch ?? null;
1451
1529
  const rows = db.prepare(
1452
- `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance,
1530
+ `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance, n.trust_state AS trustState,
1453
1531
  bm25(nodes_fts, 10.0, 1.0) AS rank
1454
1532
  FROM nodes_fts
1455
1533
  JOIN nodes n ON n.rowid = nodes_fts.rowid
1456
1534
  WHERE nodes_fts MATCH ? AND n.project_id = ?
1535
+ AND (? IS NULL OR n.created_at <= ?)
1457
1536
  ORDER BY rank
1458
1537
  LIMIT ?`
1459
- ).all(match, projectId, limit);
1538
+ ).all(match, projectId, asOfEpoch, asOfEpoch, limit);
1460
1539
  return rows;
1461
1540
  }
1462
1541
  function stats(db, projectId) {
@@ -1514,7 +1593,7 @@ function listContradictionSuggestions(db, projectId, opts = {}) {
1514
1593
  FROM contradiction_checks c
1515
1594
  JOIN nodes n ON n.id = c.candidate_id
1516
1595
  JOIN nodes a ON a.id = c.against_id
1517
- WHERE n.project_id = @projectId AND c.contradicts = 1
1596
+ WHERE n.project_id = @projectId AND c.contradicts = 1 AND c.dismissed = 0
1518
1597
  AND c.candidate_id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)
1519
1598
  ORDER BY c.checked_at DESC
1520
1599
  LIMIT @limit`
@@ -1525,11 +1604,19 @@ function countContradictionSuggestions(db, projectId) {
1525
1604
  `SELECT COUNT(*) AS count
1526
1605
  FROM contradiction_checks c
1527
1606
  JOIN nodes n ON n.id = c.candidate_id
1528
- WHERE n.project_id = @projectId AND c.contradicts = 1
1607
+ WHERE n.project_id = @projectId AND c.contradicts = 1 AND c.dismissed = 0
1529
1608
  AND c.candidate_id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)`
1530
1609
  ).get({ projectId });
1531
1610
  return row.count;
1532
1611
  }
1612
+ function dismissContradictionSuggestion(db, projectId, candidateId) {
1613
+ return db.prepare(
1614
+ `UPDATE contradiction_checks
1615
+ SET dismissed = 1
1616
+ WHERE candidate_id = @candidateId AND contradicts = 1 AND dismissed = 0
1617
+ AND candidate_id IN (SELECT id FROM nodes WHERE project_id = @projectId)`
1618
+ ).run({ projectId, candidateId }).changes;
1619
+ }
1533
1620
 
1534
1621
  // src/store/store.ts
1535
1622
  var MemoryStore = class _MemoryStore {
@@ -1687,8 +1774,8 @@ var MemoryStore = class _MemoryStore {
1687
1774
  countNodesNeedingEmbedding(projectId) {
1688
1775
  return countNodesNeedingEmbedding(this.db, projectId);
1689
1776
  }
1690
- upsertEmbedding(rowid, embedding) {
1691
- upsertEmbedding(this.db, rowid, embedding);
1777
+ upsertEmbedding(rowid, projectId, embedding) {
1778
+ upsertEmbedding(this.db, rowid, projectId, embedding);
1692
1779
  }
1693
1780
  /** The stored vector for one node, or null if it has not been embedded yet. */
1694
1781
  getEmbedding(nodeId) {
@@ -1703,14 +1790,14 @@ var MemoryStore = class _MemoryStore {
1703
1790
  setMeta(key, value) {
1704
1791
  setMeta(this.db, key, value);
1705
1792
  }
1706
- vectorSearch(projectId, embedding, limit = 20) {
1707
- return vectorSearch(this.db, projectId, embedding, limit);
1793
+ vectorSearch(projectId, embedding, limit = 20, opts = {}) {
1794
+ return vectorSearch(this.db, projectId, embedding, limit, opts);
1708
1795
  }
1709
1796
  stats(projectId) {
1710
1797
  return stats(this.db, projectId);
1711
1798
  }
1712
- search(projectId, query, limit = 20) {
1713
- return search(this.db, projectId, query, limit);
1799
+ search(projectId, query, limit = 20, opts = {}) {
1800
+ return search(this.db, projectId, query, limit, opts);
1714
1801
  }
1715
1802
  /** The project a node belongs to, or null if no node has this id. Used by `mark-stale` to validate both ids. */
1716
1803
  getNodeProjectId(id) {
@@ -1724,6 +1811,10 @@ var MemoryStore = class _MemoryStore {
1724
1811
  setSupersedes(newNodeId, staleNodeId) {
1725
1812
  setSupersedes(this.db, newNodeId, staleNodeId);
1726
1813
  }
1814
+ /** Record a human's verdict on one node -- the write behind `nexusmem review`. Caller validates project ownership first. */
1815
+ setTrustState(nodeId, state) {
1816
+ return setTrustState(this.db, nodeId, state);
1817
+ }
1727
1818
  /** Aging non-`observed` nodes nothing supersedes yet -- candidates for `nexusmem mark-stale`, not auto-applied. */
1728
1819
  listStaleCandidates(projectId, opts = {}) {
1729
1820
  return listStaleCandidates(this.db, projectId, opts);
@@ -1746,6 +1837,18 @@ var MemoryStore = class _MemoryStore {
1746
1837
  countContradictionSuggestions(projectId) {
1747
1838
  return countContradictionSuggestions(this.db, projectId);
1748
1839
  }
1840
+ /** Reject every open suggestion for this candidate; returns how many were actually dismissed. */
1841
+ dismissContradictionSuggestion(projectId, candidateId) {
1842
+ return dismissContradictionSuggestion(this.db, projectId, candidateId);
1843
+ }
1844
+ /** Record one `mutation_audit` row for a coarse/destructive operation outside `forget` (currently: `--prune-source`/`--prune-stale-shell`). */
1845
+ recordMutationAudit(input) {
1846
+ return recordMutationAudit(this.db, input);
1847
+ }
1848
+ /** Newest-first `mutation_audit` rows for this project -- every `forget` and `--prune-source` run, whether or not anything matched. */
1849
+ listMutationAudit(projectId, opts = {}) {
1850
+ return listMutationAudit(this.db, projectId, opts);
1851
+ }
1749
1852
  /** Escape hatch for tests and future modules. */
1750
1853
  get raw() {
1751
1854
  return this.db;
@@ -2252,7 +2355,7 @@ var DEFAULT_SUMMARY_CHARS = 320;
2252
2355
  var NODE_OVERHEAD_TOKENS = 8;
2253
2356
  var CONVERSATION_ANSWER_MARKER = "\n\nA: ";
2254
2357
  var MAX_PER_FAMILY = 2;
2255
- var CHUNKED_KINDS = /* @__PURE__ */ new Set(["conversation_turn", "doc_section"]);
2358
+ var CHUNKED_KINDS = /* @__PURE__ */ new Set(["conversation_turn", "doc_section", "code_diff"]);
2256
2359
  var HUNK_BOUNDARY = "\n@@ ";
2257
2360
  var STOPWORDS = /* @__PURE__ */ new Set([
2258
2361
  "the",
@@ -2395,6 +2498,7 @@ function packContext(ranked, tokensBudget, opts = {}) {
2395
2498
  summary,
2396
2499
  tokens,
2397
2500
  provenance: hit.provenance,
2501
+ trustState: hit.trustState,
2398
2502
  ...hit.project ? { project: hit.project } : {}
2399
2503
  });
2400
2504
  tokensUsed += tokens;
@@ -2408,7 +2512,8 @@ function renderContextBlock(query, result) {
2408
2512
  for (const node of result.nodes) {
2409
2513
  const project = node.project ? `[${node.project}] ` : "";
2410
2514
  const provenance = `[${node.provenance}] `;
2411
- lines.push(`- ${node.ts.slice(0, 10)} ${provenance}${project}${node.title}`);
2515
+ const trust = node.trustState !== "candidate" ? `[${node.trustState}] ` : "";
2516
+ lines.push(`- ${node.ts.slice(0, 10)} ${provenance}${trust}${project}${node.title}`);
2412
2517
  if (node.summary && node.summary !== node.title) {
2413
2518
  if (node.kind === "code_diff") {
2414
2519
  for (const line of node.summary.split("\n")) lines.push(` ${line}`);
@@ -2546,6 +2651,7 @@ function mergeSearchAndVectorHits(bm25Hits, vectorHits) {
2546
2651
  body: hit.body,
2547
2652
  signal: hit.signal,
2548
2653
  provenance: hit.provenance,
2654
+ trustState: hit.trustState,
2549
2655
  rank: 0
2550
2656
  });
2551
2657
  }
@@ -2565,7 +2671,8 @@ var HALF_LIFE_RATIO = {
2565
2671
  derived: 0.35
2566
2672
  };
2567
2673
  var SUPERSEDED_PENALTY = 0.5;
2568
- var MAX_PRIOR_OVERTURN = 2;
2674
+ var REJECTED_TRUST_PENALTY = 0.3;
2675
+ var MAX_PRIOR_OVERTURN = 2.4;
2569
2676
  var PRIOR_COUNT = 2;
2570
2677
  var PER_PRIOR_OVERTURN = MAX_PRIOR_OVERTURN ** (1 / PRIOR_COUNT);
2571
2678
  var SIGNAL_EXPONENT = Math.log(PER_PRIOR_OVERTURN) / Math.log(1 / SIGNAL_FLOOR);
@@ -2608,7 +2715,8 @@ function rankHits(hits, opts = {}) {
2608
2715
  const effectiveHalfLife = halfLife * (ratios[hit.provenance] ?? 1);
2609
2716
  const recencyFactor = RECENCY_FLOOR + (1 - RECENCY_FLOOR) * 2 ** (-ageDays / effectiveHalfLife);
2610
2717
  const rawScore = relevance * signalWeight ** SIGNAL_EXPONENT * recencyFactor ** RECENCY_EXPONENT;
2611
- const score = opts.supersededIds?.has(hit.id) ? rawScore * SUPERSEDED_PENALTY : rawScore;
2718
+ const supersededScore = opts.supersededIds?.has(hit.id) ? rawScore * SUPERSEDED_PENALTY : rawScore;
2719
+ const score = hit.trustState === "rejected" ? supersededScore * REJECTED_TRUST_PENALTY : supersededScore;
2612
2720
  return { ...hit, relevance, signalWeight, ageDays, recencyFactor, score };
2613
2721
  });
2614
2722
  return ranked.sort((a, b) => b.score - a.score);
@@ -2638,6 +2746,7 @@ function pullLinkedResolutions(resolveStore, ranked) {
2638
2746
  body: resolution.body,
2639
2747
  signal: resolution.signal,
2640
2748
  provenance: resolution.provenance,
2749
+ trustState: resolution.trustState,
2641
2750
  rank: 0,
2642
2751
  // no bm25/vector rank of its own -- never read again past this point
2643
2752
  relevance: hit.relevance,
@@ -2662,8 +2771,8 @@ async function runCrossProjectQuery(sources, query, opts) {
2662
2771
  const supersededIds = /* @__PURE__ */ new Set();
2663
2772
  for (const source of sources) {
2664
2773
  const label = (hit) => ({ ...hit, project: source.label });
2665
- const bm25Hits = source.store.search(source.projectId, query, opts.candidates).map(label);
2666
- const vectorHits = queryVector ? source.store.vectorSearch(source.projectId, queryVector, opts.candidates) : [];
2774
+ const bm25Hits = source.store.search(source.projectId, query, opts.candidates, { asOfEpoch: opts.asOfEpoch }).map(label);
2775
+ const vectorHits = queryVector ? source.store.vectorSearch(source.projectId, queryVector, opts.candidates, { asOfEpoch: opts.asOfEpoch }) : [];
2667
2776
  bm25Count += bm25Hits.length;
2668
2777
  vectorCount += vectorHits.length;
2669
2778
  perProject.push({ label: source.label, bm25: bm25Hits.length, vector: vectorHits.length });
@@ -2684,11 +2793,11 @@ async function runCrossProjectQuery(sources, query, opts) {
2684
2793
  return { bm25Count, vectorCount, hits, packed, perProject };
2685
2794
  }
2686
2795
  async function runHybridQuery(store, projectId, query, opts) {
2687
- const bm25Hits = store.search(projectId, query, opts.candidates);
2796
+ const bm25Hits = store.search(projectId, query, opts.candidates, { asOfEpoch: opts.asOfEpoch });
2688
2797
  let vectorHits = [];
2689
2798
  if (opts.embeddingProvider) {
2690
2799
  const queryVector = await opts.embeddingProvider.embed(query);
2691
- if (queryVector) vectorHits = store.vectorSearch(projectId, queryVector, opts.candidates);
2800
+ if (queryVector) vectorHits = store.vectorSearch(projectId, queryVector, opts.candidates, { asOfEpoch: opts.asOfEpoch });
2692
2801
  }
2693
2802
  const hits = vectorHits.length > 0 ? mergeSearchAndVectorHits(bm25Hits, vectorHits) : bm25Hits;
2694
2803
  const relevanceScores = vectorHits.length > 0 ? reciprocalRankFusion([bm25Hits, vectorHits]) : void 0;
@@ -3386,11 +3495,13 @@ function isGeneratedPath(path) {
3386
3495
  return GENERATED_PATHS.some((re) => re.test(path));
3387
3496
  }
3388
3497
  var TEST_PATHS = /(^|\/)(tests?|__tests__|spec|e2e)\/|\.(test|spec)\.[cm]?[jt]sx?$/;
3498
+ var MANIFEST_PATHS = /(^|\/)(package\.json|tsconfig(\..+)?\.json)$/;
3389
3499
  function scoreFileDiff(subject, file) {
3390
3500
  const header = parseConventionalHeader(subject);
3391
3501
  let score = header.type ? TYPE_WEIGHTS[header.type] ?? 0.5 : 0.5;
3392
3502
  if (header.breaking) score += 0.1;
3393
3503
  if (TEST_PATHS.test(file.path)) score -= 0.1;
3504
+ if (MANIFEST_PATHS.test(file.path)) score -= 0.1;
3394
3505
  if (file.status === "added") score += 0.05;
3395
3506
  if (file.status === "deleted") score -= 0.1;
3396
3507
  const churn = file.insertions + file.deletions;
@@ -4849,7 +4960,7 @@ ${node.body}`)
4849
4960
  for (const [index, node] of group.entries()) {
4850
4961
  const vector = vectors[index];
4851
4962
  if (vector && vector.length === provider.dimension) {
4852
- store.upsertEmbedding(node.rowid, vector);
4963
+ store.upsertEmbedding(node.rowid, projectId, vector);
4853
4964
  embedded += 1;
4854
4965
  embeddedHere += 1;
4855
4966
  } else {
@@ -5118,8 +5229,18 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
5118
5229
  );
5119
5230
  return 0;
5120
5231
  }
5232
+ const startedAt = Date.now();
5121
5233
  let removed = 0;
5122
5234
  for (const { source, id } of counts) removed += store.pruneSourceNodes(id, source, []);
5235
+ store.recordMutationAudit({
5236
+ action: "prune_source",
5237
+ projectId,
5238
+ detail: { sources, scopeProjectIds: scopeIds },
5239
+ affectedCount: removed,
5240
+ succeeded: true,
5241
+ startedAt,
5242
+ finishedAt: Date.now()
5243
+ });
5123
5244
  const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
5124
5245
  out(`${pc7.green("pruned")} ${removed} node(s) across ${sources.length} source(s)${identityPart}
5125
5246
  `);
@@ -5262,10 +5383,17 @@ async function searchMemory(input) {
5262
5383
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5263
5384
  const budget = input.budget ?? 2e3;
5264
5385
  const candidates = input.candidates ?? 30;
5386
+ let asOfEpoch;
5387
+ if (input.asOf) {
5388
+ const parsed = Date.parse(input.asOf);
5389
+ if (Number.isNaN(parsed)) throw new Error(`asOf "${input.asOf}" is not a parseable date`);
5390
+ asOfEpoch = parsed;
5391
+ }
5265
5392
  const queryOpts = {
5266
5393
  budget,
5267
5394
  candidates,
5268
- embeddingProvider: input.noVector ? null : new OllamaEmbeddingProvider()
5395
+ embeddingProvider: input.noVector ? null : new OllamaEmbeddingProvider(),
5396
+ asOfEpoch
5269
5397
  };
5270
5398
  if (input.allProjects) {
5271
5399
  const opened = await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath });
@@ -5361,11 +5489,14 @@ function createServer() {
5361
5489
  budget: z4.number().int().positive().optional().describe("Max tokens in the returned context block. Default 2000."),
5362
5490
  allProjects: z4.boolean().optional().describe(
5363
5491
  "Search every repository NexusMem has been run in on this machine, not just projectRoot. Use when the answer may live in a different project (a pattern solved elsewhere, a tool that failed the same way before). Each result is tagged with its repository."
5492
+ ),
5493
+ asOf: z4.string().optional().describe(
5494
+ 'ISO-8601 date/time. Restricts results to nodes recorded at or before this instant -- "what did memory hold as of then", not "what happened then". Omit for the normal, unrestricted read.'
5364
5495
  )
5365
5496
  }
5366
5497
  },
5367
- async ({ projectRoot, query, budget, allProjects }) => {
5368
- const result = await searchMemory({ projectRoot, query, budget, allProjects });
5498
+ async ({ projectRoot, query, budget, allProjects, asOf }) => {
5499
+ const result = await searchMemory({ projectRoot, query, budget, allProjects, asOf });
5369
5500
  return {
5370
5501
  content: [{ type: "text", text: result.text }],
5371
5502
  structuredContent: {
@@ -5550,8 +5681,20 @@ ${pc8.dim("-".repeat(40))}
5550
5681
 
5551
5682
  // src/cli/commands/query.ts
5552
5683
  import pc9 from "picocolors";
5684
+ var QueryError = class extends Error {
5685
+ constructor(message) {
5686
+ super(message);
5687
+ this.name = "QueryError";
5688
+ }
5689
+ };
5553
5690
  async function runQuery(opts) {
5554
5691
  const { repo, ws, projectId } = await loadContext(opts.cwd);
5692
+ let asOfEpoch;
5693
+ if (opts.asOf) {
5694
+ const parsed = Date.parse(opts.asOf);
5695
+ if (Number.isNaN(parsed)) throw new QueryError(`--as-of "${opts.asOf}" is not a parseable date`);
5696
+ asOfEpoch = parsed;
5697
+ }
5555
5698
  const opened = opts.allProjects ? await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath }) : null;
5556
5699
  let store = null;
5557
5700
  try {
@@ -5559,7 +5702,8 @@ async function runQuery(opts) {
5559
5702
  budget: opts.budget,
5560
5703
  candidates: opts.candidates,
5561
5704
  halfLifeDays: opts.halfLifeDays,
5562
- embeddingProvider: opts.noVector ? null : new OllamaEmbeddingProvider()
5705
+ embeddingProvider: opts.noVector ? null : new OllamaEmbeddingProvider(),
5706
+ asOfEpoch
5563
5707
  };
5564
5708
  let result;
5565
5709
  if (opened) {
@@ -5569,6 +5713,10 @@ async function runQuery(opts) {
5569
5713
  result = await runHybridQuery(store, projectId, opts.query, queryOpts);
5570
5714
  }
5571
5715
  const { bm25Count, vectorCount, hits, packed } = result;
5716
+ if (asOfEpoch !== void 0 && !opts.json) {
5717
+ process.stderr.write(`${pc9.dim("as of ")} ${new Date(asOfEpoch).toISOString()} -- excludes anything recorded after
5718
+ `);
5719
+ }
5572
5720
  if (opened && !opts.json) {
5573
5721
  const searched = opened.sources.map((s) => s.label).join(", ");
5574
5722
  process.stderr.write(`${pc9.dim("scope ")} ${opened.sources.length} project(s): ${searched}
@@ -5630,11 +5778,45 @@ async function runQuery(opts) {
5630
5778
  }
5631
5779
  }
5632
5780
 
5781
+ // src/cli/commands/review.ts
5782
+ import pc10 from "picocolors";
5783
+ var ReviewError = class extends Error {
5784
+ constructor(message) {
5785
+ super(message);
5786
+ this.name = "ReviewError";
5787
+ }
5788
+ };
5789
+ async function runReview(opts) {
5790
+ const { projectId, ws } = await loadContext(opts.cwd);
5791
+ const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
5792
+ const store = MemoryStore.open(ws.dbPath);
5793
+ try {
5794
+ const owner = store.getNodeProjectId(opts.nodeId);
5795
+ if (owner === null) {
5796
+ throw new ReviewError(`no node found with id ${opts.nodeId}`);
5797
+ }
5798
+ if (owner !== projectId) {
5799
+ throw new ReviewError("node must belong to the current project");
5800
+ }
5801
+ store.setTrustState(opts.nodeId, opts.verdict);
5802
+ const verb = opts.verdict === "verified" ? "verified" : "rejected";
5803
+ out(
5804
+ `${pc10.green(verb)} ${opts.nodeId}
5805
+ ` + (opts.verdict === "rejected" ? `${pc10.dim("down-weighted in ranking")} -- the node stays queryable, just ranked lower
5806
+ ` : `${pc10.dim("labeled only")} -- verifying does not change ranking
5807
+ `)
5808
+ );
5809
+ return 0;
5810
+ } finally {
5811
+ store.close();
5812
+ }
5813
+ }
5814
+
5633
5815
  // src/cli/commands/scan-conversation.ts
5634
- import pc11 from "picocolors";
5816
+ import pc12 from "picocolors";
5635
5817
 
5636
5818
  // src/cli/format.ts
5637
- import pc10 from "picocolors";
5819
+ import pc11 from "picocolors";
5638
5820
  var GIT_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
5639
5821
  var SHELL_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
5640
5822
  var CONVERSATION_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
@@ -5646,9 +5828,9 @@ function signalBand(signal, bands) {
5646
5828
  return "low";
5647
5829
  }
5648
5830
  var BAND_COLOR = {
5649
- high: pc10.green,
5650
- medium: pc10.yellow,
5651
- low: pc10.dim
5831
+ high: pc11.green,
5832
+ medium: pc11.yellow,
5833
+ low: pc11.dim
5652
5834
  };
5653
5835
  function formatSignal(signal, bands) {
5654
5836
  return BAND_COLOR[signalBand(signal, bands)](signal.toFixed(2));
@@ -5657,7 +5839,7 @@ function approxTotalTokens(nodes) {
5657
5839
  return nodes.reduce((n, x) => n + approxTokens(x.body), 0);
5658
5840
  }
5659
5841
  function summarize2(nodes) {
5660
- if (nodes.length === 0) return pc10.yellow("no commits matched");
5842
+ if (nodes.length === 0) return pc11.yellow("no commits matched");
5661
5843
  const timestamps = nodes.map((n) => n.ts).sort();
5662
5844
  const avgSignal = nodes.reduce((n, x) => n + x.signal, 0) / nodes.length;
5663
5845
  const totalTokens = approxTotalTokens(nodes);
@@ -5667,7 +5849,7 @@ function summarize2(nodes) {
5667
5849
  }
5668
5850
  const hottest = [...fileHits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([path, count]) => ` ${String(count).padStart(3)}x ${path}`);
5669
5851
  return [
5670
- `${pc10.bold(String(nodes.length))} nodes ${pc10.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
5852
+ `${pc11.bold(String(nodes.length))} nodes ${pc11.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
5671
5853
  ` avg signal ${avgSignal.toFixed(3)} ~${totalTokens.toLocaleString()} tokens if sent raw`,
5672
5854
  hottest.length ? ` hottest files:
5673
5855
  ${hottest.join("\n")}` : ""
@@ -5681,9 +5863,9 @@ async function runScanConversation(opts) {
5681
5863
  const files = await listTranscriptFiles(repo.root);
5682
5864
  if (!opts.json) {
5683
5865
  process.stderr.write(
5684
- files.length ? `${pc11.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
5866
+ files.length ? `${pc12.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
5685
5867
 
5686
- ` : `${pc11.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
5868
+ ` : `${pc12.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
5687
5869
  `
5688
5870
  );
5689
5871
  }
@@ -5700,7 +5882,7 @@ async function runScanConversation(opts) {
5700
5882
  const approxTotal = approxTotalTokens(nodes);
5701
5883
  process.stderr.write(
5702
5884
  `
5703
- ${pc11.bold(String(nodes.length))} of ${turns.length} exchange(s) above threshold ${pc11.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}` + (redactedTotal > 0 ? ` ${pc11.yellow(`${redactedTotal} secret-like value(s) redacted`)}` : "") + "\n"
5885
+ ${pc12.bold(String(nodes.length))} of ${turns.length} exchange(s) above threshold ${pc12.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}` + (redactedTotal > 0 ? ` ${pc12.yellow(`${redactedTotal} secret-like value(s) redacted`)}` : "") + "\n"
5704
5886
  );
5705
5887
  return 0;
5706
5888
  }
@@ -5709,7 +5891,7 @@ function formatNode(node) {
5709
5891
  }
5710
5892
 
5711
5893
  // src/cli/commands/scan-diff.ts
5712
- import pc12 from "picocolors";
5894
+ import pc13 from "picocolors";
5713
5895
  var DEFAULT_SCAN_COMMITS = 50;
5714
5896
  async function runScanDiff(opts) {
5715
5897
  const repo = await readRepoInfo(opts.cwd);
@@ -5717,9 +5899,9 @@ async function runScanDiff(opts) {
5717
5899
  if (!opts.json) {
5718
5900
  process.stderr.write(
5719
5901
  [
5720
- `${pc12.dim("repo ")} ${repo.root}`,
5721
- `${pc12.dim("branch ")} ${repo.branch ?? pc12.yellow("(detached)")}`,
5722
- `${pc12.dim("project")} ${pc12.cyan(projectId)}`,
5902
+ `${pc13.dim("repo ")} ${repo.root}`,
5903
+ `${pc13.dim("branch ")} ${repo.branch ?? pc13.yellow("(detached)")}`,
5904
+ `${pc13.dim("project")} ${pc13.cyan(projectId)}`,
5723
5905
  ""
5724
5906
  ].join("\n")
5725
5907
  );
@@ -5749,28 +5931,28 @@ function formatNode2(node) {
5749
5931
  const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
5750
5932
  return [
5751
5933
  formatSignal(node.signal, DIFF_SIGNAL_BANDS),
5752
- pc12.dim(node.ts.slice(0, 10)),
5753
- pc12.magenta(sha),
5934
+ pc13.dim(node.ts.slice(0, 10)),
5935
+ pc13.magenta(sha),
5754
5936
  String(node.meta.path ?? ""),
5755
- pc12.dim(`(${churn}, ${node.meta.hunkCount ?? 0} hunk(s))`)
5937
+ pc13.dim(`(${churn}, ${node.meta.hunkCount ?? 0} hunk(s))`)
5756
5938
  ].join(" ");
5757
5939
  }
5758
5940
 
5759
5941
  // src/cli/commands/scan-docs.ts
5760
- import pc13 from "picocolors";
5942
+ import pc14 from "picocolors";
5761
5943
  async function runScanDocs(opts) {
5762
5944
  const repo = await readRepoInfo(opts.cwd);
5763
5945
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5764
5946
  const { files, unreadable } = await readDocFiles(repo.root);
5765
5947
  if (!opts.json) {
5766
5948
  process.stderr.write(
5767
- files.length ? `${pc13.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
5949
+ files.length ? `${pc14.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
5768
5950
 
5769
- ` : `${pc13.yellow("no tracked .md files found")}
5951
+ ` : `${pc14.yellow("no tracked .md files found")}
5770
5952
  `
5771
5953
  );
5772
5954
  if (unreadable.length > 0) {
5773
- process.stderr.write(`${pc13.yellow("unreadable")} ${unreadable.join(", ")}
5955
+ process.stderr.write(`${pc14.yellow("unreadable")} ${unreadable.join(", ")}
5774
5956
 
5775
5957
  `);
5776
5958
  }
@@ -5786,7 +5968,7 @@ async function runScanDocs(opts) {
5786
5968
  const approxTotal = approxTotalTokens(nodes);
5787
5969
  process.stderr.write(
5788
5970
  `
5789
- ${pc13.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc13.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
5971
+ ${pc14.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc14.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
5790
5972
  `
5791
5973
  );
5792
5974
  return 0;
@@ -5796,17 +5978,17 @@ function formatNode3(node) {
5796
5978
  }
5797
5979
 
5798
5980
  // src/cli/commands/scan-git.ts
5799
- import pc14 from "picocolors";
5981
+ import pc15 from "picocolors";
5800
5982
  async function runScanGit(opts) {
5801
5983
  const repo = await readRepoInfo(opts.cwd);
5802
5984
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5803
5985
  if (!opts.json) {
5804
5986
  process.stderr.write(
5805
5987
  [
5806
- `${pc14.dim("repo ")} ${repo.root}`,
5807
- `${pc14.dim("branch ")} ${repo.branch ?? pc14.yellow("(detached)")}`,
5808
- `${pc14.dim("origin ")} ${repo.originUrl ?? pc14.dim("(none)")}`,
5809
- `${pc14.dim("project")} ${pc14.cyan(projectId)}`,
5988
+ `${pc15.dim("repo ")} ${repo.root}`,
5989
+ `${pc15.dim("branch ")} ${repo.branch ?? pc15.yellow("(detached)")}`,
5990
+ `${pc15.dim("origin ")} ${repo.originUrl ?? pc15.dim("(none)")}`,
5991
+ `${pc15.dim("project")} ${pc15.cyan(projectId)}`,
5810
5992
  ""
5811
5993
  ].join("\n")
5812
5994
  );
@@ -5840,21 +6022,21 @@ function formatNode4(node) {
5840
6022
  const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
5841
6023
  return [
5842
6024
  formatSignal(node.signal, GIT_SIGNAL_BANDS),
5843
- pc14.dim(date),
5844
- pc14.magenta(sha),
6025
+ pc15.dim(date),
6026
+ pc15.magenta(sha),
5845
6027
  node.title,
5846
- pc14.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
6028
+ pc15.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
5847
6029
  ].join(" ");
5848
6030
  }
5849
6031
 
5850
6032
  // src/cli/commands/scan-session.ts
5851
- import pc15 from "picocolors";
6033
+ import pc16 from "picocolors";
5852
6034
  async function runScanSession(opts) {
5853
6035
  const repo = await readRepoInfo(opts.cwd);
5854
6036
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5855
6037
  const turns = await collectClaudeCodeTranscripts(repo.root);
5856
6038
  if (turns.length === 0) {
5857
- process.stderr.write(`${pc15.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
6039
+ process.stderr.write(`${pc16.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
5858
6040
  `);
5859
6041
  return 0;
5860
6042
  }
@@ -5862,7 +6044,7 @@ async function runScanSession(opts) {
5862
6044
  const settled = selectSettledSessions(sessions, opts.settleMinutes);
5863
6045
  if (!opts.json) {
5864
6046
  process.stderr.write(
5865
- `${pc15.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
6047
+ `${pc16.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
5866
6048
 
5867
6049
  `
5868
6050
  );
@@ -5888,7 +6070,7 @@ async function runScanSession(opts) {
5888
6070
  }
5889
6071
  for (const preview of previews) {
5890
6072
  process.stdout.write(
5891
- `${pc15.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
6073
+ `${pc16.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
5892
6074
  ${preview.prompt}
5893
6075
 
5894
6076
  `
@@ -5900,7 +6082,7 @@ ${preview.prompt}
5900
6082
  settleMinutes: opts.settleMinutes,
5901
6083
  maxSessions: opts.maxSessions,
5902
6084
  onProgress: (done, total) => {
5903
- if (!opts.json) process.stderr.write(` ${pc15.dim(`summarizing ${done}/${total}`)}
6085
+ if (!opts.json) process.stderr.write(` ${pc16.dim(`summarizing ${done}/${total}`)}
5904
6086
  `);
5905
6087
  }
5906
6088
  });
@@ -5910,21 +6092,21 @@ ${preview.prompt}
5910
6092
  return 0;
5911
6093
  }
5912
6094
  for (const node of result.nodes) {
5913
- process.stdout.write(`${pc15.bold(node.title)}
5914
- ${pc15.dim(node.ts.slice(0, 16).replace("T", " "))}
6095
+ process.stdout.write(`${pc16.bold(node.title)}
6096
+ ${pc16.dim(node.ts.slice(0, 16).replace("T", " "))}
5915
6097
  ${node.body}
5916
6098
 
5917
6099
  `);
5918
6100
  }
5919
6101
  if (result.providerUnavailable) {
5920
6102
  process.stderr.write(
5921
- `${pc15.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
6103
+ `${pc16.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
5922
6104
  `
5923
6105
  );
5924
6106
  return 0;
5925
6107
  }
5926
6108
  process.stderr.write(
5927
- `${pc15.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc15.yellow(`${result.failed} failed`)}` : "") + ` ${pc15.dim(`(model ${opts.model})`)}
6109
+ `${pc16.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc16.yellow(`${result.failed} failed`)}` : "") + ` ${pc16.dim(`(model ${opts.model})`)}
5928
6110
  `
5929
6111
  );
5930
6112
  return 0;
@@ -5932,16 +6114,16 @@ ${node.body}
5932
6114
  var SCAN_SESSION_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
5933
6115
 
5934
6116
  // src/cli/commands/scan-shell.ts
5935
- import pc16 from "picocolors";
6117
+ import pc17 from "picocolors";
5936
6118
  async function runScanShell(opts) {
5937
6119
  const repo = await readRepoInfo(opts.cwd);
5938
6120
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5939
6121
  const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
5940
6122
  if (!opts.json) {
5941
6123
  process.stderr.write(
5942
- results.length ? `${pc16.dim("sources found")} ${results.map((r) => r.name).join(", ")}
6124
+ results.length ? `${pc17.dim("sources found")} ${results.map((r) => r.name).join(", ")}
5943
6125
 
5944
- ` : `${pc16.yellow("no shell history source found on this machine")}
6126
+ ` : `${pc17.yellow("no shell history source found on this machine")}
5945
6127
  `
5946
6128
  );
5947
6129
  }
@@ -5950,7 +6132,7 @@ async function runScanShell(opts) {
5950
6132
  const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
5951
6133
  allNodes.push(...nodes);
5952
6134
  if (!opts.json) {
5953
- process.stdout.write(`${pc16.bold(`shell:${result.name}`)} ${pc16.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
6135
+ process.stdout.write(`${pc17.bold(`shell:${result.name}`)} ${pc17.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
5954
6136
  `);
5955
6137
  for (const node of nodes) process.stdout.write(`${formatNode5(node)}
5956
6138
  `);
@@ -5963,19 +6145,19 @@ async function runScanShell(opts) {
5963
6145
  return 0;
5964
6146
  }
5965
6147
  const approxTotal = approxTotalTokens(allNodes);
5966
- process.stderr.write(`${pc16.bold(String(allNodes.length))} node(s) total ${pc16.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
6148
+ process.stderr.write(`${pc17.bold(String(allNodes.length))} node(s) total ${pc17.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
5967
6149
  `);
5968
6150
  return 0;
5969
6151
  }
5970
6152
  function formatNode5(node) {
5971
- const approx = node.meta.tsApprox ? pc16.dim("~") : " ";
6153
+ const approx = node.meta.tsApprox ? pc17.dim("~") : " ";
5972
6154
  const exit = node.meta.exitCode;
5973
- const exitLabel = typeof exit === "number" && exit !== 0 ? pc16.red(`exit ${exit}`) : "";
6155
+ const exitLabel = typeof exit === "number" && exit !== 0 ? pc17.red(`exit ${exit}`) : "";
5974
6156
  return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
5975
6157
  }
5976
6158
 
5977
6159
  // src/cli/commands/scan-structure.ts
5978
- import pc17 from "picocolors";
6160
+ import pc18 from "picocolors";
5979
6161
  var TRACKED_EXTENSIONS = TRACKED_PATHSPECS.map((p) => p.replace("*", "")).join("/");
5980
6162
  async function runScanStructure(opts) {
5981
6163
  const repo = await readRepoInfo(opts.cwd);
@@ -5986,33 +6168,42 @@ async function runScanStructure(opts) {
5986
6168
  return 0;
5987
6169
  }
5988
6170
  if (unreadable.length > 0) {
5989
- process.stderr.write(`${pc17.yellow("unreadable")} ${unreadable.join(", ")}
6171
+ process.stderr.write(`${pc18.yellow("unreadable")} ${unreadable.join(", ")}
5990
6172
 
5991
6173
  `);
5992
6174
  }
5993
6175
  for (const edge of edges) {
5994
- process.stdout.write(`${edge.fromPath} ${pc17.dim("->")} ${edge.toPath}
6176
+ process.stdout.write(`${edge.fromPath} ${pc18.dim("->")} ${edge.toPath}
5995
6177
  `);
5996
6178
  }
5997
6179
  process.stderr.write(
5998
6180
  `
5999
- ${pc17.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKED_EXTENSIONS} file(s)
6181
+ ${pc18.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKED_EXTENSIONS} file(s)
6000
6182
  `
6001
6183
  );
6002
6184
  return 0;
6003
6185
  }
6004
6186
 
6005
6187
  // src/cli/commands/stale.ts
6006
- import pc18 from "picocolors";
6188
+ import pc19 from "picocolors";
6007
6189
  var STALE_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
6008
6190
  async function runStale(opts) {
6009
6191
  const { projectId, ws } = await loadContext(opts.cwd);
6010
6192
  const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
6011
6193
  const store = MemoryStore.open(ws.dbPath);
6012
6194
  try {
6195
+ if (opts.dismiss) {
6196
+ const dismissed = store.dismissContradictionSuggestion(projectId, opts.dismiss);
6197
+ out(
6198
+ dismissed > 0 ? `${pc19.green("dismissed")} the contradiction suggestion for ${opts.dismiss} -- it will not resurface
6199
+ ` : `${pc19.dim("no open contradiction suggestion")} for ${opts.dismiss} -- nothing to dismiss
6200
+ `
6201
+ );
6202
+ return 0;
6203
+ }
6013
6204
  const candidates = store.listStaleCandidates(projectId, { minAgeDays: opts.minAgeDays, limit: opts.limit });
6014
6205
  if (candidates.length === 0) {
6015
- out(`${pc18.dim("no stale candidates")} -- no unconfirmed node older than the threshold lacks a successor
6206
+ out(`${pc19.dim("no stale candidates")} -- no unconfirmed node older than the threshold lacks a successor
6016
6207
  `);
6017
6208
  return 0;
6018
6209
  }
@@ -6034,15 +6225,15 @@ async function runStale(opts) {
6034
6225
  for (const s of suggestions) byCandidateId.set(s.candidateId, s);
6035
6226
  out(
6036
6227
  [
6037
- `${pc18.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
6228
+ `${pc19.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
6038
6229
  ...candidates.map((c) => {
6039
- const line = ` ${pc18.dim(c.id)} ${pc18.yellow(`${c.ageDays}d old`)} [${c.kind}] ${c.title}`;
6230
+ const line = ` ${pc19.dim(c.id)} ${pc19.yellow(`${c.ageDays}d old`)} [${c.kind}] ${c.title}`;
6040
6231
  const hit = byCandidateId.get(c.id);
6041
6232
  return hit ? `${line}
6042
- ${pc18.red("likely superseded by")} ${pc18.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
6233
+ ${pc19.red("likely superseded by")} ${pc19.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
6043
6234
  }),
6044
6235
  "",
6045
- `run ${pc18.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
6236
+ `run ${pc19.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
6046
6237
  ].join("\n").concat("\n")
6047
6238
  );
6048
6239
  return 0;
@@ -6054,7 +6245,7 @@ async function runStale(opts) {
6054
6245
  // src/cli/commands/status.ts
6055
6246
  import { basename as basename4 } from "path";
6056
6247
  import { statSync } from "fs";
6057
- import pc19 from "picocolors";
6248
+ import pc20 from "picocolors";
6058
6249
  function daySpan(oldest, newest) {
6059
6250
  const oldestDay = Date.parse(oldest.slice(0, 10));
6060
6251
  const newestDay = Date.parse(newest.slice(0, 10));
@@ -6114,34 +6305,34 @@ async function runStatus(opts) {
6114
6305
  const flaggedCount = store.countContradictionSuggestions(projectId);
6115
6306
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
6116
6307
  const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
6117
- const staleProjectWarning = otherProjectIds.length ? `${pc19.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc19.bold(
6308
+ const staleProjectWarning = otherProjectIds.length ? `${pc20.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc20.bold(
6118
6309
  "nexusmem sync --prune-source <name>"
6119
6310
  )} to remove stale source data` : "";
6120
6311
  out(
6121
6312
  [
6122
- `${pc19.dim("repo ")} ${repo.root}`,
6123
- `${pc19.dim("branch ")} ${repo.branch ?? pc19.yellow("(detached)")}`,
6124
- `${pc19.dim("project ")} ${pc19.cyan(projectId)}`,
6125
- `${pc19.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc19.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
6126
- `${pc19.dim("database")} ${ws.dbPath} ${pc19.dim(`(${humanBytes(dbBytes)})`)}`,
6313
+ `${pc20.dim("repo ")} ${repo.root}`,
6314
+ `${pc20.dim("branch ")} ${repo.branch ?? pc20.yellow("(detached)")}`,
6315
+ `${pc20.dim("project ")} ${pc20.cyan(projectId)}`,
6316
+ `${pc20.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc20.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
6317
+ `${pc20.dim("database")} ${ws.dbPath} ${pc20.dim(`(${humanBytes(dbBytes)})`)}`,
6127
6318
  staleProjectWarning,
6128
6319
  "",
6129
- `${pc19.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc19.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
6320
+ `${pc20.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc20.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
6130
6321
  ...kinds,
6131
- stats2.total ? ` ${pc19.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
6322
+ stats2.total ? ` ${pc20.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
6132
6323
  "",
6133
- sources.length ? pc19.dim("sources") : pc19.yellow("no sources synced yet"),
6324
+ sources.length ? pc20.dim("sources") : pc20.yellow("no sources synced yet"),
6134
6325
  ...sources.map((s) => {
6135
6326
  const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
6136
6327
  const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
6137
- return ` ${s.source.padEnd(14)} ${pc19.dim(`last run ${when}`)} ${pc19.dim(`cursor ${cursorLabel}`)}`;
6328
+ return ` ${s.source.padEnd(14)} ${pc20.dim(`last run ${when}`)} ${pc20.dim(`cursor ${cursorLabel}`)}`;
6138
6329
  }),
6139
6330
  "",
6140
- gitCursor && gitCursor !== repo.head ? `${pc19.yellow("git behind HEAD")} \u2014 run ${pc19.bold("nexusmem sync")}` : "",
6141
- chains.failuresTotal ? `${pc19.dim("chains ")} ${pc19.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc19.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc19.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
6142
- structure.edges ? `${pc19.dim("structure")} ${pc19.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
6143
- staleCount ? `${pc19.dim("aging ")} ${pc19.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc19.bold("nexusmem stale")}` : "",
6144
- flaggedCount ? `${pc19.dim("flagged ")} ${pc19.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc19.bold("nexusmem stale")} for detail` : ""
6331
+ gitCursor && gitCursor !== repo.head ? `${pc20.yellow("git behind HEAD")} \u2014 run ${pc20.bold("nexusmem sync")}` : "",
6332
+ chains.failuresTotal ? `${pc20.dim("chains ")} ${pc20.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc20.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc20.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
6333
+ structure.edges ? `${pc20.dim("structure")} ${pc20.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
6334
+ staleCount ? `${pc20.dim("aging ")} ${pc20.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc20.bold("nexusmem stale")}` : "",
6335
+ flaggedCount ? `${pc20.dim("flagged ")} ${pc20.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc20.bold("nexusmem stale")} for detail` : ""
6145
6336
  ].filter((line) => line !== "").join("\n").concat("\n")
6146
6337
  );
6147
6338
  return 0;
@@ -6156,7 +6347,7 @@ function isExpected(err) {
6156
6347
  // the user fixes, not stack traces they debug.
6157
6348
  err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
6158
6349
  // (antivirus, a bad install). Actionable, and not our stack to print.
6159
- err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof DenyListError || err instanceof MarkStaleError;
6350
+ err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof DenyListError || err instanceof MarkStaleError || err instanceof QueryError || err instanceof ReviewError;
6160
6351
  }
6161
6352
  function guard(run) {
6162
6353
  return async () => {
@@ -6164,7 +6355,7 @@ function guard(run) {
6164
6355
  process.exitCode = await run();
6165
6356
  } catch (err) {
6166
6357
  if (isExpected(err)) {
6167
- process.stderr.write(`${pc20.red("error")} ${err.message}
6358
+ process.stderr.write(`${pc21.red("error")} ${err.message}
6168
6359
  `);
6169
6360
  process.exitCode = 1;
6170
6361
  return;
@@ -6227,7 +6418,7 @@ program.command("hook").description("Manage the opt-in PowerShell hook that logs
6227
6418
  )
6228
6419
  );
6229
6420
  program.command("status").description("Show what is currently remembered for this repository").option("-C, --cwd <path>", "repository path", process.cwd()).option("--share", "print a plain-text summary formatted for sharing, e.g. on X or Reddit").action((options) => guard(() => runStatus({ cwd: options.cwd, share: options.share }))());
6230
- program.command("query").description("Search remembered history and print a token-budgeted context block").argument("<text>", "free-text query").option("-C, --cwd <path>", "repository path", process.cwd()).option("-b, --budget <tokens>", "max tokens in the packed context", (v) => Number.parseInt(v, 10), 2e3).option("-n, --candidates <count>", "how many search hits to rank before packing", (v) => Number.parseInt(v, 10), 30).option("--half-life <days>", "days for a node's recency weight to halve", (v) => Number.parseFloat(v)).option("--no-vector", "BM25 only -- skip embedding the query and vector search").option("-a, --all-projects", "search every registered repository, not just this one", false).option("--json", "emit the packed result as JSON on stdout", false).action(
6421
+ program.command("query").description("Search remembered history and print a token-budgeted context block").argument("<text>", "free-text query").option("-C, --cwd <path>", "repository path", process.cwd()).option("-b, --budget <tokens>", "max tokens in the packed context", (v) => Number.parseInt(v, 10), 2e3).option("-n, --candidates <count>", "how many search hits to rank before packing", (v) => Number.parseInt(v, 10), 30).option("--half-life <days>", "days for a node's recency weight to halve", (v) => Number.parseFloat(v)).option("--no-vector", "BM25 only -- skip embedding the query and vector search").option("-a, --all-projects", "search every registered repository, not just this one", false).option("--as-of <date>", 'bi-temporal read: only nodes recorded at or before this date -- "what did the store hold then", not "what happened then"').option("--json", "emit the packed result as JSON on stdout", false).action(
6231
6422
  (text, options) => guard(
6232
6423
  () => runQuery({
6233
6424
  cwd: options.cwd,
@@ -6237,6 +6428,7 @@ program.command("query").description("Search remembered history and print a toke
6237
6428
  halfLifeDays: options.halfLife,
6238
6429
  noVector: !options.vector,
6239
6430
  allProjects: options.allProjects,
6431
+ asOf: options.asOf,
6240
6432
  json: options.json
6241
6433
  })
6242
6434
  )()
@@ -6266,17 +6458,26 @@ program.command("mark-stale").description(
6266
6458
  program.command("stale").description("List unconfirmed (non-observed) nodes old enough to be worth double-checking (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--min-age-days <days>", "only nodes at least this old", (v) => Number.parseFloat(v)).option("-n, --limit <count>", "stop after N candidates", (v) => Number.parseInt(v, 10)).option(
6267
6459
  "--check-contradictions",
6268
6460
  "ask the local SLM whether a similar newer node actually contradicts each candidate (needs Ollama)"
6269
- ).option("--model <name>", "Ollama chat model for --check-contradictions", STALE_DEFAULT_MODEL).action(
6461
+ ).option("--model <name>", "Ollama chat model for --check-contradictions", STALE_DEFAULT_MODEL).option("--dismiss <candidateId>", "reject the open contradiction suggestion for this node id so it stops resurfacing").action(
6270
6462
  (options) => guard(
6271
6463
  () => runStale({
6272
6464
  cwd: options.cwd,
6273
6465
  minAgeDays: options.minAgeDays,
6274
6466
  limit: options.limit,
6275
6467
  checkContradictions: options.checkContradictions,
6276
- model: options.model
6468
+ model: options.model,
6469
+ dismiss: options.dismiss
6277
6470
  })
6278
6471
  )()
6279
6472
  );
6473
+ program.command("review").description("Record a human verdict on one node: --verify or --reject (a rejected node is down-weighted in ranking, never deleted)").argument("<nodeId>", "id of the node being reviewed").option("-C, --cwd <path>", "repository path", process.cwd()).option("--verify", "mark the node verified (label only, no ranking change)", false).option("--reject", "mark the node rejected (down-weighted in ranking, still queryable)", false).action(
6474
+ (nodeId, options) => guard(async () => {
6475
+ if (options.verify === options.reject) {
6476
+ throw new ReviewError("pass exactly one of --verify or --reject");
6477
+ }
6478
+ return runReview({ cwd: options.cwd, nodeId, verdict: options.verify ? "verified" : "rejected" });
6479
+ })()
6480
+ );
6280
6481
  program.command("projects").description("List the repositories `query --all-projects` would search").option("--prune", "forget registered projects whose database is no longer on disk", false).option("--json", "emit the registry as JSON on stdout", false).action((options) => guard(() => runProjects({ prune: options.prune, json: options.json }))());
6281
6482
  program.command("scan-git").description("Preview the MemoryNodes git history would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--since <date>", "only commits newer than this git date expression, e.g. 90.days.ago").option("-n, --limit <count>", "stop after N commits", (v) => Number.parseInt(v, 10)).option("--no-merges", "skip merge commits").option("--min-signal <score>", "drop nodes below this signal", (v) => Number.parseFloat(v), 0).option("--json", "emit MemoryNodes as JSON on stdout", false).action(
6282
6483
  (options) => guard(
@@ -6337,7 +6538,7 @@ program.command("scan-structure").description("Preview the JS/TS/Python/Go/Rust/
6337
6538
  program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
6338
6539
  program.parseAsync(process.argv).catch((err) => {
6339
6540
  const message = err instanceof Error ? err.message : String(err);
6340
- process.stderr.write(`${pc20.red("error")} ${message}
6541
+ process.stderr.write(`${pc21.red("error")} ${message}
6341
6542
  `);
6342
6543
  process.exitCode = 1;
6343
6544
  });