nexusmem 0.8.0 → 0.9.0

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,13 @@ 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
+ `;
977
998
  var MIGRATIONS = [
978
999
  { version: 1, up: (db) => db.exec(V1) },
979
1000
  { version: 2, up: (db) => db.exec(V2) },
@@ -982,7 +1003,9 @@ var MIGRATIONS = [
982
1003
  { version: 5, up: (db) => db.exec(V5) },
983
1004
  { version: 6, up: (db) => db.exec(V6) },
984
1005
  { version: 7, up: (db) => db.exec(V7) },
985
- { version: 8, up: (db) => db.exec(V8) }
1006
+ { version: 8, up: (db) => db.exec(V8) },
1007
+ { version: 9, up: (db) => db.exec(V9) },
1008
+ { version: 10, up: (db) => db.exec(V10) }
986
1009
  ];
987
1010
  var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
988
1011
  function currentSchemaVersion(db) {
@@ -1000,6 +1023,36 @@ function migrate(db) {
1000
1023
  return { from, to: currentSchemaVersion(db) };
1001
1024
  }
1002
1025
 
1026
+ // src/store/audit.ts
1027
+ function recordMutationAudit(db, input) {
1028
+ return Number(
1029
+ db.prepare(
1030
+ `INSERT INTO mutation_audit (action, project_id, detail, affected_count, succeeded, error, started_at, finished_at)
1031
+ VALUES (@action, @projectId, @detail, @affectedCount, @succeeded, @error, @startedAt, @finishedAt)`
1032
+ ).run({
1033
+ action: input.action,
1034
+ projectId: input.projectId,
1035
+ detail: JSON.stringify(input.detail),
1036
+ affectedCount: input.affectedCount,
1037
+ succeeded: input.succeeded ? 1 : 0,
1038
+ error: input.error ?? null,
1039
+ startedAt: input.startedAt,
1040
+ finishedAt: input.finishedAt
1041
+ }).lastInsertRowid
1042
+ );
1043
+ }
1044
+ function listMutationAudit(db, projectId, opts = {}) {
1045
+ const rows = db.prepare(
1046
+ `SELECT id, action, project_id AS projectId, detail, affected_count AS affectedCount,
1047
+ succeeded, error, started_at AS startedAt, finished_at AS finishedAt
1048
+ FROM mutation_audit
1049
+ WHERE project_id = @projectId
1050
+ ORDER BY started_at DESC
1051
+ LIMIT @limit`
1052
+ ).all({ projectId, limit: opts.limit ?? 50 });
1053
+ return rows.map((r) => ({ ...r, succeeded: r.succeeded === 1 }));
1054
+ }
1055
+
1003
1056
  // src/store/projects.ts
1004
1057
  function upsertProject(db, project) {
1005
1058
  db.prepare(
@@ -1152,7 +1205,7 @@ function clearProject(db, projectId) {
1152
1205
  function getNodesByIds(db, ids) {
1153
1206
  if (ids.length === 0) return [];
1154
1207
  return db.prepare(
1155
- `SELECT id, kind, project_id AS projectId, ts, title, body, signal, provenance
1208
+ `SELECT id, kind, project_id AS projectId, ts, title, body, signal, provenance, trust_state AS trustState
1156
1209
  FROM nodes WHERE id IN (SELECT value FROM json_each(?))`
1157
1210
  ).all(JSON.stringify(ids));
1158
1211
  }
