@modusensus/dsh-mneme 0.4.4 → 0.4.5

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.
@@ -1,5 +1,10 @@
1
1
  const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create"]);
2
2
 
3
+ // Epistemic trust (v0.4.5): when config.trustEpistemicWeighting is on, merge
4
+ // keepSource and conflict winners prefer the higher-trust memory. Higher value
5
+ // = preferred. observation (measured) > inferred (derived) > subjective (guess).
6
+ const EPISTEMIC_PRIORITY = { observation: 3, inferred: 2, subjective: 1 };
7
+
3
8
  /**
4
9
  * Validate a dream decision list against a snapshot of eligible memories.
5
10
  * @param decisions - LLM-produced decision list.
@@ -252,12 +257,34 @@ function applyOne(d, service, snapshot, config = {}) {
252
257
  switch (d.action) {
253
258
  case "archive": return applyArchive(d, service, snapshot);
254
259
  case "merge": return applyMerge(d, service, snapshot, config);
255
- case "conflict": return applyConflict(d, service, snapshot);
260
+ case "conflict": return applyConflict(d, service, snapshot, config);
256
261
  case "create": return applyCreate(d, service, config);
257
262
  default: return applyUpdate(d, service, snapshot, config);
258
263
  }
259
264
  }
260
265
 
266
+ /**
267
+ * Highest-epistemic-priority UNARCHIVED id among `ids` (ties break toward
268
+ * `preferred`). Archived memories are never eligible keepers — promoting one
269
+ * would demote the real keepSource to a source and then hit the archived-keeper
270
+ * guard in applyMerge, silently skipping the whole merge. When `preferred`
271
+ * itself is archived (or missing), fall back to any unarchived candidate.
272
+ */
273
+ function pickBestKeeper(ids, preferred, service) {
274
+ let best = null;
275
+ let bestP = -1;
276
+ for (const id of ids) {
277
+ const mem = service.getById(id);
278
+ if (!mem || mem.archived) continue; // archived/missing: ineligible keeper
279
+ const p = EPISTEMIC_PRIORITY[mem.epistemic_status] ?? 0;
280
+ if (p > bestP || (p === bestP && id === preferred)) {
281
+ bestP = p;
282
+ best = id;
283
+ }
284
+ }
285
+ return best ?? preferred;
286
+ }
287
+
261
288
  /**
262
289
  * Mint a fresh memory (pattern discovery). No existing target, so no CAS guard.
263
290
  * Evidence ids ride in the content so a pattern stays traceable to its source
@@ -297,6 +324,13 @@ function applyArchive(d, service, snapshot) {
297
324
  }
298
325
 
299
326
  function applyMerge(d, service, snapshot, config = {}) {
327
+ // Epistemic trust (v0.4.5): when enabled, prefer an observation keeper over a
328
+ // subjective/inferred one. Mutating the decision keeps the receipt + committed
329
+ // record aligned with the actual keeper.
330
+ if (config.trustEpistemicWeighting === true) {
331
+ const best = pickBestKeeper(d.ids, d.keepSource, service);
332
+ if (best && best !== d.keepSource) d.keepSource = best;
333
+ }
300
334
  const sources = d.ids.filter((id) => id !== d.keepSource);
301
335
  // Idempotent replay: if every other source is already archived, this merge
302
336
  // already landed — skip so a replayed/concurrent decision never double-counts
@@ -334,7 +368,14 @@ function applyMerge(d, service, snapshot, config = {}) {
334
368
  };
335
369
  }
336
370
 
337
- function applyConflict(d, service, snapshot) {
371
+ function applyConflict(d, service, snapshot, config = {}) {
372
+ // Epistemic trust (v0.4.5): when enabled, the observation side of a conflict
373
+ // is preferred as winner over a subjective/inferred one.
374
+ if (config.trustEpistemicWeighting === true) {
375
+ const pw = EPISTEMIC_PRIORITY[service.getById(d.winner)?.epistemic_status] ?? 0;
376
+ const pl = EPISTEMIC_PRIORITY[service.getById(d.loser)?.epistemic_status] ?? 0;
377
+ if (pl > pw) [d.winner, d.loser] = [d.loser, d.winner];
378
+ }
338
379
  const winner = service.getById(d.winner);
339
380
  const loser = service.getById(d.loser);
340
381
  if (!winner || !loser) return "skipped";
package/src/inject.js CHANGED
@@ -6,7 +6,12 @@ export function createInjector(ctx, service, settings, config) {
6
6
  if (!candidates.length) return "";
7
7
  const lines = ["[记忆库] 来自 dsh-mneme 的跨会话记忆(用户偏好与高优先级项目/决策):"];
8
8
  for (const m of candidates) {
9
- lines.push(`- [${m.type}] ${m.title}(重要性 ${m.importance}):${m.content}`);
9
+ // Epistemic trust (v0.4.5): when enabled, measured observations are
10
+ // flagged so the agent can weigh them above guesses/opinions.
11
+ const verified = config.trustEpistemicWeighting === true && m.epistemic_status === "observation"
12
+ ? "[verified] "
13
+ : "";
14
+ lines.push(`- [${m.type}] ${verified}${m.title}(重要性 ${m.importance}):${m.content}`);
10
15
  }
11
16
  return lines.join("\n");
12
17
  }
package/src/service.js CHANGED
@@ -3,6 +3,39 @@ import { TYPE_FILE } from "./mirror.js";
3
3
 
4
4
  const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
5
5
 
6
+ // Epistemic trust weights (v0.4.5): when config.trustEpistemicWeighting is on,
7
+ // each recall candidate's existing score is multiplied by the weight of its
8
+ // epistemic_status before ranking — measured facts outrank guesses. Missing /
9
+ // unknown statuses are unscaled (×1). Off by default, so nothing changes.
10
+ const EPISTEMIC_WEIGHTS = { observation: 1.0, inferred: 0.85, subjective: 0.7 };
11
+
12
+ /**
13
+ * Standard retrieval-quality metrics over the ordered candidate ids actually
14
+ * returned vs the ids the evaluator marked relevant (方案 B). Pure + total, so
15
+ * callers (and tests) get deterministic numbers without touching a store:
16
+ * precision = |relevant ∩ retrieved| / |retrieved|
17
+ * recall = |relevant ∩ retrieved| / |expected|
18
+ * mrr = 1 / rank of the first relevant doc (0 when none retrieved)
19
+ * hit_count is the raw intersection size. Values are rounded to 4 decimals so
20
+ * repeated divisions (e.g. 1/3) never surface binary-float noise.
21
+ */
22
+ export function computeRetrievalMetrics(actualIds, expectedIds) {
23
+ const expected = new Set(Array.isArray(expectedIds) ? expectedIds : []);
24
+ const actual = Array.isArray(actualIds) ? actualIds : [];
25
+ const relevant = actual.filter((id) => expected.has(id)).length;
26
+ const round4 = (x) => Math.round(x * 10000) / 10000;
27
+ let mrr = 0;
28
+ for (let i = 0; i < actual.length; i++) {
29
+ if (expected.has(actual[i])) { mrr = 1 / (i + 1); break; }
30
+ }
31
+ return {
32
+ precision: round4(actual.length ? relevant / actual.length : 0),
33
+ recall: round4(expected.size ? relevant / expected.size : 0),
34
+ mrr: round4(mrr),
35
+ hit_count: relevant
36
+ };
37
+ }
38
+
6
39
  export function createService({ store, mirror, config, onWrite, logger }) {
7
40
  // Optional dream scheduler hook, installed via setDreamHook after creation
8
41
  // (the scheduler holds a reference back to the service, so it cannot be
@@ -357,9 +390,21 @@ export function createService({ store, mirror, config, onWrite, logger }) {
357
390
  }
358
391
 
359
392
  merged = merged.slice(0, lim);
360
- const result = useRerank && reranker && merged.length
393
+ let result = useRerank && reranker && merged.length
361
394
  ? await rerankCandidates(q, merged, lim)
362
395
  : merged;
396
+ // Epistemic trust (v0.4.5): opt-in re-weighting of the final candidate
397
+ // scores by source credibility. When off (default) `result` is returned
398
+ // untouched — exactly the legacy behavior.
399
+ if (config.trustEpistemicWeighting === true) {
400
+ result = result
401
+ .map((m) => ({
402
+ ...m,
403
+ score: (m.score ?? 0) * (EPISTEMIC_WEIGHTS[m.epistemic_status] ?? 1)
404
+ }))
405
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
406
+ .slice(0, lim);
407
+ }
363
408
 
364
409
  // Recall layer receipt: with recordRecall on, hand the actual merged
365
410
  // candidate list (id/title/content/score/source) to the injected recorder
@@ -388,6 +433,93 @@ export function createService({ store, mirror, config, onWrite, logger }) {
388
433
  return result;
389
434
  }
390
435
 
436
+ /**
437
+ * Retrieval evaluation (方案 B): run one search for `query`, compare the ids
438
+ * it actually returned against `expectedIds`, and return the computed
439
+ * metrics. When persistence is on (config.evalPersistTestResults, or an
440
+ * explicit `persist` override per call) the snapshot is written to the
441
+ * recall_evals table — a SEPARATE store from the recall_runs production audit,
442
+ * so test/eval data never inflates the production trail.
443
+ *
444
+ * options:
445
+ * mode/topK/threshold/useRerank — passed through to searchMemories
446
+ * evalType — label for the snapshot (default 'manual')
447
+ * recordRecall — also write a recall_runs audit row for the
448
+ * same scene and link it via recall_run_id
449
+ * (default false: eval stays unlinked)
450
+ * recallRunId — explicit link to an existing recall_runs id
451
+ * persist — override the config gate for this call
452
+ *
453
+ * Returns { metrics, actualIds, expectedIds, recallRunId, persisted }.
454
+ * Never throws on persistence failures: a broken eval write must not break
455
+ * the retrieval quality measurement.
456
+ */
457
+ async function evaluateRetrieval(query, expectedIds, options = {}) {
458
+ const q = String(query ?? "").trim();
459
+ const expected = Array.isArray(expectedIds) ? expectedIds : [];
460
+ const {
461
+ mode = "auto",
462
+ topK = 20,
463
+ threshold,
464
+ useRerank = true,
465
+ evalType = "manual",
466
+ recordRecall = false,
467
+ recallRunId = null,
468
+ persist = config.evalPersistTestResults === true
469
+ } = options;
470
+ if (!q) {
471
+ const empty = computeRetrievalMetrics([], expected);
472
+ return { metrics: empty, actualIds: [], expectedIds: expected, recallRunId: null, persisted: false };
473
+ }
474
+
475
+ const rows = await searchMemories(q, { mode, topK, threshold, useRerank, recordRecall: false });
476
+ const actualIds = rows.map((m) => m.id);
477
+ const metrics = computeRetrievalMetrics(actualIds, expected);
478
+
479
+ // Optional recall_runs audit for the same scene; the eval row then links to
480
+ // it. Kept separate from the production recorder (which fires only on
481
+ // recordRecall=true inside searchMemories) — eval never double-records.
482
+ // An explicit recallRunId wins; recordRecall only mints a NEW audit run when
483
+ // the caller did not already link one (never clobber an existing link).
484
+ let runId = recallRunId ?? null;
485
+ if (recordRecall && runId === null) {
486
+ try {
487
+ const run = store.saveRecallRun({
488
+ query: q,
489
+ mode,
490
+ topK,
491
+ threshold: threshold ?? null,
492
+ candidates: rows.map((m) => ({
493
+ id: m.id,
494
+ title: m.title,
495
+ content: m.content,
496
+ score: m.score ?? null,
497
+ source: m.source ?? "keyword"
498
+ })),
499
+ created_at: new Date().toISOString()
500
+ });
501
+ runId = run.id;
502
+ } catch { /* non-fatal: the eval itself still succeeds */ }
503
+ }
504
+
505
+ let persisted = false;
506
+ if (persist) {
507
+ try {
508
+ store.saveRecallEval({
509
+ recall_run_id: runId,
510
+ query: q,
511
+ expected_ids: expected,
512
+ actual_ids: actualIds,
513
+ metrics,
514
+ eval_type: evalType,
515
+ created_at: new Date().toISOString()
516
+ });
517
+ persisted = true;
518
+ } catch { /* non-fatal: measurement survives a failed eval write */ }
519
+ }
520
+ return { metrics, actualIds, expectedIds: expected, recallRunId: runId, persisted };
521
+ }
522
+
391
523
  /**
392
524
  * Fire-and-forget write notification; errors are swallowed to keep write
393
525
  * paths clean. The store mutation has already committed, so a throwing
@@ -867,6 +999,8 @@ export function createService({ store, mirror, config, onWrite, logger }) {
867
999
  setReranker(rn) { reranker = rn; },
868
1000
  setRecallRecorder(fn) { recallRecorder = fn; },
869
1001
  searchMemories,
1002
+ evaluateRetrieval,
1003
+ computeRetrievalMetrics,
870
1004
  // passthroughs used by tools and api layers; mutations keep the mirror in sync
871
1005
  search: (q, o) => store.search(q, o),
872
1006
  searchVector: (v, o) => store.searchVector(v, o),
@@ -999,6 +1133,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
999
1133
  listConflictPending: (opts) => store.listConflictPending(opts),
1000
1134
  resolveConflictPending: (id, o) => store.resolveConflictPending(id, o),
1001
1135
  countConflictPending: () => store.countConflictPending(),
1136
+ // Recall evaluation trail (方案 B): audit-bookkeeping semantics like the
1137
+ // dream/recall passthroughs above — a recall_evals write is a snapshot, not
1138
+ // a memory mutation, so it never triggers write hooks.
1139
+ saveRecallEval: (r) => store.saveRecallEval(r),
1140
+ getRecallEval: (id) => store.getRecallEval(id),
1141
+ listRecallEvals: (opts) => store.listRecallEvals(opts),
1002
1142
  // Entity gene (v0.3.0) passthroughs for the autoDream apply path
1003
1143
  // (applyDecisions): records supersedes relations after an update and
1004
1144
  // migrates entity_attrs on merge. Bookkeeping writes like the audit
package/src/store.js CHANGED
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS memories (
13
13
  archived INTEGER NOT NULL DEFAULT 0,
14
14
  source TEXT,
15
15
  embedding TEXT,
16
+ epistemic_status TEXT NOT NULL DEFAULT 'subjective',
16
17
  last_accessed_at TEXT,
17
18
  _full_content TEXT,
18
19
  created_at TEXT NOT NULL,
@@ -61,6 +62,29 @@ CREATE TABLE IF NOT EXISTS recall_runs (
61
62
  CREATE INDEX IF NOT EXISTS idx_recall_runs_created ON recall_runs(created_at);
62
63
  CREATE INDEX IF NOT EXISTS idx_recall_runs_query ON recall_runs(query);
63
64
 
65
+ -- recall_evals: retrieval evaluation/test snapshots, kept SEPARATE from the
66
+ -- recall_runs production audit so test runs never inflate the production trail.
67
+ -- One row per evaluateRetrieval call that opted into persistence
68
+ -- (config.evalPersistTestResults): the query, the expected ids the operator
69
+ -- marked relevant, the actual ids retrieval returned, and the computed
70
+ -- metrics (precision/recall/mrr). recall_run_id optionally links to the
71
+ -- recall_runs audit row that captured the same retrieval scene (null when the
72
+ -- eval did not also record a run). Bookkeeping like the other audit tables: it
73
+ -- never triggers write hooks.
74
+ CREATE TABLE IF NOT EXISTS recall_evals (
75
+ id TEXT PRIMARY KEY,
76
+ recall_run_id TEXT, -- FK → recall_runs.id (optional linkage)
77
+ query TEXT NOT NULL,
78
+ expected_ids TEXT NOT NULL, -- JSON: relevant ids expected by the evaluator
79
+ actual_ids TEXT NOT NULL, -- JSON: ids actually retrieved
80
+ metrics TEXT NOT NULL, -- JSON: { precision, recall, mrr, hit_count }
81
+ eval_type TEXT NOT NULL DEFAULT 'manual',
82
+ created_at TEXT NOT NULL,
83
+ FOREIGN KEY (recall_run_id) REFERENCES recall_runs(id)
84
+ );
85
+ CREATE INDEX IF NOT EXISTS idx_recall_evals_created ON recall_evals(created_at);
86
+ CREATE INDEX IF NOT EXISTS idx_recall_evals_run ON recall_evals(recall_run_id);
87
+
64
88
  -- failure_memories: records user corrections / reflection failures. Captures
65
89
  -- what a memory was (actual) vs what the user changed it to (expected)
66
90
  -- so later reflection passes can mine recurring correction patterns.
@@ -191,6 +215,48 @@ CREATE TABLE IF NOT EXISTS mirror_state (
191
215
 
192
216
  const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern"]);
193
217
 
218
+ // Epistemic status: what kind of evidence a memory rests on. Defaults to
219
+ // 'subjective' so legacy rows (and rows without any signal) stay compatible.
220
+ const EPISTEMIC_STATUSES = new Set(["observation", "subjective", "inferred"]);
221
+ // Rule-based inference markers, checked in priority order (observation >
222
+ // inferred > subjective). The default fallback is 'subjective'.
223
+ const OBSERVATION_RE = /实测|观察到|观测|测得|测量|结果表明|数据显示|实验|统计|结果/;
224
+ const INFERRED_RE = /推断|推测出|推导|推论|由此可|据此|综上|意味着|所以|因此/;
225
+ const SUBJECTIVE_RE = /我推测|我猜|我觉得|我感觉|可能|大概|也许|认为|猜想|似乎|猜测|感觉/;
226
+
227
+ /**
228
+ * Heuristically infer a memory's epistemic status from its content (and the
229
+ * AI-generated types). summary/pattern entries are always 'inferred' (derived
230
+ * from other memories); otherwise content markers decide. Pure rule-based, so
231
+ * it never throws and always returns a value in EPISTEMIC_STATUSES.
232
+ */
233
+ function inferEpistemicStatus(memory) {
234
+ if (memory.type === "summary" || memory.type === "pattern") return "inferred";
235
+ const text = `${memory.title ?? ""} ${memory.content ?? ""}`;
236
+ if (OBSERVATION_RE.test(text)) return "observation";
237
+ if (INFERRED_RE.test(text)) return "inferred";
238
+ if (SUBJECTIVE_RE.test(text)) return "subjective";
239
+ return "subjective";
240
+ }
241
+
242
+ /** Resolve a requested epistemic_status: explicit valid value wins, otherwise
243
+ * re-infer from (possibly updated) content. Never returns an invalid value. */
244
+ function resolveEpistemicStatus(memory, patch) {
245
+ if (patch?.epistemic_status !== undefined) {
246
+ return EPISTEMIC_STATUSES.has(patch.epistemic_status) ? patch.epistemic_status : "subjective";
247
+ }
248
+ // Re-infer whenever any signal that feeds the heuristic changed: content
249
+ // (marker words), title (marker words), or type (summary/pattern are always
250
+ // inferred). Otherwise keep the stored status.
251
+ const changed = ["content", "title", "type"].some(
252
+ (k) => patch?.[k] !== undefined && patch[k] !== memory?.[k]
253
+ );
254
+ if (changed) {
255
+ return inferEpistemicStatus({ ...memory, ...patch });
256
+ }
257
+ return memory?.epistemic_status ?? "subjective";
258
+ }
259
+
194
260
  // Per-type mirror sync receipts (peer blocker 4): a type is either committed
195
261
  // (file written + fence applied), failed (last sync round errored for it), or
196
262
  // pending (still owed a write).
@@ -229,6 +295,7 @@ function toRow(row) {
229
295
  forgotten: row.forgotten === 1,
230
296
  archived: row.archived === 1,
231
297
  source: row.source ?? undefined,
298
+ epistemic_status: row.epistemic_status ?? "subjective",
232
299
  created_at: row.created_at,
233
300
  updated_at: row.updated_at,
234
301
  last_accessed_at: row.last_accessed_at ?? undefined,
@@ -305,6 +372,24 @@ function toRecallRun(row) {
305
372
  };
306
373
  }
307
374
 
375
+ function toRecallEval(row) {
376
+ if (!row) return undefined;
377
+ let metrics;
378
+ if (row.metrics != null) {
379
+ try { metrics = JSON.parse(row.metrics); } catch { metrics = undefined; }
380
+ }
381
+ return {
382
+ id: row.id,
383
+ recall_run_id: row.recall_run_id ?? undefined,
384
+ query: row.query,
385
+ expected_ids: parseJsonArray(row.expected_ids),
386
+ actual_ids: parseJsonArray(row.actual_ids),
387
+ metrics,
388
+ eval_type: row.eval_type,
389
+ created_at: row.created_at
390
+ };
391
+ }
392
+
308
393
  function toEntity(row) {
309
394
  if (!row) return undefined;
310
395
  return {
@@ -421,6 +506,9 @@ export function createStore(path) {
421
506
  if (!columns.includes("_full_content")) {
422
507
  db.exec("ALTER TABLE memories ADD COLUMN _full_content TEXT");
423
508
  }
509
+ if (!columns.includes("epistemic_status")) {
510
+ db.exec("ALTER TABLE memories ADD COLUMN epistemic_status TEXT NOT NULL DEFAULT 'subjective'");
511
+ }
424
512
 
425
513
  // Legacy dream_runs without policy_epoch → backfill with the default epoch.
426
514
  const dreamCols = db.prepare("PRAGMA table_info(dream_runs)").all().map((c) => c.name);
@@ -512,11 +600,16 @@ export function createStore(path) {
512
600
  const embedding = Array.isArray(memory.embedding) && memory.embedding.length
513
601
  ? JSON.stringify(memory.embedding)
514
602
  : null;
603
+ // Explicit valid status wins; otherwise infer from content/type. Falls back
604
+ // to 'subjective' (the column default) so legacy callers never break.
605
+ const epistemicStatus = EPISTEMIC_STATUSES.has(memory.epistemic_status)
606
+ ? memory.epistemic_status
607
+ : inferEpistemicStatus(memory);
515
608
  runAtomically(() => {
516
609
  db.prepare(
517
- `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, source, embedding, created_at, updated_at)
518
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`
519
- ).run(id, type, memory.title, memory.content, tags, importance, memory.source ?? null, embedding, now, now);
610
+ `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, source, embedding, epistemic_status, created_at, updated_at)
611
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`
612
+ ).run(id, type, memory.title, memory.content, tags, importance, memory.source ?? null, embedding, epistemicStatus, now, now);
520
613
  // desired generation bumped in the same transaction as the write: once
521
614
  // this commits, generation > applied_generation, so a crash right after
522
615
  // (before syncMirror) is caught by recoverMirror on restart (peer
@@ -538,9 +631,10 @@ export function createStore(path) {
538
631
  const embedding = patch.embedding !== undefined
539
632
  ? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
540
633
  : existing.embedding ?? null;
634
+ const epistemicStatus = resolveEpistemicStatus(existing, patch);
541
635
  runAtomically(() => {
542
636
  db.prepare(
543
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=? WHERE id=?`
637
+ `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, epistemic_status=?, updated_at=? WHERE id=?`
544
638
  ).run(
545
639
  type,
546
640
  patch.title ?? existing.title,
@@ -549,6 +643,7 @@ export function createStore(path) {
549
643
  Number.isInteger(patch.importance) ? patch.importance : existing.importance,
550
644
  patch.source !== undefined ? patch.source : (existing.source ?? null),
551
645
  embedding,
646
+ epistemicStatus,
552
647
  now,
553
648
  id
554
649
  );
@@ -588,6 +683,7 @@ export function createStore(path) {
588
683
  const embedding = patch.embedding !== undefined
589
684
  ? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
590
685
  : existing.embedding ?? null;
686
+ const epistemicStatus = resolveEpistemicStatus(existing, patch);
591
687
  // The CAS UPDATE and the desired-generation bump must commit together (audit
592
688
  // peer A): if the UPDATE autocommits first and the process dies before the
593
689
  // increment, the store is mutated while generation == applied_generation and
@@ -597,7 +693,7 @@ export function createStore(path) {
597
693
  let applied = false;
598
694
  runAtomically(() => {
599
695
  const result = db.prepare(
600
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
696
+ `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, epistemic_status=?, updated_at=?
601
697
  WHERE id=? AND updated_at=?`
602
698
  ).run(
603
699
  type,
@@ -607,6 +703,7 @@ export function createStore(path) {
607
703
  Number.isInteger(patch.importance) ? patch.importance : existing.importance,
608
704
  patch.source !== undefined ? patch.source : (existing.source ?? null),
609
705
  embedding,
706
+ epistemicStatus,
610
707
  now,
611
708
  id,
612
709
  expectedUpdatedAt
@@ -1001,6 +1098,60 @@ export function createStore(path) {
1001
1098
  return rows.map(toRecallRun);
1002
1099
  }
1003
1100
 
1101
+ // --- recall evaluation trail (方案 B: separate from the production audit) -
1102
+
1103
+ /**
1104
+ * Persist one retrieval-evaluation snapshot into recall_evals — the test/eval
1105
+ * sibling of recall_runs, deliberately stored apart so eval snapshots never
1106
+ * inflate the production recall audit. Like the other audit tables this is
1107
+ * bookkeeping: it never triggers write hooks. Writes are idempotent on id
1108
+ * (replay overwrites, never duplicates), matching saveRecallRun. recall_run_id
1109
+ * optionally links the eval to the recall_runs row that captured the same
1110
+ * retrieval scene (FK-referenced, null when no run was recorded).
1111
+ */
1112
+ function saveRecallEval(evalRow) {
1113
+ const id = evalRow.id ?? randomUUID();
1114
+ db.prepare(
1115
+ `INSERT INTO recall_evals (id, recall_run_id, query, expected_ids, actual_ids, metrics, eval_type, created_at)
1116
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1117
+ ON CONFLICT(id) DO UPDATE SET
1118
+ recall_run_id=excluded.recall_run_id, query=excluded.query,
1119
+ expected_ids=excluded.expected_ids, actual_ids=excluded.actual_ids,
1120
+ metrics=excluded.metrics, eval_type=excluded.eval_type,
1121
+ created_at=excluded.created_at`
1122
+ ).run(
1123
+ id,
1124
+ evalRow.recall_run_id ?? null,
1125
+ evalRow.query,
1126
+ JSON.stringify(evalRow.expected_ids ?? []),
1127
+ JSON.stringify(evalRow.actual_ids ?? []),
1128
+ JSON.stringify(evalRow.metrics ?? {}),
1129
+ evalRow.eval_type ?? "manual",
1130
+ evalRow.created_at ?? nowIso()
1131
+ );
1132
+ return getRecallEval(id);
1133
+ }
1134
+
1135
+ function getRecallEval(id) {
1136
+ const row = db.prepare("SELECT * FROM recall_evals WHERE id = ?").get(id);
1137
+ return toRecallEval(row);
1138
+ }
1139
+
1140
+ function listRecallEvals({ limit = 50, offset = 0, query } = {}) {
1141
+ const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1142
+ const clauses = [];
1143
+ const params = [];
1144
+ if (query) {
1145
+ clauses.push("query LIKE ? ESCAPE '\\'");
1146
+ params.push(`%${escapeLike(String(query))}%`);
1147
+ }
1148
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1149
+ const rows = db.prepare(
1150
+ `SELECT * FROM recall_evals ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`
1151
+ ).all(...params, lim, off);
1152
+ return rows.map(toRecallEval);
1153
+ }
1154
+
1004
1155
  // --- failure memories ----------------------------------------------------
1005
1156
 
1006
1157
  /**
@@ -1524,6 +1675,9 @@ export function createStore(path) {
1524
1675
  saveRecallRun,
1525
1676
  getRecallRun,
1526
1677
  listRecallRuns,
1678
+ saveRecallEval,
1679
+ getRecallEval,
1680
+ listRecallEvals,
1527
1681
  saveFailure,
1528
1682
  listFailures,
1529
1683
  getFailureStats,