@@ -1198,6 +1251,9 @@ function getSupersededIds(db, projectId) {
1198
1251
  function setSupersedes(db, newNodeId, staleNodeId) {
1199
1252
  db.prepare("UPDATE nodes SET supersedes = ? WHERE id = ?").run(staleNodeId, newNodeId);
1200
1253
  }
1254
+ function setTrustState(db, nodeId, state) {
1255
+ return db.prepare("UPDATE nodes SET trust_state = ? WHERE id = ?").run(state, nodeId).changes > 0;
1256
+ }
1201
1257
  function listStaleCandidates(db, projectId, opts = {}) {
1202
1258
  const now = opts.now ?? /* @__PURE__ */ new Date();
1203
1259
  const minAgeDays = opts.minAgeDays ?? 45;
@@ -1417,16 +1473,18 @@ function upsertEmbedding(db, rowid, embedding) {
1417
1473
  function dropAllEmbeddings(db) {
1418
1474
  return db.prepare("DELETE FROM nodes_vec").run().changes;
1419
1475
  }
1420
- function vectorSearch(db, projectId, embedding, limit = 20) {
1476
+ function vectorSearch(db, projectId, embedding, limit = 20, opts = {}) {
1421
1477
  const overfetch = Math.max(limit * 8, 50);
1478
+ const asOfEpoch = opts.asOfEpoch ?? null;
1422
1479
  return db.prepare(
1423
- `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance, v.distance AS distance
1480
+ `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
1481
  FROM nodes_vec v
1425
1482
  JOIN nodes n ON n.rowid = v.rowid
1426
1483
  WHERE v.embedding MATCH ? AND k = ? AND n.project_id = ?
1484
+ AND (? IS NULL OR n.created_at <= ?)
1427
1485
  ORDER BY v.distance
1428
1486
  LIMIT ?`
1429
- ).all(embedding, overfetch, projectId, limit);
1487
+ ).all(embedding, overfetch, projectId, asOfEpoch, asOfEpoch, limit);
1430
1488
  }
1431
1489
 
1432
1490
  // src/store/fts.ts
@@ -1445,18 +1503,20 @@ function toMatchQuery(input) {
1445
1503
  }
1446
1504
 
1447
1505
  // src/store/search.ts
1448
- function search(db, projectId, query, limit = 20) {
1506
+ function search(db, projectId, query, limit = 20, opts = {}) {
1449
1507
  const match = toMatchQuery(query);
1450
1508
  if (!match) return [];
1509
+ const asOfEpoch = opts.asOfEpoch ?? null;
1451
1510
  const rows = db.prepare(
1452
- `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance,
1511
+ `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance, n.trust_state AS trustState,
1453
1512
  bm25(nodes_fts, 10.0, 1.0) AS rank
1454
1513
  FROM nodes_fts
1455
1514
  JOIN nodes n ON n.rowid = nodes_fts.rowid
1456
1515
  WHERE nodes_fts MATCH ? AND n.project_id = ?
1516
+ AND (? IS NULL OR n.created_at <= ?)
1457
1517
  ORDER BY rank
1458
1518
  LIMIT ?`
1459
- ).all(match, projectId, limit);
1519
+ ).all(match, projectId, asOfEpoch, asOfEpoch, limit);
1460
1520
  return rows;
1461
1521
  }
1462
1522
  function stats(db, projectId) {
@@ -1514,7 +1574,7 @@ function listContradictionSuggestions(db, projectId, opts = {}) {
1514
1574
  FROM contradiction_checks c
1515
1575
  JOIN nodes n ON n.id = c.candidate_id
1516
1576
  JOIN nodes a ON a.id = c.against_id
1517
- WHERE n.project_id = @projectId AND c.contradicts = 1
1577
+ WHERE n.project_id = @projectId AND c.contradicts = 1 AND c.dismissed = 0
1518
1578
  AND c.candidate_id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)
1519
1579
  ORDER BY c.checked_at DESC
1520
1580
  LIMIT @limit`
@@ -1525,11 +1585,19 @@ function countContradictionSuggestions(db, projectId) {
1525
1585
  `SELECT COUNT(*) AS count
1526
1586
  FROM contradiction_checks c
1527
1587
  JOIN nodes n ON n.id = c.candidate_id
1528
- WHERE n.project_id = @projectId AND c.contradicts = 1
1588
+ WHERE n.project_id = @projectId AND c.contradicts = 1 AND c.dismissed = 0
1529
1589
  AND c.candidate_id NOT IN (SELECT supersedes FROM nodes WHERE project_id = @projectId AND supersedes IS NOT NULL)`
1530
1590
  ).get({ projectId });
1531
1591
  return row.count;
1532
1592
  }
1593
+ function dismissContradictionSuggestion(db, projectId, candidateId) {
1594
+ return db.prepare(
1595
+ `UPDATE contradiction_checks
1596
+ SET dismissed = 1
1597
+ WHERE candidate_id = @candidateId AND contradicts = 1 AND dismissed = 0
1598
+ AND candidate_id IN (SELECT id FROM nodes WHERE project_id = @projectId)`
1599
+ ).run({ projectId, candidateId }).changes;
1600
+ }
1533
1601
 
1534
1602
  // src/store/store.ts
1535
1603
  var MemoryStore = class _MemoryStore {
@@ -1703,14 +1771,14 @@ var MemoryStore = class _MemoryStore {
1703
1771
  setMeta(key, value) {
1704
1772
  setMeta(this.db, key, value);
1705
1773
  }
1706
- vectorSearch(projectId, embedding, limit = 20) {
1707
- return vectorSearch(this.db, projectId, embedding, limit);
1774
+ vectorSearch(projectId, embedding, limit = 20, opts = {}) {
1775
+ return vectorSearch(this.db, projectId, embedding, limit, opts);
1708
1776
  }
1709
1777
  stats(projectId) {
1710
1778
  return stats(this.db, projectId);
1711
1779
  }
1712
- search(projectId, query, limit = 20) {
1713
- return search(this.db, projectId, query, limit);
1780
+ search(projectId, query, limit = 20, opts = {}) {
1781
+ return search(this.db, projectId, query, limit, opts);
1714
1782
  }
1715
1783
  /** The project a node belongs to, or null if no node has this id. Used by `mark-stale` to validate both ids. */
1716
1784
  getNodeProjectId(id) {
@@ -1724,6 +1792,10 @@ var MemoryStore = class _MemoryStore {
1724
1792
  setSupersedes(newNodeId, staleNodeId) {
1725
1793
  setSupersedes(this.db, newNodeId, staleNodeId);
1726
1794
  }
1795
+ /** Record a human's verdict on one node -- the write behind `nexusmem review`. Caller validates project ownership first. */
1796
+ setTrustState(nodeId, state) {
1797
+ return setTrustState(this.db, nodeId, state);
1798
+ }
1727
1799
  /** Aging non-`observed` nodes nothing supersedes yet -- candidates for `nexusmem mark-stale`, not auto-applied. */
1728
1800
  listStaleCandidates(projectId, opts = {}) {
1729
1801
  return listStaleCandidates(this.db, projectId, opts);
@@ -1746,6 +1818,18 @@ var MemoryStore = class _MemoryStore {
1746
1818
  countContradictionSuggestions(projectId) {
1747
1819
  return countContradictionSuggestions(this.db, projectId);
1748
1820
  }
1821
+ /** Reject every open suggestion for this candidate; returns how many were actually dismissed. */
1822
+ dismissContradictionSuggestion(projectId, candidateId) {
1823
+ return dismissContradictionSuggestion(this.db, projectId, candidateId);
1824
+ }
1825
+ /** Record one `mutation_audit` row for a coarse/destructive operation outside `forget` (currently: `--prune-source`/`--prune-stale-shell`). */
1826
+ recordMutationAudit(input) {
1827
+ return recordMutationAudit(this.db, input);
1828
+ }
1829
+ /** Newest-first `mutation_audit` rows for this project -- every `forget` and `--prune-source` run, whether or not anything matched. */
1830
+ listMutationAudit(projectId, opts = {}) {
1831
+ return listMutationAudit(this.db, projectId, opts);
1832
+ }
1749
1833
  /** Escape hatch for tests and future modules. */
1750
1834
  get raw() {
1751
1835
  return this.db;
@@ -2395,6 +2479,7 @@ function packContext(ranked, tokensBudget, opts = {}) {
2395
2479
  summary,
2396
2480
  tokens,
2397
2481
  provenance: hit.provenance,
2482
+ trustState: hit.trustState,
2398
2483
  ...hit.project ? { project: hit.project } : {}
2399
2484
  });
2400
2485
  tokensUsed += tokens;
@@ -2408,7 +2493,8 @@ function renderContextBlock(query, result) {
2408
2493
  for (const node of result.nodes) {
2409
2494
  const project = node.project ? `[${node.project}] ` : "";
2410
2495
  const provenance = `[${node.provenance}] `;
2411
- lines.push(`- ${node.ts.slice(0, 10)} ${provenance}${project}${node.title}`);
2496
+ const trust = node.trustState !== "candidate" ? `[${node.trustState}] ` : "";
2497
+ lines.push(`- ${node.ts.slice(0, 10)} ${provenance}${trust}${project}${node.title}`);
2412
2498
  if (node.summary && node.summary !== node.title) {
2413
2499
  if (node.kind === "code_diff") {
2414
2500
  for (const line of node.summary.split("\n")) lines.push(` ${line}`);
@@ -2546,6 +2632,7 @@ function mergeSearchAndVectorHits(bm25Hits, vectorHits) {
2546
2632
  body: hit.body,
2547
2633
  signal: hit.signal,
2548
2634
  provenance: hit.provenance,
2635
+ trustState: hit.trustState,
2549
2636
  rank: 0
2550
2637
  });
2551
2638
  }
@@ -2565,6 +2652,7 @@ var HALF_LIFE_RATIO = {
2565
2652
  derived: 0.35
2566
2653
  };
2567
2654
  var SUPERSEDED_PENALTY = 0.5;
2655
+ var REJECTED_TRUST_PENALTY = 0.3;
2568
2656
  var MAX_PRIOR_OVERTURN = 2;
2569
2657
  var PRIOR_COUNT = 2;
2570
2658
  var PER_PRIOR_OVERTURN = MAX_PRIOR_OVERTURN ** (1 / PRIOR_COUNT);
@@ -2608,7 +2696,8 @@ function rankHits(hits, opts = {}) {
2608
2696
  const effectiveHalfLife = halfLife * (ratios[hit.provenance] ?? 1);
2609
2697
  const recencyFactor = RECENCY_FLOOR + (1 - RECENCY_FLOOR) * 2 ** (-ageDays / effectiveHalfLife);
2610
2698
  const rawScore = relevance * signalWeight ** SIGNAL_EXPONENT * recencyFactor ** RECENCY_EXPONENT;
2611
- const score = opts.supersededIds?.has(hit.id) ? rawScore * SUPERSEDED_PENALTY : rawScore;
2699
+ const supersededScore = opts.supersededIds?.has(hit.id) ? rawScore * SUPERSEDED_PENALTY : rawScore;
2700
+ const score = hit.trustState === "rejected" ? supersededScore * REJECTED_TRUST_PENALTY : supersededScore;
2612
2701
  return { ...hit, relevance, signalWeight, ageDays, recencyFactor, score };
2613
2702
  });
2614
2703
  return ranked.sort((a, b) => b.score - a.score);
@@ -2638,6 +2727,7 @@ function pullLinkedResolutions(resolveStore, ranked) {
2638
2727
  body: resolution.body,
2639
2728
  signal: resolution.signal,
2640
2729
  provenance: resolution.provenance,
2730
+ trustState: resolution.trustState,
2641
2731
  rank: 0,
2642
2732
  // no bm25/vector rank of its own -- never read again past this point
2643
2733
  relevance: hit.relevance,
@@ -2662,8 +2752,8 @@ async function runCrossProjectQuery(sources, query, opts) {
2662
2752
  const supersededIds = /* @__PURE__ */ new Set();
2663
2753
  for (const source of sources) {
2664
2754
  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) : [];
2755
+ const bm25Hits = source.store.search(source.projectId, query, opts.candidates, { asOfEpoch: opts.asOfEpoch }).map(label);
2756
+ const vectorHits = queryVector ? source.store.vectorSearch(source.projectId, queryVector, opts.candidates, { asOfEpoch: opts.asOfEpoch }) : [];
2667
2757
  bm25Count += bm25Hits.length;
2668
2758
  vectorCount += vectorHits.length;
2669
2759
  perProject.push({ label: source.label, bm25: bm25Hits.length, vector: vectorHits.length });
@@ -2684,11 +2774,11 @@ async function runCrossProjectQuery(sources, query, opts) {
2684
2774
  return { bm25Count, vectorCount, hits, packed, perProject };
2685
2775
  }
2686
2776
  async function runHybridQuery(store, projectId, query, opts) {
2687
- const bm25Hits = store.search(projectId, query, opts.candidates);
2777
+ const bm25Hits = store.search(projectId, query, opts.candidates, { asOfEpoch: opts.asOfEpoch });
2688
2778
  let vectorHits = [];
2689
2779
  if (opts.embeddingProvider) {
2690
2780
  const queryVector = await opts.embeddingProvider.embed(query);
2691
- if (queryVector) vectorHits = store.vectorSearch(projectId, queryVector, opts.candidates);
2781
+ if (queryVector) vectorHits = store.vectorSearch(projectId, queryVector, opts.candidates, { asOfEpoch: opts.asOfEpoch });
2692
2782
  }
2693
2783
  const hits = vectorHits.length > 0 ? mergeSearchAndVectorHits(bm25Hits, vectorHits) : bm25Hits;
2694
2784
  const relevanceScores = vectorHits.length > 0 ? reciprocalRankFusion([bm25Hits, vectorHits]) : void 0;
@@ -5118,8 +5208,18 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
5118
5208
  );
5119
5209
  return 0;
5120
5210
  }
5211
+ const startedAt = Date.now();
5121
5212
  let removed = 0;
5122
5213
  for (const { source, id } of counts) removed += store.pruneSourceNodes(id, source, []);
5214
+ store.recordMutationAudit({
5215
+ action: "prune_source",
5216
+ projectId,
5217
+ detail: { sources, scopeProjectIds: scopeIds },
5218
+ affectedCount: removed,
5219
+ succeeded: true,
5220
+ startedAt,
5221
+ finishedAt: Date.now()
5222
+ });
5123
5223
  const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
5124
5224
  out(`${pc7.green("pruned")} ${removed} node(s) across ${sources.length} source(s)${identityPart}
5125
5225
  `);
@@ -5262,10 +5362,17 @@ async function searchMemory(input) {
5262
5362
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5263
5363
  const budget = input.budget ?? 2e3;
5264
5364
  const candidates = input.candidates ?? 30;
5365
+ let asOfEpoch;
5366
+ if (input.asOf) {
5367
+ const parsed = Date.parse(input.asOf);
5368
+ if (Number.isNaN(parsed)) throw new Error(`asOf "${input.asOf}" is not a parseable date`);
5369
+ asOfEpoch = parsed;
5370
+ }
5265
5371
  const queryOpts = {
5266
5372
  budget,
5267
5373
  candidates,
5268
- embeddingProvider: input.noVector ? null : new OllamaEmbeddingProvider()
5374
+ embeddingProvider: input.noVector ? null : new OllamaEmbeddingProvider(),
5375
+ asOfEpoch
5269
5376
  };
5270
5377
  if (input.allProjects) {
5271
5378
  const opened = await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath });
@@ -5361,11 +5468,14 @@ function createServer() {
5361
5468
  budget: z4.number().int().positive().optional().describe("Max tokens in the returned context block. Default 2000."),
5362
5469
  allProjects: z4.boolean().optional().describe(
5363
5470
  "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."
5471
+ ),
5472
+ asOf: z4.string().optional().describe(
5473
+ '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
5474
  )
5365
5475
  }
5366
5476
  },
5367
- async ({ projectRoot, query, budget, allProjects }) => {
5368
- const result = await searchMemory({ projectRoot, query, budget, allProjects });
5477
+ async ({ projectRoot, query, budget, allProjects, asOf }) => {
5478
+ const result = await searchMemory({ projectRoot, query, budget, allProjects, asOf });
5369
5479
  return {
5370
5480
  content: [{ type: "text", text: result.text }],
5371
5481
  structuredContent: {
@@ -5550,8 +5660,20 @@ ${pc8.dim("-".repeat(40))}
5550
5660
 
5551
5661
  // src/cli/commands/query.ts
5552
5662
  import pc9 from "picocolors";
5663
+ var QueryError = class extends Error {
5664
+ constructor(message) {
5665
+ super(message);
5666
+ this.name = "QueryError";
5667
+ }
5668
+ };
5553
5669
  async function runQuery(opts) {
5554
5670
  const { repo, ws, projectId } = await loadContext(opts.cwd);
5671
+ let asOfEpoch;
5672
+ if (opts.asOf) {
5673
+ const parsed = Date.parse(opts.asOf);
5674
+ if (Number.isNaN(parsed)) throw new QueryError(`--as-of "${opts.asOf}" is not a parseable date`);
5675
+ asOfEpoch = parsed;
5676
+ }
5555
5677
  const opened = opts.allProjects ? await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath }) : null;
5556
5678
  let store = null;
5557
5679
  try {
@@ -5559,7 +5681,8 @@ async function runQuery(opts) {
5559
5681
  budget: opts.budget,
5560
5682
  candidates: opts.candidates,
5561
5683
  halfLifeDays: opts.halfLifeDays,
5562
- embeddingProvider: opts.noVector ? null : new OllamaEmbeddingProvider()
5684
+ embeddingProvider: opts.noVector ? null : new OllamaEmbeddingProvider(),
5685
+ asOfEpoch
5563
5686
  };
5564
5687
  let result;
5565
5688
  if (opened) {
@@ -5569,6 +5692,10 @@ async function runQuery(opts) {
5569
5692
  result = await runHybridQuery(store, projectId, opts.query, queryOpts);
5570
5693
  }
5571
5694
  const { bm25Count, vectorCount, hits, packed } = result;
5695
+ if (asOfEpoch !== void 0 && !opts.json) {
5696
+ process.stderr.write(`${pc9.dim("as of ")} ${new Date(asOfEpoch).toISOString()} -- excludes anything recorded after
5697
+ `);
5698
+ }
5572
5699
  if (opened && !opts.json) {
5573
5700
  const searched = opened.sources.map((s) => s.label).join(", ");
5574
5701
  process.stderr.write(`${pc9.dim("scope ")} ${opened.sources.length} project(s): ${searched}
@@ -5630,11 +5757,45 @@ async function runQuery(opts) {
5630
5757
  }
5631
5758
  }
5632
5759
 
5760
+ // src/cli/commands/review.ts
5761
+ import pc10 from "picocolors";
5762
+ var ReviewError = class extends Error {
5763
+ constructor(message) {
5764
+ super(message);
5765
+ this.name = "ReviewError";
5766
+ }
5767
+ };
5768
+ async function runReview(opts) {
5769
+ const { projectId, ws } = await loadContext(opts.cwd);
5770
+ const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
5771
+ const store = MemoryStore.open(ws.dbPath);
5772
+ try {
5773
+ const owner = store.getNodeProjectId(opts.nodeId);
5774
+ if (owner === null) {
5775
+ throw new ReviewError(`no node found with id ${opts.nodeId}`);
5776
+ }
5777
+ if (owner !== projectId) {
5778
+ throw new ReviewError("node must belong to the current project");
5779
+ }
5780
+ store.setTrustState(opts.nodeId, opts.verdict);
5781
+ const verb = opts.verdict === "verified" ? "verified" : "rejected";
5782
+ out(
5783
+ `${pc10.green(verb)} ${opts.nodeId}
5784
+ ` + (opts.verdict === "rejected" ? `${pc10.dim("down-weighted in ranking")} -- the node stays queryable, just ranked lower
5785
+ ` : `${pc10.dim("labeled only")} -- verifying does not change ranking
5786
+ `)
5787
+ );
5788
+ return 0;
5789
+ } finally {
5790
+ store.close();
5791
+ }
5792
+ }
5793
+
5633
5794
  // src/cli/commands/scan-conversation.ts
5634
- import pc11 from "picocolors";
5795
+ import pc12 from "picocolors";
5635
5796
 
5636
5797
  // src/cli/format.ts
5637
- import pc10 from "picocolors";
5798
+ import pc11 from "picocolors";
5638
5799
  var GIT_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
5639
5800
  var SHELL_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
5640
5801
  var CONVERSATION_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
@@ -5646,9 +5807,9 @@ function signalBand(signal, bands) {
5646
5807
  return "low";
5647
5808
  }
5648
5809
  var BAND_COLOR = {
5649
- high: pc10.green,
5650
- medium: pc10.yellow,
5651
- low: pc10.dim
5810
+ high: pc11.green,
5811
+ medium: pc11.yellow,
5812
+ low: pc11.dim
5652
5813
  };
5653
5814
  function formatSignal(signal, bands) {
5654
5815
  return BAND_COLOR[signalBand(signal, bands)](signal.toFixed(2));
@@ -5657,7 +5818,7 @@ function approxTotalTokens(nodes) {
5657
5818
  return nodes.reduce((n, x) => n + approxTokens(x.body), 0);
5658
5819
  }
5659
5820
  function summarize2(nodes) {
5660
- if (nodes.length === 0) return pc10.yellow("no commits matched");
5821
+ if (nodes.length === 0) return pc11.yellow("no commits matched");
5661
5822
  const timestamps = nodes.map((n) => n.ts).sort();
5662
5823
  const avgSignal = nodes.reduce((n, x) => n + x.signal, 0) / nodes.length;
5663
5824
  const totalTokens = approxTotalTokens(nodes);
@@ -5667,7 +5828,7 @@ function summarize2(nodes) {
5667
5828
  }
5668
5829
  const hottest = [...fileHits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([path, count]) => ` ${String(count).padStart(3)}x ${path}`);
5669
5830
  return [
5670
- `${pc10.bold(String(nodes.length))} nodes ${pc10.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
5831
+ `${pc11.bold(String(nodes.length))} nodes ${pc11.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
5671
5832
  ` avg signal ${avgSignal.toFixed(3)} ~${totalTokens.toLocaleString()} tokens if sent raw`,
5672
5833
  hottest.length ? ` hottest files:
5673
5834
  ${hottest.join("\n")}` : ""
@@ -5681,9 +5842,9 @@ async function runScanConversation(opts) {
5681
5842
  const files = await listTranscriptFiles(repo.root);
5682
5843
  if (!opts.json) {
5683
5844
  process.stderr.write(
5684
- files.length ? `${pc11.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
5845
+ files.length ? `${pc12.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
5685
5846
 
5686
- ` : `${pc11.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
5847
+ ` : `${pc12.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
5687
5848
  `
5688
5849
  );
5689
5850
  }
@@ -5700,7 +5861,7 @@ async function runScanConversation(opts) {
5700
5861
  const approxTotal = approxTotalTokens(nodes);
5701
5862
  process.stderr.write(
5702
5863
  `
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"
5864
+ ${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
5865
  );
5705
5866
  return 0;
5706
5867
  }
@@ -5709,7 +5870,7 @@ function formatNode(node) {
5709
5870
  }
5710
5871
 
5711
5872
  // src/cli/commands/scan-diff.ts
5712
- import pc12 from "picocolors";
5873
+ import pc13 from "picocolors";
5713
5874
  var DEFAULT_SCAN_COMMITS = 50;
5714
5875
  async function runScanDiff(opts) {
5715
5876
  const repo = await readRepoInfo(opts.cwd);
@@ -5717,9 +5878,9 @@ async function runScanDiff(opts) {
5717
5878
  if (!opts.json) {
5718
5879
  process.stderr.write(
5719
5880
  [
5720
- `${pc12.dim("repo ")} ${repo.root}`,
5721
- `${pc12.dim("branch ")} ${repo.branch ?? pc12.yellow("(detached)")}`,
5722
- `${pc12.dim("project")} ${pc12.cyan(projectId)}`,
5881
+ `${pc13.dim("repo ")} ${repo.root}`,
5882
+ `${pc13.dim("branch ")} ${repo.branch ?? pc13.yellow("(detached)")}`,
5883
+ `${pc13.dim("project")} ${pc13.cyan(projectId)}`,
5723
5884
  ""
5724
5885
  ].join("\n")
5725
5886
  );
@@ -5749,28 +5910,28 @@ function formatNode2(node) {
5749
5910
  const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
5750
5911
  return [
5751
5912
  formatSignal(node.signal, DIFF_SIGNAL_BANDS),
5752
- pc12.dim(node.ts.slice(0, 10)),
5753
- pc12.magenta(sha),
5913
+ pc13.dim(node.ts.slice(0, 10)),
5914
+ pc13.magenta(sha),
5754
5915
  String(node.meta.path ?? ""),
5755
- pc12.dim(`(${churn}, ${node.meta.hunkCount ?? 0} hunk(s))`)
5916
+ pc13.dim(`(${churn}, ${node.meta.hunkCount ?? 0} hunk(s))`)
5756
5917
  ].join(" ");
5757
5918
  }
5758
5919
 
5759
5920
  // src/cli/commands/scan-docs.ts
5760
- import pc13 from "picocolors";
5921
+ import pc14 from "picocolors";
5761
5922
  async function runScanDocs(opts) {
5762
5923
  const repo = await readRepoInfo(opts.cwd);
5763
5924
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5764
5925
  const { files, unreadable } = await readDocFiles(repo.root);
5765
5926
  if (!opts.json) {
5766
5927
  process.stderr.write(
5767
- files.length ? `${pc13.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
5928
+ files.length ? `${pc14.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
5768
5929
 
5769
- ` : `${pc13.yellow("no tracked .md files found")}
5930
+ ` : `${pc14.yellow("no tracked .md files found")}
5770
5931
  `
5771
5932
  );
5772
5933
  if (unreadable.length > 0) {
5773
- process.stderr.write(`${pc13.yellow("unreadable")} ${unreadable.join(", ")}
5934
+ process.stderr.write(`${pc14.yellow("unreadable")} ${unreadable.join(", ")}
5774
5935
 
5775
5936
  `);
5776
5937
  }
@@ -5786,7 +5947,7 @@ async function runScanDocs(opts) {
5786
5947
  const approxTotal = approxTotalTokens(nodes);
5787
5948
  process.stderr.write(
5788
5949
  `
5789
- ${pc13.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc13.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
5950
+ ${pc14.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc14.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
5790
5951
  `
5791
5952
  );
5792
5953
  return 0;
@@ -5796,17 +5957,17 @@ function formatNode3(node) {
5796
5957
  }
5797
5958
 
5798
5959
  // src/cli/commands/scan-git.ts
5799
- import pc14 from "picocolors";
5960
+ import pc15 from "picocolors";
5800
5961
  async function runScanGit(opts) {
5801
5962
  const repo = await readRepoInfo(opts.cwd);
5802
5963
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5803
5964
  if (!opts.json) {
5804
5965
  process.stderr.write(
5805
5966
  [
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)}`,
5967
+ `${pc15.dim("repo ")} ${repo.root}`,
5968
+ `${pc15.dim("branch ")} ${repo.branch ?? pc15.yellow("(detached)")}`,
5969
+ `${pc15.dim("origin ")} ${repo.originUrl ?? pc15.dim("(none)")}`,
5970
+ `${pc15.dim("project")} ${pc15.cyan(projectId)}`,
5810
5971
  ""
5811
5972
  ].join("\n")
5812
5973
  );
@@ -5840,21 +6001,21 @@ function formatNode4(node) {
5840
6001
  const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
5841
6002
  return [
5842
6003
  formatSignal(node.signal, GIT_SIGNAL_BANDS),
5843
- pc14.dim(date),
5844
- pc14.magenta(sha),
6004
+ pc15.dim(date),
6005
+ pc15.magenta(sha),
5845
6006
  node.title,
5846
- pc14.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
6007
+ pc15.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
5847
6008
  ].join(" ");
5848
6009
  }
5849
6010
 
5850
6011
  // src/cli/commands/scan-session.ts
5851
- import pc15 from "picocolors";
6012
+ import pc16 from "picocolors";
5852
6013
  async function runScanSession(opts) {
5853
6014
  const repo = await readRepoInfo(opts.cwd);
5854
6015
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5855
6016
  const turns = await collectClaudeCodeTranscripts(repo.root);
5856
6017
  if (turns.length === 0) {
5857
- process.stderr.write(`${pc15.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
6018
+ process.stderr.write(`${pc16.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
5858
6019
  `);
5859
6020
  return 0;
5860
6021
  }
@@ -5862,7 +6023,7 @@ async function runScanSession(opts) {
5862
6023
  const settled = selectSettledSessions(sessions, opts.settleMinutes);
5863
6024
  if (!opts.json) {
5864
6025
  process.stderr.write(
5865
- `${pc15.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
6026
+ `${pc16.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
5866
6027
 
5867
6028
  `
5868
6029
  );
@@ -5888,7 +6049,7 @@ async function runScanSession(opts) {
5888
6049
  }
5889
6050
  for (const preview of previews) {
5890
6051
  process.stdout.write(
5891
- `${pc15.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
6052
+ `${pc16.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
5892
6053
  ${preview.prompt}
5893
6054
 
5894
6055
  `
@@ -5900,7 +6061,7 @@ ${preview.prompt}
5900
6061
  settleMinutes: opts.settleMinutes,
5901
6062
  maxSessions: opts.maxSessions,
5902
6063
  onProgress: (done, total) => {
5903
- if (!opts.json) process.stderr.write(` ${pc15.dim(`summarizing ${done}/${total}`)}
6064
+ if (!opts.json) process.stderr.write(` ${pc16.dim(`summarizing ${done}/${total}`)}
5904
6065
  `);
5905
6066
  }
5906
6067
  });
@@ -5910,21 +6071,21 @@ ${preview.prompt}
5910
6071
  return 0;
5911
6072
  }
5912
6073
  for (const node of result.nodes) {
5913
- process.stdout.write(`${pc15.bold(node.title)}
5914
- ${pc15.dim(node.ts.slice(0, 16).replace("T", " "))}
6074
+ process.stdout.write(`${pc16.bold(node.title)}
6075
+ ${pc16.dim(node.ts.slice(0, 16).replace("T", " "))}
5915
6076
  ${node.body}
5916
6077
 
5917
6078
  `);
5918
6079
  }
5919
6080
  if (result.providerUnavailable) {
5920
6081
  process.stderr.write(
5921
- `${pc15.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
6082
+ `${pc16.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
5922
6083
  `
5923
6084
  );
5924
6085
  return 0;
5925
6086
  }
5926
6087
  process.stderr.write(
5927
- `${pc15.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc15.yellow(`${result.failed} failed`)}` : "") + ` ${pc15.dim(`(model ${opts.model})`)}
6088
+ `${pc16.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc16.yellow(`${result.failed} failed`)}` : "") + ` ${pc16.dim(`(model ${opts.model})`)}
5928
6089
  `
5929
6090
  );
5930
6091
  return 0;
@@ -5932,16 +6093,16 @@ ${node.body}
5932
6093
  var SCAN_SESSION_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
5933
6094
 
5934
6095
  // src/cli/commands/scan-shell.ts
5935
- import pc16 from "picocolors";
6096
+ import pc17 from "picocolors";
5936
6097
  async function runScanShell(opts) {
5937
6098
  const repo = await readRepoInfo(opts.cwd);
5938
6099
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
5939
6100
  const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
5940
6101
  if (!opts.json) {
5941
6102
  process.stderr.write(
5942
- results.length ? `${pc16.dim("sources found")} ${results.map((r) => r.name).join(", ")}
6103
+ results.length ? `${pc17.dim("sources found")} ${results.map((r) => r.name).join(", ")}
5943
6104
 
5944
- ` : `${pc16.yellow("no shell history source found on this machine")}
6105
+ ` : `${pc17.yellow("no shell history source found on this machine")}
5945
6106
  `
5946
6107
  );
5947
6108
  }
@@ -5950,7 +6111,7 @@ async function runScanShell(opts) {
5950
6111
  const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
5951
6112
  allNodes.push(...nodes);
5952
6113
  if (!opts.json) {
5953
- process.stdout.write(`${pc16.bold(`shell:${result.name}`)} ${pc16.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
6114
+ process.stdout.write(`${pc17.bold(`shell:${result.name}`)} ${pc17.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
5954
6115
  `);
5955
6116
  for (const node of nodes) process.stdout.write(`${formatNode5(node)}
5956
6117
  `);
@@ -5963,19 +6124,19 @@ async function runScanShell(opts) {
5963
6124
  return 0;
5964
6125
  }
5965
6126
  const approxTotal = approxTotalTokens(allNodes);
5966
- process.stderr.write(`${pc16.bold(String(allNodes.length))} node(s) total ${pc16.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
6127
+ process.stderr.write(`${pc17.bold(String(allNodes.length))} node(s) total ${pc17.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
5967
6128
  `);
5968
6129
  return 0;
5969
6130
  }
5970
6131
  function formatNode5(node) {
5971
- const approx = node.meta.tsApprox ? pc16.dim("~") : " ";
6132
+ const approx = node.meta.tsApprox ? pc17.dim("~") : " ";
5972
6133
  const exit = node.meta.exitCode;
5973
- const exitLabel = typeof exit === "number" && exit !== 0 ? pc16.red(`exit ${exit}`) : "";
6134
+ const exitLabel = typeof exit === "number" && exit !== 0 ? pc17.red(`exit ${exit}`) : "";
5974
6135
  return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
5975
6136
  }
5976
6137
 
5977
6138
  // src/cli/commands/scan-structure.ts
5978
- import pc17 from "picocolors";
6139
+ import pc18 from "picocolors";
5979
6140
  var TRACKED_EXTENSIONS = TRACKED_PATHSPECS.map((p) => p.replace("*", "")).join("/");
5980
6141
  async function runScanStructure(opts) {
5981
6142
  const repo = await readRepoInfo(opts.cwd);
@@ -5986,33 +6147,42 @@ async function runScanStructure(opts) {
5986
6147
  return 0;
5987
6148
  }
5988
6149
  if (unreadable.length > 0) {
5989
- process.stderr.write(`${pc17.yellow("unreadable")} ${unreadable.join(", ")}
6150
+ process.stderr.write(`${pc18.yellow("unreadable")} ${unreadable.join(", ")}
5990
6151
 
5991
6152
  `);
5992
6153
  }
5993
6154
  for (const edge of edges) {
5994
- process.stdout.write(`${edge.fromPath} ${pc17.dim("->")} ${edge.toPath}
6155
+ process.stdout.write(`${edge.fromPath} ${pc18.dim("->")} ${edge.toPath}
5995
6156
  `);
5996
6157
  }
5997
6158
  process.stderr.write(
5998
6159
  `
5999
- ${pc17.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKED_EXTENSIONS} file(s)
6160
+ ${pc18.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKED_EXTENSIONS} file(s)
6000
6161
  `
6001
6162
  );
6002
6163
  return 0;
6003
6164
  }
6004
6165
 
6005
6166
  // src/cli/commands/stale.ts
6006
- import pc18 from "picocolors";
6167
+ import pc19 from "picocolors";
6007
6168
  var STALE_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
6008
6169
  async function runStale(opts) {
6009
6170
  const { projectId, ws } = await loadContext(opts.cwd);
6010
6171
  const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
6011
6172
  const store = MemoryStore.open(ws.dbPath);
6012
6173
  try {
6174
+ if (opts.dismiss) {
6175
+ const dismissed = store.dismissContradictionSuggestion(projectId, opts.dismiss);
6176
+ out(
6177
+ dismissed > 0 ? `${pc19.green("dismissed")} the contradiction suggestion for ${opts.dismiss} -- it will not resurface
6178
+ ` : `${pc19.dim("no open contradiction suggestion")} for ${opts.dismiss} -- nothing to dismiss
6179
+ `
6180
+ );
6181
+ return 0;
6182
+ }
6013
6183
  const candidates = store.listStaleCandidates(projectId, { minAgeDays: opts.minAgeDays, limit: opts.limit });
6014
6184
  if (candidates.length === 0) {
6015
- out(`${pc18.dim("no stale candidates")} -- no unconfirmed node older than the threshold lacks a successor
6185
+ out(`${pc19.dim("no stale candidates")} -- no unconfirmed node older than the threshold lacks a successor
6016
6186
  `);
6017
6187
  return 0;
6018
6188
  }
@@ -6034,15 +6204,15 @@ async function runStale(opts) {
6034
6204
  for (const s of suggestions) byCandidateId.set(s.candidateId, s);
6035
6205
  out(
6036
6206
  [
6037
- `${pc18.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
6207
+ `${pc19.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
6038
6208
  ...candidates.map((c) => {
6039
- const line = ` ${pc18.dim(c.id)} ${pc18.yellow(`${c.ageDays}d old`)} [${c.kind}] ${c.title}`;
6209
+ const line = ` ${pc19.dim(c.id)} ${pc19.yellow(`${c.ageDays}d old`)} [${c.kind}] ${c.title}`;
6040
6210
  const hit = byCandidateId.get(c.id);
6041
6211
  return hit ? `${line}
6042
- ${pc18.red("likely superseded by")} ${pc18.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
6212
+ ${pc19.red("likely superseded by")} ${pc19.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
6043
6213
  }),
6044
6214
  "",
6045
- `run ${pc18.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
6215
+ `run ${pc19.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
6046
6216
  ].join("\n").concat("\n")
6047
6217
  );
6048
6218
  return 0;
@@ -6054,7 +6224,7 @@ async function runStale(opts) {
6054
6224
  // src/cli/commands/status.ts
6055
6225
  import { basename as basename4 } from "path";
6056
6226
  import { statSync } from "fs";
6057
- import pc19 from "picocolors";
6227
+ import pc20 from "picocolors";
6058
6228
  function daySpan(oldest, newest) {
6059
6229
  const oldestDay = Date.parse(oldest.slice(0, 10));
6060
6230
  const newestDay = Date.parse(newest.slice(0, 10));
@@ -6114,34 +6284,34 @@ async function runStatus(opts) {
6114
6284
  const flaggedCount = store.countContradictionSuggestions(projectId);
6115
6285
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
6116
6286
  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(
6287
+ 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
6288
  "nexusmem sync --prune-source <name>"
6119
6289
  )} to remove stale source data` : "";
6120
6290
  out(
6121
6291
  [
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)})`)}`,
6292
+ `${pc20.dim("repo ")} ${repo.root}`,
6293
+ `${pc20.dim("branch ")} ${repo.branch ?? pc20.yellow("(detached)")}`,
6294
+ `${pc20.dim("project ")} ${pc20.cyan(projectId)}`,
6295
+ `${pc20.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc20.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
6296
+ `${pc20.dim("database")} ${ws.dbPath} ${pc20.dim(`(${humanBytes(dbBytes)})`)}`,
6127
6297
  staleProjectWarning,
6128
6298
  "",
6129
- `${pc19.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc19.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
6299
+ `${pc20.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc20.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
6130
6300
  ...kinds,
6131
- stats2.total ? ` ${pc19.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
6301
+ stats2.total ? ` ${pc20.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
6132
6302
  "",
6133
- sources.length ? pc19.dim("sources") : pc19.yellow("no sources synced yet"),
6303
+ sources.length ? pc20.dim("sources") : pc20.yellow("no sources synced yet"),
6134
6304
  ...sources.map((s) => {
6135
6305
  const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
6136
6306
  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}`)}`;
6307
+ return ` ${s.source.padEnd(14)} ${pc20.dim(`last run ${when}`)} ${pc20.dim(`cursor ${cursorLabel}`)}`;
6138
6308
  }),
6139
6309
  "",
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` : ""
6310
+ gitCursor && gitCursor !== repo.head ? `${pc20.yellow("git behind HEAD")} \u2014 run ${pc20.bold("nexusmem sync")}` : "",
6311
+ 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` : ""}` : "",
6312
+ structure.edges ? `${pc20.dim("structure")} ${pc20.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
6313
+ staleCount ? `${pc20.dim("aging ")} ${pc20.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc20.bold("nexusmem stale")}` : "",
6314
+ flaggedCount ? `${pc20.dim("flagged ")} ${pc20.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc20.bold("nexusmem stale")} for detail` : ""
6145
6315
  ].filter((line) => line !== "").join("\n").concat("\n")
6146
6316
  );
6147
6317
  return 0;
@@ -6156,7 +6326,7 @@ function isExpected(err) {
6156
6326
  // the user fixes, not stack traces they debug.
6157
6327
  err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
6158
6328
  // (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;
6329
+ 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
6330
  }
6161
6331
  function guard(run) {
6162
6332
  return async () => {
@@ -6164,7 +6334,7 @@ function guard(run) {
6164
6334
  process.exitCode = await run();
6165
6335
  } catch (err) {
6166
6336
  if (isExpected(err)) {
6167
- process.stderr.write(`${pc20.red("error")} ${err.message}
6337
+ process.stderr.write(`${pc21.red("error")} ${err.message}
6168
6338
  `);
6169
6339
  process.exitCode = 1;
6170
6340
  return;
@@ -6227,7 +6397,7 @@ program.command("hook").description("Manage the opt-in PowerShell hook that logs
6227
6397
  )
6228
6398
  );
6229
6399
  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(
6400
+ 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
6401
  (text, options) => guard(
6232
6402
  () => runQuery({
6233
6403
  cwd: options.cwd,
@@ -6237,6 +6407,7 @@ program.command("query").description("Search remembered history and print a toke
6237
6407
  halfLifeDays: options.halfLife,
6238
6408
  noVector: !options.vector,
6239
6409
  allProjects: options.allProjects,
6410
+ asOf: options.asOf,
6240
6411
  json: options.json
6241
6412
  })
6242
6413
  )()
@@ -6266,17 +6437,26 @@ program.command("mark-stale").description(
6266
6437
  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
6438
  "--check-contradictions",
6268
6439
  "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(
6440
+ ).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
6441
  (options) => guard(
6271
6442
  () => runStale({
6272
6443
  cwd: options.cwd,
6273
6444
  minAgeDays: options.minAgeDays,
6274
6445
  limit: options.limit,
6275
6446
  checkContradictions: options.checkContradictions,
6276
- model: options.model
6447
+ model: options.model,
6448
+ dismiss: options.dismiss
6277
6449
  })
6278
6450
  )()
6279
6451
  );
6452
+ 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(
6453
+ (nodeId, options) => guard(async () => {
6454
+ if (options.verify === options.reject) {
6455
+ throw new ReviewError("pass exactly one of --verify or --reject");
6456
+ }
6457
+ return runReview({ cwd: options.cwd, nodeId, verdict: options.verify ? "verified" : "rejected" });
6458
+ })()
6459
+ );
6280
6460
  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
6461
  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
6462
  (options) => guard(
@@ -6337,7 +6517,7 @@ program.command("scan-structure").description("Preview the JS/TS/Python/Go/Rust/
6337
6517
  program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
6338
6518
  program.parseAsync(process.argv).catch((err) => {
6339
6519
  const message = err instanceof Error ? err.message : String(err);
6340
- process.stderr.write(`${pc20.red("error")} ${message}
6520
+ process.stderr.write(`${pc21.red("error")} ${message}
6341
6521
  `);
6342
6522
  process.exitCode = 1;
6343
6523
  });