@modusensus/dsh-mneme 0.6.8 → 0.6.10

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.
Files changed (117) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +468 -463
  3. package/cordis.patch.yml +15 -15
  4. package/lib/api.js +783 -783
  5. package/lib/client.js +1754 -1757
  6. package/lib/commands.js +64 -64
  7. package/lib/config.js +298 -288
  8. package/lib/dream/clustering.js +118 -118
  9. package/lib/dream/decisions.js +488 -439
  10. package/lib/dream/sleep.js +561 -561
  11. package/lib/dream/tag-extractor.js +156 -156
  12. package/lib/dream.js +958 -935
  13. package/lib/embedding.js +154 -154
  14. package/lib/entities/extractor.js +242 -242
  15. package/lib/hot-memory.js +53 -53
  16. package/lib/index.js +361 -361
  17. package/lib/inject.js +208 -208
  18. package/lib/local-embedder.js +282 -282
  19. package/lib/mirror.js +170 -170
  20. package/lib/parser/tag.js +59 -59
  21. package/lib/parser/wiki-link.js +38 -38
  22. package/lib/quality-filter.js +123 -123
  23. package/lib/reranker.js +218 -218
  24. package/lib/search/adaptive.js +22 -22
  25. package/lib/search/bm25.js +96 -96
  26. package/lib/search/tag-boost.js +61 -61
  27. package/lib/service.js +1726 -1726
  28. package/lib/settings.js +172 -172
  29. package/lib/store.js +2238 -2238
  30. package/lib/summarize.js +236 -236
  31. package/lib/tools.js +290 -290
  32. package/lib/vector-index.js +116 -116
  33. package/package.json +80 -80
  34. package/scripts/benchmark-embed.js +201 -201
  35. package/scripts/benchmark-recall.js +133 -133
  36. package/scripts/benchmark-rerank.js +166 -166
  37. package/scripts/e2e-dsh.js +218 -218
  38. package/scripts/stress-dsh.js +255 -255
  39. package/scripts/sync-lib.js +52 -52
  40. package/src/api.js +783 -783
  41. package/src/commands.js +64 -64
  42. package/src/config.js +298 -288
  43. package/src/dream/clustering.js +118 -118
  44. package/src/dream/decisions.js +488 -439
  45. package/src/dream/sleep.js +561 -561
  46. package/src/dream/tag-extractor.js +156 -156
  47. package/src/dream.js +958 -935
  48. package/src/embedding.js +154 -154
  49. package/src/entities/extractor.js +242 -242
  50. package/src/hot-memory.js +53 -53
  51. package/src/index.js +361 -361
  52. package/src/inject.js +208 -208
  53. package/src/local-embedder.js +282 -282
  54. package/src/mirror.js +170 -170
  55. package/src/parser/tag.js +59 -59
  56. package/src/parser/wiki-link.js +38 -38
  57. package/src/quality-filter.js +123 -123
  58. package/src/reranker.js +218 -218
  59. package/src/search/adaptive.js +22 -22
  60. package/src/search/bm25.js +96 -96
  61. package/src/search/tag-boost.js +61 -61
  62. package/src/service.js +1726 -1726
  63. package/src/settings.js +172 -172
  64. package/src/store.js +2238 -2238
  65. package/src/summarize.js +236 -236
  66. package/src/tools.js +290 -290
  67. package/src/vector-index.js +116 -116
  68. package/test/api.test.js +594 -594
  69. package/test/audit.test.js +448 -448
  70. package/test/benchmark.test.js +35 -35
  71. package/test/boundary-v0625.test.js +82 -82
  72. package/test/client.test.js +368 -368
  73. package/test/clustering.test.js +100 -100
  74. package/test/commands.test.js +69 -69
  75. package/test/config.test.js +50 -50
  76. package/test/conflict-freeze.test.js +290 -290
  77. package/test/directory.test.js +134 -134
  78. package/test/dream.test.js +1060 -903
  79. package/test/entities.test.js +522 -522
  80. package/test/epistemic.test.js +298 -298
  81. package/test/fnew-0112.test.js +311 -311
  82. package/test/fnew-03.test.js +422 -422
  83. package/test/graph-api.test.js +175 -175
  84. package/test/helpers/dream-mock.js +82 -82
  85. package/test/hot-memory.test.js +174 -174
  86. package/test/inject.test.js +103 -103
  87. package/test/llm-audit.test.js +279 -279
  88. package/test/local-embedder.test.js +227 -227
  89. package/test/mirror-dirty.test.js +424 -424
  90. package/test/mirror-edit-digest.test.js +187 -187
  91. package/test/mirror-generation.test.js +499 -499
  92. package/test/mirror.test.js +249 -249
  93. package/test/normalize-decisions.test.js +120 -120
  94. package/test/peer-blockers.test.js +190 -190
  95. package/test/policy-epoch.test.js +259 -259
  96. package/test/provenance.test.js +103 -103
  97. package/test/quality-filter.test.js +118 -118
  98. package/test/reasoning-effort.test.js +199 -199
  99. package/test/recall-evals.test.js +235 -235
  100. package/test/recall-layer.test.js +315 -315
  101. package/test/receipt-chain.test.js +451 -451
  102. package/test/reflection.test.js +226 -226
  103. package/test/reranker.test.js +240 -240
  104. package/test/search-fusion.test.js +90 -90
  105. package/test/semantic.test.js +124 -124
  106. package/test/service-search.test.js +199 -199
  107. package/test/service.test.js +435 -435
  108. package/test/settings.test.js +118 -118
  109. package/test/sleep.test.js +365 -365
  110. package/test/store.test.js +436 -436
  111. package/test/stress.test.js +209 -209
  112. package/test/summarize.test.js +191 -191
  113. package/test/tag-boost.test.js +125 -125
  114. package/test/tag.test.js +312 -312
  115. package/test/tools.test.js +285 -285
  116. package/test/vector-index.test.js +221 -221
  117. package/test/wiki-link.test.js +332 -332
package/lib/store.js CHANGED
@@ -1,2238 +1,2238 @@
1
- import { DatabaseSync } from "node:sqlite";
2
- import { randomUUID } from "node:crypto";
3
- import { sanitizeTags } from "./parser/tag.js";
4
-
5
- const SCHEMA = `
6
- CREATE TABLE IF NOT EXISTS memories (
7
- id TEXT PRIMARY KEY,
8
- type TEXT NOT NULL,
9
- title TEXT NOT NULL,
10
- content TEXT NOT NULL,
11
- tags TEXT NOT NULL DEFAULT '[]',
12
- importance INTEGER NOT NULL DEFAULT 3,
13
- forgotten INTEGER NOT NULL DEFAULT 0,
14
- archived INTEGER NOT NULL DEFAULT 0,
15
- session_disposed_at TEXT,
16
- source TEXT,
17
- session_id TEXT,
18
- content_history TEXT,
19
- embedding TEXT,
20
- epistemic_status TEXT NOT NULL DEFAULT 'subjective',
21
- last_accessed_at TEXT,
22
- _full_content TEXT,
23
- created_at TEXT NOT NULL,
24
- updated_at TEXT NOT NULL
25
- );
26
- CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
27
- CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance);
28
-
29
- -- autoDream audit trail: one row per consolidation run, capturing the exact
30
- -- input snapshot digest + the LLM decision list + per-id outcome + a compact
31
- -- receipt. This makes every decision replayable so silent consolidation errors
32
- -- (high pass rate but wrong merge/conflict) can be located after the fact.
33
- CREATE TABLE IF NOT EXISTS dream_runs (
34
- id TEXT PRIMARY KEY,
35
- created_at TEXT NOT NULL,
36
- status TEXT NOT NULL, -- ok | noop | degraded | reconcile | failed
37
- error TEXT,
38
- provider TEXT,
39
- model TEXT,
40
- snapshot_hash TEXT NOT NULL,
41
- input_count INTEGER NOT NULL,
42
- input TEXT, -- JSON: full input snapshot (id/type/title/content/importance/updated_at)
43
- decisions TEXT, -- JSON: raw LLM decision list
44
- outcome TEXT, -- JSON: { byId: {id: action} }
45
- applied INTEGER NOT NULL DEFAULT 0,
46
- summary_stored INTEGER NOT NULL DEFAULT 0,
47
- receipt TEXT NOT NULL,
48
- policy_epoch INTEGER NOT NULL DEFAULT 0, -- 裁决规则版本:规则升级后旧裁决降级为历史证据
49
- run_type TEXT NOT NULL DEFAULT 'auto' -- auto | sleep:睡眠周期的审计区分
50
- );
51
- CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
52
-
53
- -- recall_runs: recall-layer receipt. One row per retrieval scene — the query,
54
- -- mode, top-k, threshold and the exact candidate list (id/title/content/score/
55
- -- source) that was returned — so retrieval behavior can be audited and
56
- -- replayed after the fact. Sibling of the dream judgment-layer audit trail.
57
- CREATE TABLE IF NOT EXISTS recall_runs (
58
- id TEXT PRIMARY KEY,
59
- query TEXT NOT NULL,
60
- mode TEXT NOT NULL,
61
- top_k INTEGER,
62
- threshold REAL,
63
- candidates TEXT NOT NULL, -- JSON: 召回候选数组(含 id/title/content/score/source)
64
- created_at TEXT NOT NULL
65
- );
66
- CREATE INDEX IF NOT EXISTS idx_recall_runs_created ON recall_runs(created_at);
67
- CREATE INDEX IF NOT EXISTS idx_recall_runs_query ON recall_runs(query);
68
-
69
- -- recall_evals: retrieval evaluation/test snapshots, kept SEPARATE from the
70
- -- recall_runs production audit so test runs never inflate the production trail.
71
- -- One row per evaluateRetrieval call that opted into persistence
72
- -- (config.evalPersistTestResults): the query, the expected ids the operator
73
- -- marked relevant, the actual ids retrieval returned, and the computed
74
- -- metrics (precision/recall/mrr). recall_run_id optionally links to the
75
- -- recall_runs audit row that captured the same retrieval scene (null when the
76
- -- eval did not also record a run). Bookkeeping like the other audit tables: it
77
- -- never triggers write hooks.
78
- CREATE TABLE IF NOT EXISTS recall_evals (
79
- id TEXT PRIMARY KEY,
80
- recall_run_id TEXT, -- FK → recall_runs.id (optional linkage)
81
- query TEXT NOT NULL,
82
- expected_ids TEXT NOT NULL, -- JSON: relevant ids expected by the evaluator
83
- actual_ids TEXT NOT NULL, -- JSON: ids actually retrieved
84
- metrics TEXT NOT NULL, -- JSON: { precision, recall, mrr, hit_count }
85
- eval_type TEXT NOT NULL DEFAULT 'manual',
86
- created_at TEXT NOT NULL,
87
- FOREIGN KEY (recall_run_id) REFERENCES recall_runs(id)
88
- );
89
- CREATE INDEX IF NOT EXISTS idx_recall_evals_created ON recall_evals(created_at);
90
- CREATE INDEX IF NOT EXISTS idx_recall_evals_run ON recall_evals(recall_run_id);
91
-
92
- -- failure_memories: records user corrections / reflection failures. Captures
93
- -- what a memory was (actual) vs what the user changed it to (expected)
94
- -- so later reflection passes can mine recurring correction patterns.
95
- -- before holds a JSON snapshot of the pre-change title/content/importance,
96
- -- so a title-only or importance-only correction is still traceable.
97
- CREATE TABLE IF NOT EXISTS failure_memories (
98
- id TEXT PRIMARY KEY,
99
- query TEXT,
100
- expected TEXT,
101
- actual TEXT,
102
- before TEXT,
103
- failure_type TEXT NOT NULL,
104
- memory_id TEXT,
105
- created_at TEXT NOT NULL
106
- );
107
- CREATE INDEX IF NOT EXISTS idx_failure_memories_created ON failure_memories(created_at);
108
- CREATE INDEX IF NOT EXISTS idx_failure_memories_type ON failure_memories(failure_type);
109
-
110
- -- receipt_chain: per-record receipt chain. One row per mutable verdict
111
- -- (merge/conflict/update), carrying the input digest (the basis of the
112
- -- decision, content-addressed) and the idempotency check counters
113
- -- count_before → count_after. Replaying the same decision must reproduce the
114
- -- same result; a digest match with a divergent outcome pinpoints drift to the
115
- -- specific record/run. Sibling of the run-level dream audit trail.
116
- CREATE TABLE IF NOT EXISTS receipt_chain (
117
- receipt_id TEXT PRIMARY KEY,
118
- run_id TEXT NOT NULL,
119
- record_id TEXT NOT NULL,
120
- kind TEXT NOT NULL, -- merge | conflict | update
121
- input_digest TEXT NOT NULL,
122
- winner_id TEXT,
123
- loser_id TEXT,
124
- keep_source TEXT,
125
- sources TEXT, -- JSON: merge 全部参与 id 数组
126
- verdict TEXT NOT NULL, -- live | revoked | historical
127
- count_before INTEGER NOT NULL,
128
- count_after INTEGER NOT NULL,
129
- policy_epoch INTEGER NOT NULL DEFAULT 0,
130
- created_at TEXT NOT NULL
131
- );
132
- CREATE INDEX IF NOT EXISTS idx_receipt_chain_record ON receipt_chain(record_id);
133
- CREATE INDEX IF NOT EXISTS idx_receipt_chain_run ON receipt_chain(run_id);
134
-
135
- -- conflict_pending: conflicts parked for manual review (conflict freeze mode,
136
- -- opt-in via config.conflictFreezeEnabled). When enabled, the dream layer does
137
- -- NOT auto-adjudicate winner/loser — the conflicting pair is parked here until
138
- -- a human reviews it. resolveConflictPending stamps resolved_at (plus the chosen
139
- -- winner) so the review action stays auditable. Like the other audit tables this
140
- -- is bookkeeping: it never triggers write hooks.
141
- CREATE TABLE IF NOT EXISTS conflict_pending (
142
- id TEXT PRIMARY KEY,
143
- run_id TEXT,
144
- memory_a TEXT NOT NULL,
145
- memory_b TEXT NOT NULL,
146
- reason TEXT,
147
- created_at TEXT NOT NULL,
148
- resolved_at TEXT,
149
- resolved_winner TEXT
150
- );
151
- CREATE INDEX IF NOT EXISTS idx_conflict_pending_unresolved ON conflict_pending(resolved_at);
152
-
153
- -- llm_audit_logs: every background LLM call (autoDream consolidation + summary,
154
- -- autoSummarize compression) is recorded here — tokens in/out, duration, status
155
- -- and the trigger that caused it (Bug8). Failures are captured as status='error'
156
- -- and never block the calling feature. retentionDays is enforced by a boot-time
157
- -- purge (deleteOldLlmAudits). Bookkeeping like the other audit tables: it never
158
- -- triggers write hooks.
159
- CREATE TABLE IF NOT EXISTS llm_audit_logs (
160
- id INTEGER PRIMARY KEY AUTOINCREMENT,
161
- timestamp TEXT NOT NULL,
162
- trigger_source TEXT NOT NULL, -- autoDream | autoSummarize | manual ...
163
- operation_type TEXT NOT NULL, -- dream_consolidate | dream_summarize | summarize_compress ...
164
- model_id TEXT NOT NULL,
165
- input_tokens INTEGER NOT NULL DEFAULT 0,
166
- output_tokens INTEGER NOT NULL DEFAULT 0,
167
- total_tokens INTEGER NOT NULL DEFAULT 0,
168
- cost_usd REAL NOT NULL DEFAULT 0,
169
- duration_ms INTEGER NOT NULL DEFAULT 0,
170
- status TEXT NOT NULL, -- success | error | skipped
171
- error_message TEXT,
172
- related_memory_ids TEXT, -- JSON: ids the call operated on
173
- metadata TEXT -- JSON: free-form extras
174
- );
175
- CREATE INDEX IF NOT EXISTS idx_llm_audit_timestamp ON llm_audit_logs(timestamp);
176
- CREATE INDEX IF NOT EXISTS idx_llm_audit_source ON llm_audit_logs(trigger_source);
177
-
178
- -- entity gene (v0.3.0): named entities mentioned across memories, with
179
- -- time-boxed attributes (valid_from → valid_until) and typed relations.
180
- -- Attributes follow the snapshot style: saveAttr invalidates the previous
181
- -- value for the same entity+key before inserting a new row, so the current
182
- -- value is always the row with valid_until IS NULL.
183
- CREATE TABLE IF NOT EXISTS entities (
184
- id TEXT PRIMARY KEY,
185
- name TEXT NOT NULL,
186
- type TEXT,
187
- first_seen TEXT NOT NULL,
188
- last_seen TEXT NOT NULL,
189
- mention_count INTEGER DEFAULT 1,
190
- canonical_memory_id TEXT
191
- );
192
- CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
193
- CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(type);
194
-
195
- CREATE TABLE IF NOT EXISTS entity_attrs (
196
- id TEXT PRIMARY KEY,
197
- entity_id TEXT NOT NULL,
198
- attr_key TEXT NOT NULL,
199
- attr_value TEXT NOT NULL,
200
- memory_id TEXT,
201
- valid_from TEXT NOT NULL,
202
- valid_until TEXT,
203
- confidence REAL DEFAULT 1.0,
204
- source TEXT
205
- );
206
- CREATE INDEX IF NOT EXISTS idx_attrs_entity ON entity_attrs(entity_id);
207
- CREATE INDEX IF NOT EXISTS idx_attrs_key ON entity_attrs(attr_key);
208
- CREATE INDEX IF NOT EXISTS idx_attrs_valid ON entity_attrs(valid_from, valid_until);
209
- CREATE INDEX IF NOT EXISTS idx_attrs_memory ON entity_attrs(memory_id);
210
-
211
- CREATE TABLE IF NOT EXISTS entity_relations (
212
- id TEXT PRIMARY KEY,
213
- from_entity TEXT NOT NULL,
214
- to_entity TEXT NOT NULL,
215
- relation_type TEXT NOT NULL,
216
- memory_id TEXT,
217
- created_at TEXT NOT NULL,
218
- metadata TEXT
219
- );
220
- CREATE INDEX IF NOT EXISTS idx_relations_from ON entity_relations(from_entity);
221
- CREATE INDEX IF NOT EXISTS idx_relations_to ON entity_relations(to_entity);
222
- CREATE INDEX IF NOT EXISTS idx_relations_type ON entity_relations(relation_type);
223
-
224
- -- mirror 渲染状态 (F-NEW-03): 单行持久记录 mirror 同步失败/成功状态,使
225
- -- syncMirror 失败不再只靠瞬时 console.warn —— dirty=1 提示镜像脏了需重渲染,
226
- -- last_error/last_attempt 记录失败原因与最近尝试,success_at 记录最近成功。
227
- -- 上层可据此在启动时重试、提供人工 reconcile 入口与健康状态查询。
228
- -- v0.3.6: 新增 generation/applied_generation/type_status —— desired-applied
229
- -- 建模镜像债务:generation 是期望同步轮次,applied_generation 是已成功应用
230
- -- 轮次(成功清 dirty 必须 CAS/fence 到具体轮次,旧 worker 不能清新故障),
231
- -- type_status 逐 type 记录部分成功状态。旧库经 PRAGMA table_info 检查后
232
- -- ALTER 补列,幂等且不丢数据。
233
- CREATE TABLE IF NOT EXISTS mirror_state (
234
- id TEXT PRIMARY KEY, -- 单一状态行(用 'main')
235
- dirty INTEGER NOT NULL DEFAULT 0, -- 1=镜像脏了需重渲染
236
- last_error TEXT, -- 最近失败原因
237
- last_attempt TEXT, -- 最近尝试时间(ISO)
238
- success_at TEXT, -- 最近成功时间(ISO)
239
- generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0 AND generation <= 9007199254740991 AND generation = CAST(generation AS INTEGER)), -- 期望的同步轮次(desired)
240
- applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991 AND applied_generation = CAST(applied_generation AS INTEGER)), -- 已成功应用的轮次
241
- type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
242
- );
243
- `;
244
-
245
- const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern"]);
246
-
247
- // Epistemic status: what kind of evidence a memory rests on. Defaults to
248
- // 'subjective' so legacy rows (and rows without any signal) stay compatible.
249
- const EPISTEMIC_STATUSES = new Set(["observation", "subjective", "inferred"]);
250
- // Rule-based inference markers, checked in priority order (observation >
251
- // inferred > subjective). The default fallback is 'subjective'.
252
- const OBSERVATION_RE = /实测|观察到|观测|测得|测量|结果表明|数据显示|实验|统计|结果/;
253
- const INFERRED_RE = /推断|推测出|推导|推论|由此可|据此|综上|意味着|所以|因此/;
254
- const SUBJECTIVE_RE = /我推测|我猜|我觉得|我感觉|可能|大概|也许|认为|猜想|似乎|猜测|感觉/;
255
-
256
- /**
257
- * Heuristically infer a memory's epistemic status from its content (and the
258
- * AI-generated types). summary/pattern entries are always 'inferred' (derived
259
- * from other memories); otherwise content markers decide. Pure rule-based, so
260
- * it never throws and always returns a value in EPISTEMIC_STATUSES.
261
- */
262
- function inferEpistemicStatus(memory) {
263
- if (memory.type === "summary" || memory.type === "pattern") return "inferred";
264
- const text = `${memory.title ?? ""} ${memory.content ?? ""}`;
265
- if (OBSERVATION_RE.test(text)) return "observation";
266
- if (INFERRED_RE.test(text)) return "inferred";
267
- if (SUBJECTIVE_RE.test(text)) return "subjective";
268
- return "subjective";
269
- }
270
-
271
- /** Resolve a requested epistemic_status: explicit valid value wins, otherwise
272
- * re-infer from (possibly updated) content. Never returns an invalid value. */
273
- function resolveEpistemicStatus(memory, patch) {
274
- if (patch?.epistemic_status !== undefined) {
275
- return EPISTEMIC_STATUSES.has(patch.epistemic_status) ? patch.epistemic_status : "subjective";
276
- }
277
- // Re-infer whenever any signal that feeds the heuristic changed: content
278
- // (marker words), title (marker words), or type (summary/pattern are always
279
- // inferred). Otherwise keep the stored status.
280
- const changed = ["content", "title", "type"].some(
281
- (k) => patch?.[k] !== undefined && patch[k] !== memory?.[k]
282
- );
283
- if (changed) {
284
- return inferEpistemicStatus({ ...memory, ...patch });
285
- }
286
- return memory?.epistemic_status ?? "subjective";
287
- }
288
-
289
- // Per-type mirror sync receipts (peer blocker 4): a type is either committed
290
- // (file written + fence applied), failed (last sync round errored for it), or
291
- // pending (still owed a write).
292
- const VALID_TYPE_STATUS = new Set(["committed", "failed", "pending"]);
293
-
294
- // Pure helpers: no shared module state.
295
-
296
- function sanitizePage(limit, offset, defaultLimit) {
297
- const lim = Number.isInteger(limit) && limit > 0 ? limit : defaultLimit;
298
- const off = Number.isInteger(offset) && offset > 0 ? offset : 0;
299
- return { limit: lim, offset: off };
300
- }
301
-
302
- function escapeLike(q) {
303
- return q.replace(/[\\%_]/g, (c) => `\\${c}`);
304
- }
305
-
306
- function parseTags(raw) {
307
- try {
308
- const arr = JSON.parse(raw);
309
- return Array.isArray(arr) ? arr : [];
310
- } catch {
311
- return [];
312
- }
313
- }
314
-
315
- function toRow(row) {
316
- if (!row) return undefined;
317
- return {
318
- id: row.id,
319
- type: row.type,
320
- title: row.title,
321
- content: row.content,
322
- tags: parseTags(row.tags),
323
- importance: row.importance,
324
- forgotten: row.forgotten === 1,
325
- archived: row.archived === 1,
326
- session_disposed_at: row.session_disposed_at ?? undefined,
327
- source: row.source ?? undefined,
328
- session_id: row.session_id ?? undefined,
329
- content_history: parseJsonArray(row.content_history),
330
- quality_score: row.quality_score !== null && row.quality_score !== undefined ? Number(row.quality_score) : undefined,
331
- epistemic_status: row.epistemic_status ?? "subjective",
332
- created_at: row.created_at,
333
- updated_at: row.updated_at,
334
- last_accessed_at: row.last_accessed_at ?? undefined,
335
- _full_content: row._full_content ?? undefined
336
- };
337
- }
338
-
339
- function toDreamRun(row) {
340
- if (!row) return undefined;
341
- return {
342
- id: row.id,
343
- created_at: row.created_at,
344
- status: row.status,
345
- error: row.error ?? undefined,
346
- provider: row.provider ?? undefined,
347
- model: row.model ?? undefined,
348
- snapshot_hash: row.snapshot_hash,
349
- input_count: row.input_count,
350
- input: row.input ? JSON.parse(row.input) : undefined,
351
- decisions: row.decisions ? JSON.parse(row.decisions) : undefined,
352
- outcome: row.outcome ? JSON.parse(row.outcome) : undefined,
353
- applied: row.applied,
354
- summary_stored: row.summary_stored === 1,
355
- receipt: row.receipt,
356
- policy_epoch: row.policy_epoch ?? 0,
357
- run_type: row.run_type ?? "auto"
358
- };
359
- }
360
-
361
- function toReceipt(row) {
362
- if (!row) return undefined;
363
- return {
364
- receipt_id: row.receipt_id,
365
- run_id: row.run_id,
366
- record_id: row.record_id,
367
- kind: row.kind,
368
- input_digest: row.input_digest,
369
- winner_id: row.winner_id ?? undefined,
370
- loser_id: row.loser_id ?? undefined,
371
- keep_source: row.keep_source ?? undefined,
372
- sources: parseJsonArray(row.sources),
373
- verdict: row.verdict,
374
- count_before: row.count_before,
375
- count_after: row.count_after,
376
- policy_epoch: row.policy_epoch ?? 0,
377
- created_at: row.created_at
378
- };
379
- }
380
-
381
- function toConflictPending(row) {
382
- if (!row) return undefined;
383
- return {
384
- id: row.id,
385
- run_id: row.run_id ?? undefined,
386
- memory_a: row.memory_a,
387
- memory_b: row.memory_b,
388
- reason: row.reason ?? undefined,
389
- created_at: row.created_at,
390
- resolved_at: row.resolved_at ?? undefined,
391
- resolved_winner: row.resolved_winner ?? undefined
392
- };
393
- }
394
-
395
- function toRecallRun(row) {
396
- if (!row) return undefined;
397
- return {
398
- id: row.id,
399
- query: row.query,
400
- mode: row.mode,
401
- topK: row.top_k,
402
- threshold: row.threshold,
403
- candidates: parseJsonArray(row.candidates),
404
- created_at: row.created_at
405
- };
406
- }
407
-
408
- function toRecallEval(row) {
409
- if (!row) return undefined;
410
- let metrics;
411
- if (row.metrics != null) {
412
- try { metrics = JSON.parse(row.metrics); } catch { metrics = undefined; }
413
- }
414
- return {
415
- id: row.id,
416
- recall_run_id: row.recall_run_id ?? undefined,
417
- query: row.query,
418
- expected_ids: parseJsonArray(row.expected_ids),
419
- actual_ids: parseJsonArray(row.actual_ids),
420
- metrics,
421
- eval_type: row.eval_type,
422
- created_at: row.created_at
423
- };
424
- }
425
-
426
- function toEntity(row) {
427
- if (!row) return undefined;
428
- return {
429
- id: row.id,
430
- name: row.name,
431
- type: row.type ?? undefined,
432
- first_seen: row.first_seen,
433
- last_seen: row.last_seen,
434
- mention_count: row.mention_count,
435
- canonical_memory_id: row.canonical_memory_id ?? undefined
436
- };
437
- }
438
-
439
- function toAttr(row) {
440
- if (!row) return undefined;
441
- return {
442
- id: row.id,
443
- entity_id: row.entity_id,
444
- attr_key: row.attr_key,
445
- attr_value: row.attr_value,
446
- memory_id: row.memory_id ?? undefined,
447
- valid_from: row.valid_from,
448
- valid_until: row.valid_until ?? undefined,
449
- confidence: row.confidence,
450
- source: row.source ?? undefined
451
- };
452
- }
453
-
454
- function toRelation(row) {
455
- if (!row) return undefined;
456
- let metadata;
457
- if (row.metadata != null) {
458
- try {
459
- metadata = JSON.parse(row.metadata);
460
- } catch {
461
- metadata = row.metadata;
462
- }
463
- }
464
- return {
465
- id: row.id,
466
- from_entity: row.from_entity,
467
- to_entity: row.to_entity,
468
- relation_type: row.relation_type,
469
- memory_id: row.memory_id ?? undefined,
470
- created_at: row.created_at,
471
- metadata
472
- };
473
- }
474
-
475
- function toLlmAudit(row) {
476
- if (!row) return undefined;
477
- let metadata;
478
- if (row.metadata != null) {
479
- try {
480
- metadata = JSON.parse(row.metadata);
481
- } catch {
482
- metadata = row.metadata;
483
- }
484
- }
485
- return {
486
- id: row.id,
487
- timestamp: row.timestamp,
488
- trigger_source: row.trigger_source,
489
- operation_type: row.operation_type,
490
- model_id: row.model_id,
491
- input_tokens: row.input_tokens,
492
- output_tokens: row.output_tokens,
493
- total_tokens: row.total_tokens,
494
- cost_usd: row.cost_usd,
495
- duration_ms: row.duration_ms,
496
- status: row.status,
497
- error_message: row.error_message ?? undefined,
498
- related_memory_ids: parseJsonArray(row.related_memory_ids),
499
- metadata
500
- };
501
- }
502
-
503
- function toMirrorState(row) {
504
- if (!row) {
505
- return {
506
- dirty: false,
507
- last_error: null,
508
- last_attempt: null,
509
- success_at: null,
510
- generation: 0,
511
- applied_generation: 0,
512
- type_status: {}
513
- };
514
- }
515
- let typeStatus = {};
516
- if (row.type_status) {
517
- try {
518
- typeStatus = JSON.parse(row.type_status) || {};
519
- } catch {
520
- typeStatus = {};
521
- }
522
- }
523
- return {
524
- id: row.id,
525
- dirty: row.dirty === 1,
526
- last_error: row.last_error,
527
- last_attempt: row.last_attempt,
528
- success_at: row.success_at,
529
- generation: Number(row.generation) || 0,
530
- applied_generation: Number(row.applied_generation) || 0,
531
- type_status: typeStatus
532
- };
533
- }
534
-
535
- function parseJsonArray(raw) {
536
- try {
537
- const arr = JSON.parse(raw);
538
- return Array.isArray(arr) ? arr : [];
539
- } catch {
540
- return [];
541
- }
542
- }
543
-
544
- export function createStore(path) {
545
- const db = new DatabaseSync(path);
546
- // Set busy_timeout BEFORE the journal-mode switch (audit peer: 8-process WAL
547
- // init). Switching a fresh DB to WAL takes an exclusive lock; when several
548
- // processes open the same path simultaneously, that lock can fail with
549
- // SQLITE_BUSY before the timeout is armed. With the timeout installed first,
550
- // the WAL transition (and every later write) blocks and retries instead of
551
- // failing outright, so concurrent init converges to a stable 447/447.
552
- db.exec("PRAGMA busy_timeout = 5000;");
553
- db.exec("PRAGMA journal_mode = WAL;");
554
- db.exec(SCHEMA);
555
-
556
- // Wiki-link dedup (v0.6.1): a (from_entity, to_entity) pair is unique only for
557
- // relation_type='links_to'. This is a PARTIAL index scoped to links_to, so the
558
- // append-only semantics of all other relation types (uses/depends_on/part_of/
559
- // related_to/supersedes — the extractor and autoDream write these per-run
560
- // without global dedup, and supersedes rows carry distinct metadata like
561
- // attr_key/old_value) are preserved. Idempotent (IF NOT EXISTS), atomic, and
562
- // race-safe. Legacy DBs have no links_to rows yet, so the index builds cleanly
563
- // everywhere and never breaks plugin startup (a full-table UNIQUE index would
564
- // fail on legacy duplicates).
565
- db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_relations_wikilink ON entity_relations(from_entity, to_entity, relation_type) WHERE relation_type = 'links_to'");
566
-
567
- // Schema migrations for legacy databases (idempotent). Each ADD COLUMN is
568
- // also race-safe: two concurrently-opening processes can both pass the
569
- // PRAGMA table_info check before either ALTERs, so the ALTER itself is
570
- // guarded against the "duplicate column name" error SQLite raises when the
571
- // other process won the race (SQLite has no ADD COLUMN IF NOT EXISTS).
572
- const addColumn = (table, column, ddl) => {
573
- const cols = db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name);
574
- if (!cols.includes(column)) {
575
- try {
576
- db.exec(ddl);
577
- } catch (e) {
578
- if (!/duplicate column name/i.test(String(e?.message ?? e))) throw e;
579
- }
580
- }
581
- };
582
-
583
- addColumn("memories", "archived", "ALTER TABLE memories ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
584
- addColumn("memories", "session_disposed_at", "ALTER TABLE memories ADD COLUMN session_disposed_at TEXT");
585
- addColumn("memories", "embedding", "ALTER TABLE memories ADD COLUMN embedding TEXT");
586
- addColumn("memories", "last_accessed_at", "ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
587
- addColumn("memories", "_full_content", "ALTER TABLE memories ADD COLUMN _full_content TEXT");
588
- addColumn("memories", "epistemic_status", "ALTER TABLE memories ADD COLUMN epistemic_status TEXT NOT NULL DEFAULT 'subjective'");
589
- addColumn("memories", "content_history", "ALTER TABLE memories ADD COLUMN content_history TEXT");
590
- addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
591
- addColumn("memories", "session_id", "ALTER TABLE memories ADD COLUMN session_id TEXT");
592
-
593
- // Composite index for session-lifecycle queries (dispose/restore/listBySession).
594
- // Created post-migration, NOT in SCHEMA: on legacy DBs both columns arrive via
595
- // ADD COLUMN above, so the index would fail at db.exec(SCHEMA) time. CREATE
596
- // INDEX IF NOT EXISTS is atomic, so the two-process race is safe here.
597
- db.exec("CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id, session_disposed_at)");
598
-
599
- // Legacy dream_runs without policy_epoch → backfill with the default epoch.
600
- addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
601
- addColumn("dream_runs", "run_type", "ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
602
-
603
- // Legacy mirror_state without v0.3.6 generation columns → add each missing
604
- // column idempotently (old DBs open cleanly, no data loss).
605
- addColumn("mirror_state", "generation", "ALTER TABLE mirror_state ADD COLUMN generation INTEGER NOT NULL DEFAULT 0");
606
- addColumn("mirror_state", "applied_generation", "ALTER TABLE mirror_state ADD COLUMN applied_generation INTEGER NOT NULL DEFAULT 0");
607
- addColumn("mirror_state", "type_status", "ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
608
-
609
- // Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
610
- // (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
611
- // enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
612
- // round — so surface it as a hard error on open instead of silently reading it
613
- // as a coherent generation. Fail-closed: the operator must repair or reset the
614
- // state row rather than continue with a lie.
615
- for (const col of ["generation", "applied_generation"]) {
616
- const bad = db.prepare(
617
- `SELECT id FROM mirror_state WHERE ${col} IS NOT NULL AND ${col} != CAST(${col} AS INTEGER) LIMIT 1`
618
- ).get();
619
- if (bad) {
620
- throw new RangeError(
621
- `mirror_state.${col} holds a non-integer value (legacy dirty state); ` +
622
- `repair or reset the row before opening this database`
623
- );
624
- }
625
- }
626
-
627
- // Per-instance monotonic timestamp guard: consecutive writes within the same
628
- // millisecond must still produce strictly increasing timestamps (test asserts
629
- // updated_at != created_at). State lives in the store closure, not module scope.
630
- let lastTs = "";
631
- function nowIso() {
632
- let ts = new Date().toISOString();
633
- if (lastTs && ts <= lastTs) {
634
- const d = new Date(lastTs);
635
- d.setMilliseconds(d.getMilliseconds() + 1);
636
- ts = d.toISOString();
637
- }
638
- lastTs = ts;
639
- return ts;
640
- }
641
-
642
- function count(type, { includeForgotten = false, includeArchived = false, includeDisposed = false } = {}) {
643
- const clauses = [];
644
- const params = [];
645
- if (type !== undefined) {
646
- clauses.push("type = ?");
647
- params.push(type);
648
- }
649
- if (!includeForgotten) {
650
- clauses.push("forgotten = 0");
651
- }
652
- if (!includeArchived) {
653
- clauses.push("archived = 0");
654
- }
655
- if (!includeDisposed) {
656
- clauses.push("session_disposed_at IS NULL");
657
- }
658
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
659
- return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
660
- }
661
-
662
- function getById(id) {
663
- const row = db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
664
- return toRow(row);
665
- }
666
-
667
- /**
668
- * Case-insensitive exact title lookup (v0.6.1 wiki-link). COLLATE NOCASE
669
- * folds ASCII case (CJK titles are inherently case-free, so they match
670
- * verbatim). Returns the first matching memory or undefined. Best-effort —
671
- * used by wiki-link target resolution and the read APIs.
672
- */
673
- function findByTitle(title) {
674
- if (typeof title !== "string" || !title.trim()) return undefined;
675
- return toRow(db.prepare(
676
- "SELECT * FROM memories WHERE title = ? COLLATE NOCASE LIMIT 1"
677
- ).get(title.trim()));
678
- }
679
-
680
- function save(memory) {
681
- const id = memory.id ?? randomUUID();
682
- const type = memory.type;
683
- if (!TYPES.has(type)) throw new Error(`invalid memory type: ${type}`);
684
- if (memory.tags !== undefined && !Array.isArray(memory.tags)) {
685
- throw new Error("tags must be an array");
686
- }
687
- const now = nowIso();
688
- const tags = JSON.stringify(memory.tags ?? []);
689
- const importance = Number.isInteger(memory.importance) ? memory.importance : 3;
690
- const embedding = Array.isArray(memory.embedding) && memory.embedding.length
691
- ? JSON.stringify(memory.embedding)
692
- : null;
693
- // Explicit valid status wins; otherwise infer from content/type. Falls back
694
- // to 'subjective' (the column default) so legacy callers never break.
695
- const epistemicStatus = EPISTEMIC_STATUSES.has(memory.epistemic_status)
696
- ? memory.epistemic_status
697
- : inferEpistemicStatus(memory);
698
- runAtomically(() => {
699
- db.prepare(
700
- `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, session_id, content_history, quality_score, embedding, epistemic_status, created_at, updated_at)
701
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
702
- ).run(
703
- id,
704
- type,
705
- memory.title,
706
- memory.content,
707
- tags,
708
- importance,
709
- memory.archived ? 1 : 0,
710
- memory.source ?? null,
711
- memory.session_id ?? null,
712
- JSON.stringify(memory.content_history ?? []),
713
- Number.isFinite(memory.quality_score) ? memory.quality_score : null,
714
- embedding,
715
- epistemicStatus,
716
- now,
717
- now
718
- );
719
- // desired generation bumped in the same transaction as the write: once
720
- // this commits, generation > applied_generation, so a crash right after
721
- // (before syncMirror) is caught by recoverMirror on restart (peer
722
- // blocker 1). ROLLBACK on error rolls this back with the write.
723
- incrementGeneration();
724
- });
725
- return getById(id);
726
- }
727
-
728
- function update(id, patch) {
729
- const existing = getById(id);
730
- if (!existing) throw new Error(`memory not found: ${id}`);
731
- const type = patch.type ?? existing.type;
732
- if (!TYPES.has(type)) throw new Error(`invalid memory type: ${type}`);
733
- if (patch.tags !== undefined && !Array.isArray(patch.tags)) {
734
- throw new Error("tags must be an array");
735
- }
736
- const now = nowIso();
737
- const embedding = patch.embedding !== undefined
738
- ? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
739
- : existing.embedding ?? null;
740
- const epistemicStatus = resolveEpistemicStatus(existing, patch);
741
- const contentHistory = Array.isArray(patch.content_history)
742
- ? JSON.stringify(patch.content_history)
743
- : (Array.isArray(existing.content_history) ? JSON.stringify(existing.content_history) : null);
744
- const qualityScore = patch.quality_score !== undefined && Number.isFinite(patch.quality_score)
745
- ? patch.quality_score
746
- : (existing.quality_score ?? null);
747
- runAtomically(() => {
748
- db.prepare(
749
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, updated_at=? WHERE id=?`
750
- ).run(
751
- type,
752
- patch.title ?? existing.title,
753
- patch.content ?? existing.content,
754
- JSON.stringify(patch.tags ?? existing.tags),
755
- Number.isInteger(patch.importance) ? patch.importance : existing.importance,
756
- patch.source !== undefined ? patch.source : (existing.source ?? null),
757
- contentHistory,
758
- qualityScore,
759
- embedding,
760
- epistemicStatus,
761
- now,
762
- id
763
- );
764
- // Desired generation bumped in the same transaction as the update (peer
765
- // blocker 1: crash between write and sync must still be recoverable).
766
- incrementGeneration();
767
- });
768
- return getById(id);
769
- }
770
-
771
- function remove(id) {
772
- runAtomically(() => {
773
- db.prepare("DELETE FROM memories WHERE id = ?").run(id);
774
- // Mirror sync must reflect the deletion; bump desired generation so a
775
- // crash between the delete and syncMirror leaves a recoverable debt.
776
- incrementGeneration();
777
- });
778
- }
779
-
780
- /**
781
- * Atomic compare-and-set update: applies `patch` only when the row still
782
- * carries `expectedUpdatedAt` (the version token read by the caller). Returns
783
- * the updated memory on success, or undefined when the row changed since the
784
- * caller read it — the caller must re-read and retry. The version guard lives
785
- * in the UPDATE's WHERE clause, so a concurrent read-modify-write across
786
- * connections cannot silently overwrite a newer value (lost update).
787
- */
788
- function compareAndUpdate(id, expectedUpdatedAt, patch) {
789
- const existing = getById(id);
790
- if (!existing) throw new Error(`memory not found: ${id}`);
791
- const type = patch.type ?? existing.type;
792
- if (!TYPES.has(type)) throw new Error(`invalid memory type: ${type}`);
793
- if (patch.tags !== undefined && !Array.isArray(patch.tags)) {
794
- throw new Error("tags must be an array");
795
- }
796
- const now = nowIso();
797
- const embedding = patch.embedding !== undefined
798
- ? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
799
- : existing.embedding ?? null;
800
- const epistemicStatus = resolveEpistemicStatus(existing, patch);
801
- const contentHistory = Array.isArray(patch.content_history)
802
- ? JSON.stringify(patch.content_history)
803
- : (Array.isArray(existing.content_history) ? JSON.stringify(existing.content_history) : null);
804
- const qualityScore = patch.quality_score !== undefined && Number.isFinite(patch.quality_score)
805
- ? patch.quality_score
806
- : (existing.quality_score ?? null);
807
- // The CAS UPDATE and the desired-generation bump must commit together (audit
808
- // peer A): if the UPDATE autocommits first and the process dies before the
809
- // increment, the store is mutated while generation == applied_generation and
810
- // dirty == false — recoverMirror sees no debt and the mirror stays stale.
811
- // Wrapping both in one transaction means a CAS miss rolls back cleanly too
812
- // (no write, no generation bump).
813
- let applied = false;
814
- runAtomically(() => {
815
- const result = db.prepare(
816
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, updated_at=?
817
- WHERE id=? AND updated_at=?`
818
- ).run(
819
- type,
820
- patch.title ?? existing.title,
821
- patch.content ?? existing.content,
822
- JSON.stringify(patch.tags ?? existing.tags),
823
- Number.isInteger(patch.importance) ? patch.importance : existing.importance,
824
- patch.source !== undefined ? patch.source : (existing.source ?? null),
825
- contentHistory,
826
- qualityScore,
827
- embedding,
828
- epistemicStatus,
829
- now,
830
- id,
831
- expectedUpdatedAt
832
- );
833
- if (result.changes === 0) return; // CAS miss: a concurrent write won
834
- // Only bump desired generation on a successful CAS — a miss writes nothing.
835
- incrementGeneration();
836
- applied = true;
837
- });
838
- if (!applied) return undefined;
839
- return getById(id);
840
- }
841
-
842
- function setForget(id, forgotten) {
843
- runAtomically(() => {
844
- db.prepare("UPDATE memories SET forgotten = ?, updated_at = ? WHERE id = ?")
845
- .run(forgotten === true || forgotten === 1 ? 1 : 0, nowIso(), id);
846
- incrementGeneration();
847
- });
848
- return getById(id);
849
- }
850
-
851
- function setArchived(id, archived) {
852
- runAtomically(() => {
853
- db.prepare("UPDATE memories SET archived = ?, updated_at = ? WHERE id = ?")
854
- .run(archived ? 1 : 0, nowIso(), id);
855
- incrementGeneration();
856
- });
857
- return getById(id);
858
- }
859
-
860
- // --- session lifecycle (v0.6.0) ------------------------------------------
861
- // Session dispose is orthogonal to `archived`: memory_archive is the user/AI
862
- // choosing to keep an entry long-term-but-quiet, while session_disposed_at
863
- // marks entries hidden because the session they were born in was deleted
864
- // (a reversible "undo" — restoreBySession clears it). They never clobber each
865
- // other: restoreBySession must not resurrect user-archived memories.
866
- // Mirrors list/search: disposed rows are hidden by default. A consumer that
867
- // needs to see the full picture (e.g. a restore flow that tells the user
868
- // "these N entries were hidden") opts in via includeDisposed.
869
- function listBySession(sessionId, { includeDisposed = false } = {}) {
870
- const disposedFilter = includeDisposed ? "" : "AND session_disposed_at IS NULL";
871
- const rows = db.prepare(
872
- `SELECT * FROM memories WHERE session_id = ? ${disposedFilter} ORDER BY updated_at DESC`
873
- ).all(sessionId);
874
- return rows.map(toRow);
875
- }
876
-
877
- // Idempotent by state guard, not timestamp compare (nowIso() differs every
878
- // call, so a fresh-timestamp re-dispose would spuriously count): dispose only
879
- // touches rows that are NOT yet disposed; restore only touches rows that ARE.
880
- // updated_at is deliberately left alone — this is a lifecycle flag, not
881
- // content — so a true flip is the sole trigger for a mirror generation.
882
- function setDisposedBySession(sessionId, disposed) {
883
- const at = disposed ? nowIso() : null;
884
- let affected = 0;
885
- runAtomically(() => {
886
- const result = disposed
887
- ? db.prepare(
888
- "UPDATE memories SET session_disposed_at = ? WHERE session_id = ? AND session_disposed_at IS NULL"
889
- ).run(at, sessionId)
890
- : db.prepare(
891
- "UPDATE memories SET session_disposed_at = NULL WHERE session_id = ? AND session_disposed_at IS NOT NULL"
892
- ).run(sessionId);
893
- affected = result.changes;
894
- if (affected > 0) incrementGeneration();
895
- });
896
- return affected;
897
- }
898
-
899
- // --- sleep-mode storage support (v0.4.0) ---------------------------------
900
- // touchLastAccess stamps the read time on recall/inject paths. It deliberately
901
- // does NOT bump the mirror generation: reads must not mark the mirror dirty.
902
- function touchLastAccess(id, at) {
903
- if (!getById(id)) return false;
904
- db.prepare("UPDATE memories SET last_accessed_at = ? WHERE id = ?")
905
- .run(at ?? nowIso(), id);
906
- return true;
907
- }
908
-
909
- // Shrink an aged memory to `summary`, parking its full body in _full_content.
910
- // Idempotent: an already-demoted memory (non-null _full_content) is left
911
- // untouched. minRefTimeMs guards the fast path — if last_accessed_at moved
912
- // after the caller's snapshot (>= minRefTimeMs), the memory is hot again and
913
- // is skipped. Returns the updated memory, or undefined when skipped/absent.
914
- function demoteToSummary(id, summary, { minRefTimeMs } = {}) {
915
- let changed = false;
916
- runAtomically(() => {
917
- const row = db.prepare("SELECT last_accessed_at, content, _full_content FROM memories WHERE id = ?").get(id);
918
- if (!row || row._full_content) return;
919
- if (minRefTimeMs !== undefined && row.last_accessed_at) {
920
- const lastMs = Date.parse(row.last_accessed_at);
921
- if (lastMs >= minRefTimeMs) return; // touched after snapshot — still hot
922
- }
923
- db.prepare(
924
- "UPDATE memories SET content = ?, _full_content = ?, updated_at = ? WHERE id = ?"
925
- ).run(summary, row.content, nowIso(), id);
926
- incrementGeneration();
927
- changed = true;
928
- });
929
- return changed ? getById(id) : undefined;
930
- }
931
-
932
- // Undo demoteToSummary: pull the parked body back into content.
933
- function restoreContent(id) {
934
- let changed = false;
935
- runAtomically(() => {
936
- const row = db.prepare("SELECT content, _full_content FROM memories WHERE id = ?").get(id);
937
- if (!row || !row._full_content) return;
938
- db.prepare(
939
- "UPDATE memories SET content = ?, _full_content = NULL, updated_at = ? WHERE id = ?"
940
- ).run(row._full_content, nowIso(), id);
941
- incrementGeneration();
942
- changed = true;
943
- });
944
- return changed ? getById(id) : undefined;
945
- }
946
-
947
- // Live memories that have not been touched since `cutMs` (never-touched ones
948
- // fall back to created_at). Ordered by last access ascending — the coldest
949
- // first. Used by sleep phase 2 to pick archival-demotion candidates.
950
- function getUnrecalledSince(cutMs, { limit = 500 } = {}) {
951
- const cutIso = new Date(cutMs).toISOString();
952
- const rows = db.prepare(
953
- `SELECT * FROM memories
954
- WHERE forgotten = 0 AND archived = 0
955
- AND session_disposed_at IS NULL
956
- AND (last_accessed_at IS NULL OR last_accessed_at < ?)
957
- ORDER BY COALESCE(last_accessed_at, created_at) ASC, id
958
- LIMIT ?`
959
- ).all(cutIso, limit);
960
- return rows.map(toRow);
961
- }
962
-
963
- function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false, includeDisposed = false } = {}) {
964
- const clauses = [];
965
- const params = [];
966
- if (type) {
967
- clauses.push("type = ?");
968
- params.push(type);
969
- }
970
- if (!includeForgotten) {
971
- clauses.push("forgotten = 0");
972
- }
973
- if (!includeArchived) {
974
- clauses.push("archived = 0");
975
- }
976
- if (!includeDisposed) {
977
- clauses.push("session_disposed_at IS NULL");
978
- }
979
- const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
980
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
981
- const rows = db.prepare(
982
- `SELECT * FROM memories ${where} ORDER BY importance DESC, updated_at DESC, id LIMIT ? OFFSET ?`
983
- ).all(...params, lim, off);
984
- return rows.map(toRow);
985
- }
986
-
987
- function all() {
988
- const rows = db.prepare("SELECT * FROM memories ORDER BY updated_at DESC").all();
989
- return rows.map(toRow);
990
- }
991
-
992
- /** Set (or clear with null) the embedding vector of a memory. */
993
- function setEmbedding(id, vector) {
994
- const json = Array.isArray(vector) && vector.length ? JSON.stringify(vector) : null;
995
- db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(json, id);
996
- }
997
-
998
- /** Batch fetch stored embeddings by id (v0.5.0 search-time semantic dedup).
999
- * Returns a Map(id → number[]); rows without a parseable embedding are
1000
- * simply absent from the map. */
1001
- function getEmbeddings(ids) {
1002
- const out = new Map();
1003
- const list = (Array.isArray(ids) ? ids : []).filter(Boolean);
1004
- for (let i = 0; i < list.length; i += 100) {
1005
- const chunk = list.slice(i, i + 100);
1006
- const rows = db.prepare(
1007
- `SELECT id, embedding FROM memories
1008
- WHERE embedding IS NOT NULL AND embedding != ''
1009
- AND id IN (${chunk.map(() => "?").join(",")})`
1010
- ).all(...chunk);
1011
- for (const row of rows) {
1012
- try {
1013
- const vec = JSON.parse(row.embedding);
1014
- if (Array.isArray(vec) && vec.length) out.set(row.id, vec);
1015
- } catch { /* corrupt row: skip */ }
1016
- }
1017
- }
1018
- return out;
1019
- }
1020
-
1021
- function embeddedCount() {
1022
- return db.prepare(
1023
- "SELECT count(*) AS c FROM memories WHERE embedding IS NOT NULL AND embedding != ''"
1024
- ).get().c;
1025
- }
1026
-
1027
- /** Candidate rows still missing an embedding, for incremental re-indexing. */
1028
- function needsEmbedding(limit = 50) {
1029
- return db.prepare(
1030
- `SELECT id, title, content FROM memories
1031
- WHERE embedding IS NULL OR embedding = ''
1032
- ORDER BY updated_at DESC LIMIT ?`
1033
- ).all(limit);
1034
- }
1035
-
1036
- function search(query, { limit = 20, includeArchived = false, includeDisposed = false } = {}) {
1037
- const q = String(query).trim();
1038
- if (!q) return [];
1039
- // Plain LIKE substring scan over title/content/tags (wildcards escaped so
1040
- // user input matches literally). No FTS5: CJK substring matching needs
1041
- // LIKE, and typical memory stores are small enough that a scan is fine.
1042
- const like = `%${escapeLike(q)}%`;
1043
- const { limit: lim } = sanitizePage(limit, 0, 20);
1044
- const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
1045
- const disposedFilter = includeDisposed ? "" : "session_disposed_at IS NULL AND ";
1046
- const rows = db.prepare(
1047
- `SELECT * FROM memories
1048
- WHERE ${archivedFilter}${disposedFilter}forgotten = 0 AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')
1049
- ORDER BY
1050
- CASE WHEN title LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END,
1051
- importance DESC,
1052
- updated_at DESC,
1053
- id
1054
- LIMIT ?`
1055
- ).all(like, like, like, like, lim);
1056
- return rows.map(toRow);
1057
- }
1058
-
1059
- // --- vector search ------------------------------------------------------
1060
-
1061
- function cosine(a, b) {
1062
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || !a.length) return 0;
1063
- let dot = 0;
1064
- let na = 0;
1065
- let nb = 0;
1066
- for (let i = 0; i < a.length; i++) {
1067
- dot += a[i] * b[i];
1068
- na += a[i] * a[i];
1069
- nb += b[i] * b[i];
1070
- }
1071
- if (na === 0 || nb === 0) return 0;
1072
- return dot / (Math.sqrt(na) * Math.sqrt(nb));
1073
- }
1074
-
1075
- /**
1076
- * Brute-force cosine similarity over embedded rows. Returns rows decorated
1077
- * with a `score` (0..1). Only rows with a stored embedding participate.
1078
- */
1079
- function searchVector(vector, { limit = 20, includeArchived = false, includeDisposed = false, threshold = 0 } = {}) {
1080
- if (!Array.isArray(vector) || !vector.length) return [];
1081
- const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
1082
- const disposedFilter = includeDisposed ? "" : "session_disposed_at IS NULL AND ";
1083
- const rows = db.prepare(
1084
- `SELECT * FROM memories
1085
- WHERE ${archivedFilter}${disposedFilter}forgotten = 0 AND embedding IS NOT NULL AND embedding != ''`
1086
- ).all();
1087
- const scored = [];
1088
- for (const row of rows) {
1089
- let v;
1090
- try {
1091
- v = JSON.parse(row.embedding);
1092
- } catch {
1093
- continue;
1094
- }
1095
- const score = cosine(vector, v);
1096
- if (score >= threshold) scored.push({ row, score });
1097
- }
1098
- scored.sort((a, b) => b.score - a.score);
1099
- const { limit: lim } = sanitizePage(limit, 0, 20);
1100
- return scored.slice(0, lim).map(({ row, score }) => ({ ...toRow(row), score }));
1101
- }
1102
-
1103
- // --- autoDream audit trail ----------------------------------------------
1104
-
1105
- /**
1106
- * Persist one autoDream run. The audit row is machine-verifiable but never
1107
- * triggers write hooks (it is bookkeeping, not a memory mutation): dream
1108
- * records its own runs, and a notify here would loop back into the dream
1109
- * scheduler. Writes are idempotent on run id (replay overwrites, never
1110
- * duplicates) so the same logical run can be re-applied for verification.
1111
- */
1112
- function saveDreamRun(run) {
1113
- const id = run.id ?? randomUUID();
1114
- const now = nowIso();
1115
- const policyEpoch = Number.isInteger(run.policy_epoch) ? run.policy_epoch : 0;
1116
- const runType = run.run_type ?? "auto";
1117
- db.prepare(
1118
- `INSERT INTO dream_runs (id, created_at, status, error, provider, model, snapshot_hash,
1119
- input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch, run_type)
1120
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1121
- ON CONFLICT(id) DO UPDATE SET
1122
- created_at=excluded.created_at, status=excluded.status, error=excluded.error,
1123
- provider=excluded.provider, model=excluded.model, snapshot_hash=excluded.snapshot_hash,
1124
- input_count=excluded.input_count, input=excluded.input, decisions=excluded.decisions,
1125
- outcome=excluded.outcome, applied=excluded.applied, summary_stored=excluded.summary_stored,
1126
- receipt=excluded.receipt, policy_epoch=excluded.policy_epoch, run_type=excluded.run_type`
1127
- ).run(
1128
- id,
1129
- run.created_at ?? now,
1130
- run.status,
1131
- run.error ?? null,
1132
- run.provider ?? null,
1133
- run.model ?? null,
1134
- run.snapshot_hash,
1135
- run.input_count,
1136
- run.input !== undefined ? JSON.stringify(run.input) : null,
1137
- run.decisions !== undefined ? JSON.stringify(run.decisions) : null,
1138
- run.outcome !== undefined ? JSON.stringify(run.outcome) : null,
1139
- run.applied ?? 0,
1140
- run.summary_stored ? 1 : 0,
1141
- run.receipt,
1142
- policyEpoch,
1143
- runType
1144
- );
1145
- return getDreamRun(id);
1146
- }
1147
-
1148
- function getDreamRun(id) {
1149
- const row = db.prepare("SELECT * FROM dream_runs WHERE id = ?").get(id);
1150
- return toDreamRun(row);
1151
- }
1152
-
1153
- function listDreamRuns({ limit = 50, offset = 0 } = {}) {
1154
- const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1155
- const rows = db.prepare(
1156
- "SELECT * FROM dream_runs ORDER BY created_at DESC, id LIMIT ? OFFSET ?"
1157
- ).all(lim, off);
1158
- return rows.map(toDreamRun);
1159
- }
1160
-
1161
- /**
1162
- * Latest ruling-rule version seen on the audit trail. policy_epoch is a config
1163
- * value stamped onto each run by the caller; reading the newest row's epoch
1164
- * gives the current effective version, falling back to 0 (default) when the
1165
- * trail is empty. Rules upgrades leave older runs with their original epoch,
1166
- * so those decisions can be demoted to historical evidence.
1167
- */
1168
- function getLatestPolicyEpoch() {
1169
- const row = db.prepare(
1170
- "SELECT policy_epoch FROM dream_runs ORDER BY created_at DESC, id LIMIT 1"
1171
- ).get();
1172
- return row ? (row.policy_epoch ?? 0) : 0;
1173
- }
1174
-
1175
- // --- per-record receipt chain --------------------------------------------
1176
-
1177
- /**
1178
- * Persist one per-record receipt (a single merge/conflict/update verdict).
1179
- * The run-level dream audit trail answers "did this run happen and with what
1180
- * input"; the receipt chain drills down to each mutable verdict, carrying the
1181
- * input digest (decision basis) plus count_before → count_after idempotency
1182
- * checkpoints so replay drift can be located to the exact record/run. Like
1183
- * the dream trail this is bookkeeping: it never triggers write hooks. Writes
1184
- * are idempotent on receipt id (replay overwrites, never duplicates).
1185
- */
1186
- function saveReceipt(run) {
1187
- const id = run.receipt_id ?? randomUUID();
1188
- const now = nowIso();
1189
- const policyEpoch = Number.isInteger(run.policy_epoch) ? run.policy_epoch : 0;
1190
- db.prepare(
1191
- `INSERT INTO receipt_chain (receipt_id, run_id, record_id, kind, input_digest,
1192
- winner_id, loser_id, keep_source, sources, verdict, count_before, count_after,
1193
- policy_epoch, created_at)
1194
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1195
- ON CONFLICT(receipt_id) DO UPDATE SET
1196
- run_id=excluded.run_id, record_id=excluded.record_id, kind=excluded.kind,
1197
- input_digest=excluded.input_digest, winner_id=excluded.winner_id,
1198
- loser_id=excluded.loser_id, keep_source=excluded.keep_source,
1199
- sources=excluded.sources, verdict=excluded.verdict,
1200
- count_before=excluded.count_before, count_after=excluded.count_after,
1201
- policy_epoch=excluded.policy_epoch, created_at=excluded.created_at`
1202
- ).run(
1203
- id,
1204
- run.run_id,
1205
- run.record_id,
1206
- run.kind,
1207
- run.input_digest,
1208
- run.winner_id ?? null,
1209
- run.loser_id ?? null,
1210
- run.keep_source ?? null,
1211
- JSON.stringify(run.sources ?? []),
1212
- run.verdict,
1213
- run.count_before,
1214
- run.count_after,
1215
- policyEpoch,
1216
- run.created_at ?? now
1217
- );
1218
- return getReceipt(id);
1219
- }
1220
-
1221
- function getReceipt(id) {
1222
- const row = db.prepare("SELECT * FROM receipt_chain WHERE receipt_id = ?").get(id);
1223
- return toReceipt(row);
1224
- }
1225
-
1226
- function listReceipts({ limit = 50, offset = 0, run_id } = {}) {
1227
- const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1228
- const clauses = [];
1229
- const params = [];
1230
- if (run_id) {
1231
- clauses.push("run_id = ?");
1232
- params.push(run_id);
1233
- }
1234
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1235
- const rows = db.prepare(
1236
- `SELECT * FROM receipt_chain ${where} ORDER BY created_at DESC, receipt_id LIMIT ? OFFSET ?`
1237
- ).all(...params, lim, off);
1238
- return rows.map(toReceipt);
1239
- }
1240
-
1241
- // --- recall-layer audit trail -------------------------------------------
1242
-
1243
- /**
1244
- * Persist one recall run (the retrieval scene: query/mode/top-k/threshold +
1245
- * the exact candidate list handed to the caller). Like the dream audit trail
1246
- * this is bookkeeping, so it never triggers write hooks — a notify here would
1247
- * loop back into search itself. Writes are idempotent on run id (replay
1248
- * overwrites, never duplicates), matching saveDreamRun.
1249
- */
1250
- function saveRecallRun(run) {
1251
- const id = run.id ?? randomUUID();
1252
- db.prepare(
1253
- `INSERT INTO recall_runs (id, query, mode, top_k, threshold, candidates, created_at)
1254
- VALUES (?, ?, ?, ?, ?, ?, ?)
1255
- ON CONFLICT(id) DO UPDATE SET
1256
- query=excluded.query, mode=excluded.mode, top_k=excluded.top_k,
1257
- threshold=excluded.threshold, candidates=excluded.candidates,
1258
- created_at=excluded.created_at`
1259
- ).run(
1260
- id,
1261
- run.query,
1262
- run.mode,
1263
- run.topK ?? null,
1264
- run.threshold ?? null,
1265
- JSON.stringify(run.candidates ?? []),
1266
- run.created_at ?? nowIso()
1267
- );
1268
- return getRecallRun(id);
1269
- }
1270
-
1271
- function getRecallRun(id) {
1272
- const row = db.prepare("SELECT * FROM recall_runs WHERE id = ?").get(id);
1273
- return toRecallRun(row);
1274
- }
1275
-
1276
- function listRecallRuns({ limit = 50, offset = 0, query } = {}) {
1277
- const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1278
- const clauses = [];
1279
- const params = [];
1280
- if (query) {
1281
- clauses.push("query LIKE ? ESCAPE '\\'");
1282
- params.push(`%${escapeLike(String(query))}%`);
1283
- }
1284
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1285
- const rows = db.prepare(
1286
- `SELECT * FROM recall_runs ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`
1287
- ).all(...params, lim, off);
1288
- return rows.map(toRecallRun);
1289
- }
1290
-
1291
- // --- recall evaluation trail (方案 B: separate from the production audit) -
1292
-
1293
- /**
1294
- * Persist one retrieval-evaluation snapshot into recall_evals — the test/eval
1295
- * sibling of recall_runs, deliberately stored apart so eval snapshots never
1296
- * inflate the production recall audit. Like the other audit tables this is
1297
- * bookkeeping: it never triggers write hooks. Writes are idempotent on id
1298
- * (replay overwrites, never duplicates), matching saveRecallRun. recall_run_id
1299
- * optionally links the eval to the recall_runs row that captured the same
1300
- * retrieval scene (FK-referenced, null when no run was recorded).
1301
- */
1302
- function saveRecallEval(evalRow) {
1303
- const id = evalRow.id ?? randomUUID();
1304
- db.prepare(
1305
- `INSERT INTO recall_evals (id, recall_run_id, query, expected_ids, actual_ids, metrics, eval_type, created_at)
1306
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1307
- ON CONFLICT(id) DO UPDATE SET
1308
- recall_run_id=excluded.recall_run_id, query=excluded.query,
1309
- expected_ids=excluded.expected_ids, actual_ids=excluded.actual_ids,
1310
- metrics=excluded.metrics, eval_type=excluded.eval_type,
1311
- created_at=excluded.created_at`
1312
- ).run(
1313
- id,
1314
- evalRow.recall_run_id ?? null,
1315
- evalRow.query,
1316
- JSON.stringify(evalRow.expected_ids ?? []),
1317
- JSON.stringify(evalRow.actual_ids ?? []),
1318
- JSON.stringify(evalRow.metrics ?? {}),
1319
- evalRow.eval_type ?? "manual",
1320
- evalRow.created_at ?? nowIso()
1321
- );
1322
- return getRecallEval(id);
1323
- }
1324
-
1325
- function getRecallEval(id) {
1326
- const row = db.prepare("SELECT * FROM recall_evals WHERE id = ?").get(id);
1327
- return toRecallEval(row);
1328
- }
1329
-
1330
- function listRecallEvals({ limit = 50, offset = 0, query } = {}) {
1331
- const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1332
- const clauses = [];
1333
- const params = [];
1334
- if (query) {
1335
- clauses.push("query LIKE ? ESCAPE '\\'");
1336
- params.push(`%${escapeLike(String(query))}%`);
1337
- }
1338
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1339
- const rows = db.prepare(
1340
- `SELECT * FROM recall_evals ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`
1341
- ).all(...params, lim, off);
1342
- return rows.map(toRecallEval);
1343
- }
1344
-
1345
- // --- llm audit trail (Bug8) ---------------------------------------------
1346
-
1347
- /**
1348
- * Persist one LLM audit row (a background call's token/time/status receipt).
1349
- * Bookkeeping like the other audit tables: it never triggers write hooks, so
1350
- * recording a call can never loop back into the scheduler that made it. The
1351
- * call itself is wrapped so a failure is captured (status='error') instead of
1352
- * blocking the feature — only a throwing saveLlmAudit is swallowed, never the
1353
- * LLM call.
1354
- */
1355
- function saveLlmAudit(entry) {
1356
- const now = nowIso();
1357
- const inTokens = Number.isFinite(entry.input_tokens) ? entry.input_tokens : 0;
1358
- const outTokens = Number.isFinite(entry.output_tokens) ? entry.output_tokens : 0;
1359
- db.prepare(
1360
- `INSERT INTO llm_audit_logs (timestamp, trigger_source, operation_type, model_id,
1361
- input_tokens, output_tokens, total_tokens, cost_usd, duration_ms, status,
1362
- error_message, related_memory_ids, metadata)
1363
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1364
- ).run(
1365
- entry.timestamp ?? now,
1366
- entry.trigger_source,
1367
- entry.operation_type,
1368
- entry.model_id,
1369
- inTokens,
1370
- outTokens,
1371
- Number.isFinite(entry.total_tokens) ? entry.total_tokens : inTokens + outTokens,
1372
- Number.isFinite(entry.cost_usd) ? entry.cost_usd : 0,
1373
- Number.isFinite(entry.duration_ms) ? entry.duration_ms : 0,
1374
- entry.status ?? "success",
1375
- entry.error_message ?? null,
1376
- JSON.stringify(entry.related_memory_ids ?? []),
1377
- entry.metadata !== undefined
1378
- ? (typeof entry.metadata === "string" ? entry.metadata : JSON.stringify(entry.metadata))
1379
- : null
1380
- );
1381
- return toLlmAudit(db.prepare("SELECT * FROM llm_audit_logs ORDER BY id DESC LIMIT 1").get());
1382
- }
1383
-
1384
- function listLlmAudits({ limit = 50, offset = 0, source } = {}) {
1385
- const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1386
- const clauses = [];
1387
- const params = [];
1388
- if (source) {
1389
- clauses.push("trigger_source = ?");
1390
- params.push(source);
1391
- }
1392
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1393
- const rows = db.prepare(
1394
- `SELECT * FROM llm_audit_logs ${where} ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?`
1395
- ).all(...params, lim, off);
1396
- return rows.map(toLlmAudit);
1397
- }
1398
-
1399
- function countLlmAudits({ source } = {}) {
1400
- const clauses = [];
1401
- const params = [];
1402
- if (source) {
1403
- clauses.push("trigger_source = ?");
1404
- params.push(source);
1405
- }
1406
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1407
- return db.prepare(`SELECT count(*) AS c FROM llm_audit_logs ${where}`).get(...params).c;
1408
- }
1409
-
1410
- /**
1411
- * Aggregate LLM spend over the last `days`: total calls/tokens/duration/cost,
1412
- * broken down by trigger_source and by status. Used by the API's
1413
- * /llm-audit/stats endpoint so the Web panel can show where budget goes.
1414
- */
1415
- function getLlmAuditStats({ days = 7 } = {}) {
1416
- const since = new Date(Date.now() - days * 86400000).toISOString();
1417
- const total = db.prepare(
1418
- `SELECT count(*) AS c,
1419
- COALESCE(SUM(input_tokens), 0) AS i,
1420
- COALESCE(SUM(output_tokens), 0) AS o,
1421
- COALESCE(SUM(total_tokens), 0) AS t,
1422
- COALESCE(SUM(duration_ms), 0) AS d,
1423
- COALESCE(SUM(cost_usd), 0) AS cst
1424
- FROM llm_audit_logs WHERE timestamp >= ?`
1425
- ).get(since);
1426
- const bySource = db.prepare(
1427
- `SELECT trigger_source AS source, count(*) AS c,
1428
- COALESCE(SUM(total_tokens), 0) AS total_tokens
1429
- FROM llm_audit_logs WHERE timestamp >= ?
1430
- GROUP BY trigger_source ORDER BY total_tokens DESC`
1431
- ).all(since);
1432
- const byStatus = db.prepare(
1433
- "SELECT status, count(*) AS c FROM llm_audit_logs WHERE timestamp >= ? GROUP BY status"
1434
- ).all(since);
1435
- return {
1436
- days,
1437
- since,
1438
- total_calls: total.c,
1439
- input_tokens: total.i,
1440
- output_tokens: total.o,
1441
- total_tokens: total.t,
1442
- total_duration_ms: total.d,
1443
- total_cost_usd: Number(total.cst),
1444
- by_source: bySource,
1445
- by_status: byStatus
1446
- };
1447
- }
1448
-
1449
- /** Delete audit rows older than `before` (ISO string). Returns count removed. */
1450
- function deleteOldLlmAudits(before) {
1451
- return db.prepare("DELETE FROM llm_audit_logs WHERE timestamp < ?").run(before).changes;
1452
- }
1453
-
1454
- // --- failure memories ----------------------------------------------------
1455
-
1456
- /**
1457
- * Persist one failure record (user correction, failed expectation, etc.).
1458
- * Like the dream audit trail this is bookkeeping: it never triggers write
1459
- * hooks, so reflection mining of failures cannot loop back into the writer.
1460
- */
1461
- function saveFailure({ id, query, expected, actual, before, failure_type, memory_id }) {
1462
- const now = nowIso();
1463
- const beforeJson = before && typeof before === "object" ? JSON.stringify(before) : (before ?? null);
1464
- db.prepare(
1465
- `INSERT INTO failure_memories (id, query, expected, actual, before, failure_type, memory_id, created_at)
1466
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
1467
- ).run(id ?? randomUUID(), query ?? null, expected ?? null, actual ?? null, beforeJson, failure_type, memory_id ?? null, now);
1468
- return { id, query, expected, actual, before: before ?? null, failure_type, memory_id, created_at: now };
1469
- }
1470
-
1471
- function listFailures({ limit = 50, offset = 0, since, memory_id, failure_type } = {}) {
1472
- const clauses = [];
1473
- const params = [];
1474
- if (since) { clauses.push("created_at >= ?"); params.push(since); }
1475
- if (memory_id) { clauses.push("memory_id = ?"); params.push(memory_id); }
1476
- if (failure_type) { clauses.push("failure_type = ?"); params.push(failure_type); }
1477
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1478
- const lim = Number.isInteger(limit) && limit > 0 ? limit : 50;
1479
- const off = Number.isInteger(offset) && offset > 0 ? offset : 0;
1480
- return db.prepare(`SELECT * FROM failure_memories ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`).all(...params, lim, off)
1481
- .map((row) => {
1482
- let before;
1483
- try { before = row.before ? JSON.parse(row.before) : null; } catch { before = null; }
1484
- return { ...row, before };
1485
- });
1486
- }
1487
-
1488
- /** Delete failure rows older than `before` (ISO string). Returns count removed. */
1489
- function deleteOldFailures(before) {
1490
- return db.prepare("DELETE FROM failure_memories WHERE created_at < ?").run(before).changes;
1491
- }
1492
-
1493
- // --- conflict freeze: pending manual review ------------------------------
1494
-
1495
- /**
1496
- * Park a detected conflict for human review (conflict freeze mode). The pair
1497
- * order is normalized (sorted by id) so the same two memories are only ever
1498
- * pending once — a re-detection in a later dream run is a no-op, never a
1499
- * duplicate queue entry. Returns the pending row (freshly inserted, or the
1500
- * existing unresolved row when the pair is already pending).
1501
- */
1502
- function saveConflictPending({ run_id, memory_a, memory_b, reason }) {
1503
- const [a, b] = [memory_a, memory_b].sort();
1504
- const existing = db.prepare(
1505
- "SELECT * FROM conflict_pending WHERE memory_a = ? AND memory_b = ? AND resolved_at IS NULL LIMIT 1"
1506
- ).get(a, b);
1507
- if (existing) return toConflictPending(existing);
1508
- const id = randomUUID();
1509
- const now = nowIso();
1510
- db.prepare(
1511
- `INSERT INTO conflict_pending (id, run_id, memory_a, memory_b, reason, created_at)
1512
- VALUES (?, ?, ?, ?, ?, ?)`
1513
- ).run(id, run_id ?? null, a, b, reason ?? null, now);
1514
- return toConflictPending(db.prepare("SELECT * FROM conflict_pending WHERE id = ?").get(id));
1515
- }
1516
-
1517
- /**
1518
- * List pending conflicts, newest first. Unresolved rows only by default;
1519
- * pass includeResolved to include resolved ones (audit view).
1520
- */
1521
- function listConflictPending({ limit = 50, offset = 0, includeResolved = false } = {}) {
1522
- const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1523
- const clauses = [];
1524
- const params = [];
1525
- if (!includeResolved) clauses.push("resolved_at IS NULL");
1526
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1527
- const rows = db.prepare(
1528
- `SELECT * FROM conflict_pending ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`
1529
- ).all(...params, lim, off);
1530
- return rows.map(toConflictPending);
1531
- }
1532
-
1533
- /**
1534
- * Mark a pending conflict as reviewed. winner (optional) records which side
1535
- * the human chose, keeping the resolution auditable. Returns the updated row,
1536
- * or undefined for an unknown id.
1537
- */
1538
- function resolveConflictPending(id, { winner } = {}) {
1539
- const row = db.prepare("SELECT * FROM conflict_pending WHERE id = ?").get(id);
1540
- if (!row) return undefined;
1541
- db.prepare("UPDATE conflict_pending SET resolved_at = ?, resolved_winner = ? WHERE id = ?")
1542
- .run(nowIso(), winner ?? null, id);
1543
- return toConflictPending(db.prepare("SELECT * FROM conflict_pending WHERE id = ?").get(id));
1544
- }
1545
-
1546
- /** Number of unresolved (awaiting review) pending conflicts. */
1547
- function countConflictPending() {
1548
- return db.prepare(
1549
- "SELECT count(*) AS c FROM conflict_pending WHERE resolved_at IS NULL"
1550
- ).get().c;
1551
- }
1552
-
1553
- function getFailureStats({ since } = {}) {
1554
- const clause = since ? "WHERE created_at >= ?" : "";
1555
- const params = since ? [since] : [];
1556
- const rows = db.prepare(
1557
- `SELECT failure_type, count(*) AS c FROM failure_memories ${clause} GROUP BY failure_type`
1558
- ).all(...params);
1559
- const stats = {};
1560
- for (const row of rows) stats[row.failure_type] = row.c;
1561
- return stats;
1562
- }
1563
-
1564
- // --- entity gene: named entities + time-boxed attrs + relations (v0.3.0) --
1565
-
1566
- /**
1567
- * Create a named entity. A fresh mention always records first_seen = now;
1568
- * repeated sightings should call updateEntity (which bumps mention_count and
1569
- * refreshes last_seen) rather than creating duplicate rows.
1570
- */
1571
- function createEntity({ name, type }) {
1572
- const id = randomUUID();
1573
- const now = nowIso();
1574
- db.prepare(
1575
- `INSERT INTO entities (id, name, type, first_seen, last_seen, mention_count, canonical_memory_id)
1576
- VALUES (?, ?, ?, ?, ?, ?, ?)`
1577
- ).run(id, name, type ?? null, now, now, 1, null);
1578
- return toEntity(db.prepare("SELECT * FROM entities WHERE id = ?").get(id));
1579
- }
1580
-
1581
- function findEntityByName(name) {
1582
- return toEntity(db.prepare("SELECT * FROM entities WHERE name = ?").get(name));
1583
- }
1584
-
1585
- function findEntityById(id) {
1586
- return toEntity(db.prepare("SELECT * FROM entities WHERE id = ?").get(id));
1587
- }
1588
-
1589
- /**
1590
- * Apply a partial update to an entity, always refreshing last_seen. The
1591
- * mention counter increments on every sighting unless the caller overrides
1592
- * it explicitly via patch.mention_count (e.g. to correct a count).
1593
- */
1594
- function updateEntity(id, patch) {
1595
- const old = findEntityById(id);
1596
- if (!old) return undefined;
1597
- const has = (k) => Object.prototype.hasOwnProperty.call(patch, k);
1598
- const name = has("name") ? patch.name : old.name;
1599
- const type = has("type") ? patch.type : old.type;
1600
- const canonical_memory_id = has("canonical_memory_id")
1601
- ? patch.canonical_memory_id
1602
- : old.canonical_memory_id;
1603
- const mention_count = has("mention_count")
1604
- ? patch.mention_count
1605
- : (old.mention_count ?? 1) + 1;
1606
- const now = nowIso();
1607
- db.prepare(
1608
- `UPDATE entities SET name = ?, type = ?, last_seen = ?, mention_count = ?, canonical_memory_id = ? WHERE id = ?`
1609
- ).run(name, type ?? null, now, mention_count, canonical_memory_id ?? null, id);
1610
- return findEntityById(id);
1611
- }
1612
-
1613
- /**
1614
- * Record an attribute value for an entity. The previous value for the same
1615
- * entity+key is invalidated (valid_until = now) before the new row is
1616
- * inserted, so exactly one row per entity+key is current (valid_until IS NULL).
1617
- */
1618
- function saveAttr({ entity_id, attr_key, attr_value, memory_id, confidence, source }) {
1619
- const now = nowIso();
1620
- invalidateOldAttr(entity_id, attr_key, now);
1621
- const id = randomUUID();
1622
- db.prepare(
1623
- `INSERT INTO entity_attrs (id, entity_id, attr_key, attr_value, memory_id, valid_from, valid_until, confidence, source)
1624
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
1625
- ).run(id, entity_id, attr_key, attr_value, memory_id ?? null, now, null, confidence ?? 1.0, source ?? null);
1626
- return toAttr(db.prepare("SELECT * FROM entity_attrs WHERE id = ?").get(id));
1627
- }
1628
-
1629
- /** Mark every currently-valid attr row for entityId+attrKey as expired. Returns rows changed. */
1630
- function invalidateOldAttr(entityId, attrKey, now) {
1631
- return db.prepare(
1632
- `UPDATE entity_attrs SET valid_until = ? WHERE entity_id = ? AND attr_key = ? AND valid_until IS NULL`
1633
- ).run(now, entityId, attrKey).changes;
1634
- }
1635
-
1636
- /** Only the live value per attr_key (valid_until IS NULL). */
1637
- function getCurrentAttrs(entityId) {
1638
- return db.prepare(
1639
- "SELECT * FROM entity_attrs WHERE entity_id = ? AND valid_until IS NULL"
1640
- ).all(entityId).map(toAttr);
1641
- }
1642
-
1643
- /** Full history per attr_key, oldest first. */
1644
- function getAttrHistory(entityId) {
1645
- return db.prepare(
1646
- "SELECT * FROM entity_attrs WHERE entity_id = ? ORDER BY valid_from"
1647
- ).all(entityId).map(toAttr);
1648
- }
1649
-
1650
- /**
1651
- * All attr rows carrying a reference to the given memory (any valid state),
1652
- * oldest first. Used by autoDream's update path to record what an update
1653
- * superseded (v0.3.0 Phase 4 / 4.3.1).
1654
- */
1655
- function getAttrsByMemory(memoryId) {
1656
- return db.prepare(
1657
- "SELECT * FROM entity_attrs WHERE memory_id = ? ORDER BY valid_from ASC"
1658
- ).all(memoryId).map(toAttr);
1659
- }
1660
-
1661
- /**
1662
- * Memories carrying a currently-valid attr matching key=value (deduped).
1663
- * When value is empty/undefined, the attr_value filter is dropped and every
1664
- * currently-valid memory for that attr_key is returned — the "attr:key"
1665
- * (no =value) contract, v0.3.0. Only live rows (valid_until IS NULL) with a
1666
- * memory reference participate, and each memory appears at most once.
1667
- */
1668
- function findMemoriesByAttr(key, value) {
1669
- const empty = value === undefined || value === null || value === "";
1670
- const sql = empty
1671
- ? `SELECT DISTINCT memory_id FROM entity_attrs
1672
- WHERE attr_key = ? AND valid_until IS NULL
1673
- AND memory_id IS NOT NULL AND memory_id != ''`
1674
- : `SELECT DISTINCT memory_id FROM entity_attrs
1675
- WHERE attr_key = ? AND attr_value = ? AND valid_until IS NULL
1676
- AND memory_id IS NOT NULL AND memory_id != ''`;
1677
- const params = empty ? [key] : [key, value];
1678
- const rows = db.prepare(sql).all(...params);
1679
- const memories = [];
1680
- const stmt = db.prepare("SELECT * FROM memories WHERE id = ?");
1681
- for (const { memory_id } of rows) {
1682
- const row = stmt.get(memory_id);
1683
- if (row) memories.push(toRow(row));
1684
- }
1685
- return memories;
1686
- }
1687
-
1688
- // --- tag storage (v0.6.2) ------------------------------------------------
1689
- // Tags ride the snapshot-style entity_attrs table (attr_key='tags'), so one
1690
- // memory has exactly one live tags row; setMemoryTags invalidates any prior
1691
- // live row and inserts a fresh one (idempotent overwrite). entity_id is the
1692
- // memory id itself (the memory is its own tag entity), memory_id is kept so
1693
- // the existing memory-scoped attr queries (getAttrsByMemory / findMemoriesByAttr)
1694
- // and the bulk tag map all work without a special path.
1695
-
1696
- /** Normalize an arbitrary tags input to a deduplicated string array.
1697
- * Delegates to parser/tag.js sanitizeTags (shared validation with parseTags
1698
- * and the autoDream tag-extractor): strips a leading `#`, trims, drops
1699
- * non-strings/blanks/over-long/illegal-char tags. Kept as a thin alias so
1700
- * the tag write path validates identically to the parser path. */
1701
- function normalizeTags(tags) {
1702
- return sanitizeTags(tags);
1703
- }
1704
-
1705
- /**
1706
- * Set (overwrite) the live tag set for a memory. Exactly one tags row stays
1707
- * live per memory: any prior live row is invalidated first, then one fresh
1708
- * row is written (no-op when tags is empty — the invalidated row is removed
1709
- * so "clear tags" = no live row). Returns the stored tag array.
1710
- */
1711
- function setMemoryTags(memoryId, tags) {
1712
- const arr = normalizeTags(tags);
1713
- const now = nowIso();
1714
- // Atomic: the invalidation and the fresh row must land together, so a
1715
- // mid-write crash never leaves the old live row gone without a replacement.
1716
- // SAVEPOINT (not BEGIN) so this nests safely inside service.transaction().
1717
- db.exec("SAVEPOINT set_memory_tags");
1718
- try {
1719
- db.prepare(
1720
- `UPDATE entity_attrs SET valid_until = ?
1721
- WHERE attr_key = 'tags' AND memory_id = ? AND valid_until IS NULL`
1722
- ).run(now, memoryId);
1723
- if (arr.length) {
1724
- const id = randomUUID();
1725
- db.prepare(
1726
- `INSERT INTO entity_attrs (id, entity_id, attr_key, attr_value, memory_id, valid_from, valid_until, confidence, source)
1727
- VALUES (?, ?, 'tags', ?, ?, ?, NULL, 1.0, 'manual')`
1728
- ).run(id, memoryId, JSON.stringify(arr), memoryId, now);
1729
- }
1730
- db.exec("RELEASE set_memory_tags");
1731
- } catch (e) {
1732
- db.exec("ROLLBACK TO set_memory_tags");
1733
- db.exec("RELEASE set_memory_tags");
1734
- throw e;
1735
- }
1736
- return arr;
1737
- }
1738
-
1739
- /** Live tags for a memory ([] when none / unknown). */
1740
- function getMemoryTags(memoryId) {
1741
- const row = db.prepare(
1742
- `SELECT attr_value FROM entity_attrs
1743
- WHERE attr_key = 'tags' AND memory_id = ? AND valid_until IS NULL
1744
- ORDER BY valid_from DESC LIMIT 1`
1745
- ).get(memoryId);
1746
- if (!row) return [];
1747
- try {
1748
- const arr = JSON.parse(row.attr_value);
1749
- return Array.isArray(arr) ? arr : [];
1750
- } catch {
1751
- return [];
1752
- }
1753
- }
1754
-
1755
- /** Bulk live-tags lookup for mirror rendering. Returns Map<memoryId, string[]>. */
1756
- function getMemoryTagsMap(ids) {
1757
- const out = new Map();
1758
- const list = (Array.isArray(ids) ? ids : []).filter(Boolean);
1759
- for (let i = 0; i < list.length; i += 100) {
1760
- const chunk = list.slice(i, i + 100);
1761
- const rows = db.prepare(
1762
- `SELECT memory_id, attr_value FROM entity_attrs
1763
- WHERE attr_key = 'tags' AND valid_until IS NULL
1764
- AND memory_id IN (${chunk.map(() => "?").join(",")})`
1765
- ).all(...chunk);
1766
- for (const row of rows) {
1767
- try {
1768
- const arr = JSON.parse(row.attr_value);
1769
- if (Array.isArray(arr) && arr.length) out.set(row.memory_id, arr);
1770
- } catch { /* corrupt row: skip */ }
1771
- }
1772
- }
1773
- return out;
1774
- }
1775
-
1776
- /**
1777
- * Memories carrying a live tags row that contains EVERY requested tag
1778
- * (AND semantics for a multi-tag query). attr_value is a JSON array, so the
1779
- * match uses quoted `"tag"` substrings — `tag:lin` never collides with
1780
- * `linux` because JSON array elements are quote-delimited. Only live rows
1781
- * (valid_until IS NULL) with a memory reference participate; each memory
1782
- * appears once.
1783
- */
1784
- function findMemoriesByTags(tags) {
1785
- const list = normalizeTags(tags);
1786
- if (!list.length) return [];
1787
- const where = list.map(() => `attr_value LIKE ? ESCAPE '\\'`).join(" AND ");
1788
- const params = list.map((t) => `%"${escapeLike(t)}"%`);
1789
- const rows = db.prepare(
1790
- `SELECT DISTINCT memory_id FROM entity_attrs
1791
- WHERE attr_key = 'tags' AND valid_until IS NULL
1792
- AND memory_id IS NOT NULL AND memory_id != ''
1793
- AND (${where})`
1794
- ).all(...params);
1795
- // Same live-memory filter as getDirectory/store.search: forgotten/archived/
1796
- // session-disposed memories are invisible to `tag:` recall.
1797
- const stmt = db.prepare(
1798
- "SELECT * FROM memories WHERE id = ? AND forgotten = 0 AND archived = 0 AND session_disposed_at IS NULL"
1799
- );
1800
- const memories = [];
1801
- for (const { memory_id } of rows) {
1802
- const row = stmt.get(memory_id);
1803
- if (row) memories.push(toRow(row));
1804
- }
1805
- return memories;
1806
- }
1807
-
1808
- /**
1809
- * Directory view (v0.6.3): group live memories by their entity_attrs-backed
1810
- * tag set. A memory carrying N tags appears under all N tag folders; a memory
1811
- * with no live tags lands in `untagged`. Only live rows participate —
1812
- * forgotten, archived and session-disposed memories are excluded. Groups are
1813
- * ordered by tag (locale-aware), group members and untagged follow the
1814
- * canonical memory order (importance DESC, updated_at DESC, id).
1815
- * @returns {{groups: {tag: string, memories: object[]}[], untagged: object[]}}
1816
- */
1817
- function getDirectory() {
1818
- const rows = db.prepare(
1819
- `SELECT * FROM memories
1820
- WHERE forgotten = 0 AND archived = 0 AND session_disposed_at IS NULL
1821
- ORDER BY importance DESC, updated_at DESC, id`
1822
- ).all();
1823
- const memories = rows.map(toRow);
1824
- const tagMap = getMemoryTagsMap(memories.map((m) => m.id));
1825
- const byTag = new Map(); // tag -> memory[]
1826
- const untagged = [];
1827
- for (const m of memories) {
1828
- const tags = tagMap.get(m.id);
1829
- if (!tags || tags.length === 0) {
1830
- untagged.push(m);
1831
- continue;
1832
- }
1833
- for (const tag of tags) {
1834
- if (!byTag.has(tag)) byTag.set(tag, []);
1835
- byTag.get(tag).push(m);
1836
- }
1837
- }
1838
- const groups = [...byTag.entries()]
1839
- .sort((a, b) => a[0].localeCompare(b[0]))
1840
- .map(([tag, ms]) => ({ tag, memories: ms }));
1841
- return { groups, untagged };
1842
- }
1843
-
1844
- /**
1845
- * Record a typed relation between two entities. metadata (optional) is a
1846
- * free-form JSON blob describing the relation. Relations are append-only —
1847
- * callers that need idempotency (e.g. wiki-links, via saveWikiLinks) guard
1848
- * with their own existence check plus the partial links_to unique index
1849
- * (idx_relations_wikilink) as a race backstop.
1850
- */
1851
- function saveRelation({ from_entity, to_entity, relation_type, memory_id, metadata }) {
1852
- const id = randomUUID();
1853
- const now = nowIso();
1854
- const metaStr = metadata === undefined
1855
- ? null
1856
- : typeof metadata === "string"
1857
- ? metadata
1858
- : JSON.stringify(metadata);
1859
- db.prepare(
1860
- `INSERT INTO entity_relations (id, from_entity, to_entity, relation_type, memory_id, created_at, metadata)
1861
- VALUES (?, ?, ?, ?, ?, ?, ?)`
1862
- ).run(id, from_entity, to_entity, relation_type, memory_id ?? null, now, metaStr);
1863
- return toRelation(db.prepare(
1864
- "SELECT * FROM entity_relations WHERE from_entity = ? AND to_entity = ? AND relation_type = ? LIMIT 1"
1865
- ).get(from_entity, to_entity, relation_type));
1866
- }
1867
-
1868
- /**
1869
- * Record wiki-link relations (v0.6.1). For each target title, resolve the
1870
- * target memory (case-insensitive title match via findByTitle) and write a
1871
- * links_to relation:
1872
- * from_entity = source memory title, to_entity = canonical target memory
1873
- * title, relation_type = 'links_to', memory_id = source memory id.
1874
- * Using the canonical resolved title keeps the graph case-consistent
1875
- * ([[beta]] and [[Beta]] collapse onto the same to_entity), so backlink
1876
- * lookups never fight the way a target was typed.
1877
- * Fail-safe: a target with no matching memory is skipped (never an error).
1878
- * Idempotent: an already-existing triple is a silent no-op (existence check
1879
- * here + the idx_relations_wikilink partial unique index as a race backstop),
1880
- * so `saved` only counts newly written relations. Returns { saved, skipped }.
1881
- */
1882
- function saveWikiLinks({ memoryId, title, targets }) {
1883
- const saved = [];
1884
- const skipped = [];
1885
- const seen = new Set(); // canonical (lowercased) targets already handled
1886
- const existsStmt = db.prepare(
1887
- "SELECT id FROM entity_relations WHERE from_entity = ? AND to_entity = ? AND relation_type = ?"
1888
- );
1889
- const list = Array.isArray(targets)
1890
- ? targets.filter((t) => typeof t === "string" && t.trim())
1891
- : [];
1892
- for (const raw of list) {
1893
- const target = raw.trim();
1894
- const key = target.toLowerCase();
1895
- if (seen.has(key)) continue; // dedupe within a single call (case-insensitive)
1896
- seen.add(key);
1897
- const targetMem = findByTitle(target);
1898
- if (!targetMem) {
1899
- skipped.push(target); // 目标不存在 → 跳过(Fail-safe)
1900
- continue;
1901
- }
1902
- const toEntity = targetMem.title;
1903
- if (existsStmt.get(title, toEntity, "links_to")) continue; // already linked → no-op
1904
- saved.push(saveRelation({
1905
- from_entity: title,
1906
- to_entity: toEntity,
1907
- relation_type: "links_to",
1908
- memory_id: memoryId,
1909
- metadata: { target_memory_id: targetMem.id }
1910
- }));
1911
- }
1912
- return { saved, skipped };
1913
- }
1914
-
1915
- /**
1916
- * Re-point every attr row whose memory_id is fromMemoryId to toMemoryId
1917
- * (autoDream merge migration, v0.3.0 Phase 4 / 4.3.2). When the keeper
1918
- * already carries a live attr for the same entity+key, the source row is
1919
- * superseded and invalidated instead (the keeper's value wins). Returns
1920
- * { migrated, invalidated }.
1921
- */
1922
- function migrateAttrsToMemory(fromMemoryId, toMemoryId, now) {
1923
- let migrated = 0;
1924
- let invalidated = 0;
1925
- const attrs = db.prepare(
1926
- "SELECT * FROM entity_attrs WHERE memory_id = ?"
1927
- ).all(fromMemoryId);
1928
- for (const attr of attrs) {
1929
- // 仅当 keeper 已有同 entity+key 的当前有效属性才视为被替代(限定 memory_id,
1930
- // 避免把 loser 自身的 live 行误判为 keeper 行)。
1931
- const keeperLive = db.prepare(
1932
- "SELECT id FROM entity_attrs WHERE entity_id = ? AND attr_key = ? AND valid_until IS NULL AND memory_id = ?"
1933
- ).get(attr.entity_id, attr.attr_key, toMemoryId);
1934
- if (keeperLive) {
1935
- db.prepare(
1936
- "UPDATE entity_attrs SET valid_until = ? WHERE id = ?"
1937
- ).run(now, attr.id);
1938
- invalidated++;
1939
- } else {
1940
- db.prepare(
1941
- "UPDATE entity_attrs SET memory_id = ? WHERE id = ?"
1942
- ).run(toMemoryId, attr.id);
1943
- migrated++;
1944
- }
1945
- }
1946
- return { migrated, invalidated };
1947
- }
1948
-
1949
- /** Relations where the entity appears on either side (from or to). */
1950
- function getRelations(entityId) {
1951
- return db.prepare(
1952
- "SELECT * FROM entity_relations WHERE from_entity = ? OR to_entity = ?"
1953
- ).all(entityId, entityId).map(toRelation);
1954
- }
1955
-
1956
- /** All entities (optionally name-filtered, newest first). Used by sleep phase 4
1957
- * orphan detection: an entity with zero relations is a candidate for relation
1958
- * completion. */
1959
- function listEntities({ limit = 1000 } = {}) {
1960
- const rows = db.prepare(
1961
- "SELECT * FROM entities ORDER BY last_seen DESC, name ASC LIMIT ?"
1962
- ).all(limit);
1963
- return rows.map(toEntity);
1964
- }
1965
-
1966
- // --- mirror sync state (F-NEW-03) -----------------------------------------
1967
-
1968
- /**
1969
- * Upsert the single mirror_state row (id='main'). patch accepts
1970
- * {dirty?, last_error?, last_attempt?, success_at?, generation?,
1971
- * applied_generation?, type_status?} — only the keys present on the object
1972
- * are written, everything else is left untouched (partial upsert). type_status
1973
- * is stored as JSON text (objects are serialized on write), generation /
1974
- * applied_generation are coerced to non-negative integers. Returns the freshly
1975
- * read state row (default shape when absent).
1976
- */
1977
- function setMirrorState(patch) {
1978
- const ALLOWED = new Set([
1979
- "dirty",
1980
- "last_error",
1981
- "last_attempt",
1982
- "success_at",
1983
- "generation",
1984
- "applied_generation",
1985
- "type_status"
1986
- ]);
1987
- const keys = Object.keys(patch).filter(
1988
- (key) => ALLOWED.has(key) && Object.prototype.hasOwnProperty.call(patch, key)
1989
- );
1990
- if (keys.length === 0) {
1991
- db.prepare(
1992
- "INSERT INTO mirror_state (id) VALUES ('main') ON CONFLICT(id) DO NOTHING"
1993
- ).run();
1994
- return getMirrorState();
1995
- }
1996
- // 列同时出现在 INSERT 与 ON CONFLICT 里(excluded.*),保证首次插入也写入
1997
- // patch 值,而不只是默认值;未传入的列保持不变(partial upsert)。
1998
- const cols = [];
1999
- const values = [];
2000
- const updates = [];
2001
- for (const key of keys) {
2002
- let value = patch[key];
2003
- if (key === "dirty") {
2004
- value = value ? 1 : 0;
2005
- } else if (key === "generation" || key === "applied_generation") {
2006
- // Fail-closed integer enforcement (audit peer F): never truncate. A
2007
- // fractional value like 1.5 previously passed the JS gate via
2008
- // Math.trunc while SQLite's CHECK (>= 0) silently accepted it too, so a
2009
- // dirty legacy row could carry a non-integer generation that reads as a
2010
- // coherent applied round. Reject non-integers outright — the caller must
2011
- // pass a whole number, and a stale dirty value stays visible instead of
2012
- // being "repaired" into a misleading clean integer.
2013
- value = Number(value);
2014
- if (!Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
2015
- throw new RangeError(`mirror_state.${key} out of range: ${value}`);
2016
- }
2017
- } else if (key === "type_status" && value != null && typeof value !== "string") {
2018
- value = JSON.stringify(value);
2019
- }
2020
- cols.push(key);
2021
- values.push(value);
2022
- updates.push(`${key} = excluded.${key}`);
2023
- }
2024
- const placeholders = cols.map(() => "?").join(", ");
2025
- db.prepare(
2026
- `INSERT INTO mirror_state (id, ${cols.join(", ")}) VALUES ('main', ${placeholders})
2027
- ON CONFLICT(id) DO UPDATE SET ${updates.join(", ")}`
2028
- ).run(...values);
2029
- return getMirrorState();
2030
- }
2031
-
2032
- /** Current mirror state; default {dirty:0, last_error:null, last_attempt:null, success_at:null, generation:0, applied_generation:0, type_status:{}} when absent. */
2033
- function getMirrorState() {
2034
- const row = db.prepare("SELECT * FROM mirror_state WHERE id = 'main'").get();
2035
- return toMirrorState(row);
2036
- }
2037
-
2038
- /**
2039
- * Mark the mirror dirty after a failed sync (dirty=1 + last_error +
2040
- * last_attempt). v0.3.6: also bumps the desired generation so the debt is
2041
- * bound to a specific sync round; applied_generation is left untouched
2042
- * (the round was NOT applied). A stale worker that started earlier cannot
2043
- * clear this newer debt — only a clean fenced to a generation at least as
2044
- * recent as this one may.
2045
- */
2046
- function markMirrorDirty(error, now) {
2047
- // Bump the desired generation atomically first — the new debt must be bound
2048
- // to a fresh round so a stale worker cannot fence-clean it. Even if this
2049
- // write fails (peer blocker 2), generation still advanced, so recoverMirror
2050
- // sees generation > applied_generation and retries rather than false-clean.
2051
- incrementGeneration();
2052
- return setMirrorState({
2053
- dirty: 1,
2054
- last_error: error,
2055
- last_attempt: now ?? nowIso()
2056
- });
2057
- }
2058
-
2059
- /**
2060
- * Fenced clean (CAS): mark the mirror clean for a specific generation.
2061
- * First records that generation `gen` has been applied
2062
- * (applied_generation = MAX(applied_generation, gen)), then clears dirty only
2063
- * when the current desired generation has not advanced past gen — a stale
2064
- * worker cleaning an older round must not wipe a newer failure's debt.
2065
- * Returns the resulting state (dirty stays set when the fence holds).
2066
- */
2067
- function markMirrorCleanForGeneration(gen, now) {
2068
- const current = getMirrorState();
2069
- const applied = Math.max(current.applied_generation || 0, gen);
2070
- const patch = { applied_generation: applied };
2071
- if (applied >= gen && (current.generation || 0) <= gen) {
2072
- patch.dirty = 0;
2073
- patch.last_error = null;
2074
- patch.success_at = now ?? nowIso();
2075
- }
2076
- return setMirrorState(patch);
2077
- }
2078
-
2079
- /** Convenience: mark the mirror clean for the current desired generation (backward-compatible with pre-v0.3.6 callers). */
2080
- function markMirrorClean(now) {
2081
- const current = getMirrorState();
2082
- return markMirrorCleanForGeneration(current.generation || 0, now);
2083
- }
2084
-
2085
- /** Convenience: clear only the dirty flag + last_error, leaving success_at untouched (manual reconcile / retry path). */
2086
- function clearMirrorDirty() {
2087
- return setMirrorState({ dirty: 0, last_error: null });
2088
- }
2089
-
2090
- /**
2091
- * Record per-type mirror status (partial success bookkeeping). `status` is a
2092
- * patch {status: 'committed'|'failed'|'pending', applied_gen?, last_error?}
2093
- * replacing the entry for `type` (other types untouched). Standardizing on an
2094
- * explicit status gives per-type committed/failed/pending receipts — a type
2095
- * whose file was written while a sibling failed is recorded as such, not
2096
- * collapsed into a bulk "dirty" (peer blocker 4). Returns the updated state.
2097
- */
2098
- function setTypeStatus(type, status) {
2099
- if (!VALID_TYPE_STATUS.has(status?.status)) {
2100
- throw new TypeError(`setTypeStatus: status must be one of committed|failed|pending, got ${status?.status}`);
2101
- }
2102
- const current = getMirrorState();
2103
- const statuses = current.type_status || {};
2104
- statuses[type] = {
2105
- status: status.status,
2106
- ...(status.applied_gen !== undefined ? { applied_gen: status.applied_gen } : {}),
2107
- ...(status.last_error !== undefined ? { last_error: status.last_error } : {})
2108
- };
2109
- return setMirrorState({ type_status: JSON.stringify(statuses) });
2110
- }
2111
-
2112
- /** Per-type mirror status map {type: {dirty, applied_gen, last_error}}, {} when unset. */
2113
- function getTypeStatus() {
2114
- const current = getMirrorState();
2115
- return current.type_status || {};
2116
- }
2117
-
2118
- /** Run fn atomically: when the connection is already inside a transaction
2119
- * (service.transaction's BEGIN), just run it — the outer COMMIT covers us.
2120
- * Otherwise wrap in BEGIN/COMMIT so a memory write and its desired-generation
2121
- * bump commit together: a crash between them can never leave a mutated store
2122
- * with generation == applied (audit peer blocker 1, "crash window"). */
2123
- function runAtomically(fn) {
2124
- if (db.isTransaction) return fn();
2125
- db.exec("BEGIN");
2126
- try {
2127
- const result = fn();
2128
- db.exec("COMMIT");
2129
- return result;
2130
- } catch (error) {
2131
- try { db.exec("ROLLBACK"); } catch { /* connection may be closed */ }
2132
- throw error;
2133
- }
2134
- }
2135
-
2136
- /** Bump the desired generation atomically (SQLite single-statement increment,
2137
- * no SELECT-then-UPSERT race: peer blocker 3 lost 10 of 91 concurrent
2138
- * increments under an 8-process probe). Returns the new mirror state.
2139
- * Guards the upper bound: generation must stay within MAX_SAFE_INTEGER so
2140
- * reads never hit ERR_OUT_OF_RANGE (peer blocker 6). */
2141
- function incrementGeneration() {
2142
- return runAtomically(() => {
2143
- // Ensure the singleton row exists before incrementing (UPDATE alone would
2144
- // match nothing on a fresh DB).
2145
- db.prepare("INSERT OR IGNORE INTO mirror_state (id) VALUES ('main')").run();
2146
- const row = db.prepare(
2147
- "UPDATE mirror_state SET generation = generation + 1 WHERE id = 'main' AND generation < ? RETURNING generation"
2148
- ).get(Number.MAX_SAFE_INTEGER);
2149
- if (!row) throw new RangeError("mirror_state.generation exceeded MAX_SAFE_INTEGER");
2150
- return getMirrorState();
2151
- });
2152
- }
2153
-
2154
- return {
2155
- db,
2156
- count,
2157
- getById,
2158
- save,
2159
- update,
2160
- compareAndUpdate,
2161
- remove,
2162
- setForget,
2163
- setArchived,
2164
- listBySession,
2165
- setDisposedBySession,
2166
- touchLastAccess,
2167
- demoteToSummary,
2168
- restoreContent,
2169
- getUnrecalledSince,
2170
- list,
2171
- all,
2172
- search,
2173
- setEmbedding,
2174
- getEmbeddings,
2175
- embeddedCount,
2176
- needsEmbedding,
2177
- searchVector,
2178
- saveDreamRun,
2179
- getDreamRun,
2180
- listDreamRuns,
2181
- getLatestPolicyEpoch,
2182
- saveReceipt,
2183
- getReceipt,
2184
- listReceipts,
2185
- saveRecallRun,
2186
- getRecallRun,
2187
- listRecallRuns,
2188
- saveRecallEval,
2189
- getRecallEval,
2190
- listRecallEvals,
2191
- saveLlmAudit,
2192
- listLlmAudits,
2193
- countLlmAudits,
2194
- getLlmAuditStats,
2195
- deleteOldLlmAudits,
2196
- saveFailure,
2197
- listFailures,
2198
- getFailureStats,
2199
- deleteOldFailures,
2200
- saveConflictPending,
2201
- listConflictPending,
2202
- resolveConflictPending,
2203
- countConflictPending,
2204
- createEntity,
2205
- findEntityByName,
2206
- findEntityById,
2207
- listEntities,
2208
- updateEntity,
2209
- saveAttr,
2210
- invalidateOldAttr,
2211
- getCurrentAttrs,
2212
- getAttrHistory,
2213
- getAttrsByMemory,
2214
- findMemoriesByAttr,
2215
- setMemoryTags,
2216
- getMemoryTags,
2217
- getMemoryTagsMap,
2218
- findMemoriesByTags,
2219
- getDirectory,
2220
- saveRelation,
2221
- saveWikiLinks,
2222
- findByTitle,
2223
- migrateAttrsToMemory,
2224
- getRelations,
2225
- setMirrorState,
2226
- getMirrorState,
2227
- markMirrorDirty,
2228
- markMirrorClean,
2229
- markMirrorCleanForGeneration,
2230
- clearMirrorDirty,
2231
- setTypeStatus,
2232
- getTypeStatus,
2233
- incrementGeneration,
2234
- close() {
2235
- db.close();
2236
- }
2237
- };
2238
- }
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { randomUUID } from "node:crypto";
3
+ import { sanitizeTags } from "./parser/tag.js";
4
+
5
+ const SCHEMA = `
6
+ CREATE TABLE IF NOT EXISTS memories (
7
+ id TEXT PRIMARY KEY,
8
+ type TEXT NOT NULL,
9
+ title TEXT NOT NULL,
10
+ content TEXT NOT NULL,
11
+ tags TEXT NOT NULL DEFAULT '[]',
12
+ importance INTEGER NOT NULL DEFAULT 3,
13
+ forgotten INTEGER NOT NULL DEFAULT 0,
14
+ archived INTEGER NOT NULL DEFAULT 0,
15
+ session_disposed_at TEXT,
16
+ source TEXT,
17
+ session_id TEXT,
18
+ content_history TEXT,
19
+ embedding TEXT,
20
+ epistemic_status TEXT NOT NULL DEFAULT 'subjective',
21
+ last_accessed_at TEXT,
22
+ _full_content TEXT,
23
+ created_at TEXT NOT NULL,
24
+ updated_at TEXT NOT NULL
25
+ );
26
+ CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
27
+ CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance);
28
+
29
+ -- autoDream audit trail: one row per consolidation run, capturing the exact
30
+ -- input snapshot digest + the LLM decision list + per-id outcome + a compact
31
+ -- receipt. This makes every decision replayable so silent consolidation errors
32
+ -- (high pass rate but wrong merge/conflict) can be located after the fact.
33
+ CREATE TABLE IF NOT EXISTS dream_runs (
34
+ id TEXT PRIMARY KEY,
35
+ created_at TEXT NOT NULL,
36
+ status TEXT NOT NULL, -- ok | noop | degraded | reconcile | failed
37
+ error TEXT,
38
+ provider TEXT,
39
+ model TEXT,
40
+ snapshot_hash TEXT NOT NULL,
41
+ input_count INTEGER NOT NULL,
42
+ input TEXT, -- JSON: full input snapshot (id/type/title/content/importance/updated_at)
43
+ decisions TEXT, -- JSON: raw LLM decision list
44
+ outcome TEXT, -- JSON: { byId: {id: action} }
45
+ applied INTEGER NOT NULL DEFAULT 0,
46
+ summary_stored INTEGER NOT NULL DEFAULT 0,
47
+ receipt TEXT NOT NULL,
48
+ policy_epoch INTEGER NOT NULL DEFAULT 0, -- 裁决规则版本:规则升级后旧裁决降级为历史证据
49
+ run_type TEXT NOT NULL DEFAULT 'auto' -- auto | sleep:睡眠周期的审计区分
50
+ );
51
+ CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
52
+
53
+ -- recall_runs: recall-layer receipt. One row per retrieval scene — the query,
54
+ -- mode, top-k, threshold and the exact candidate list (id/title/content/score/
55
+ -- source) that was returned — so retrieval behavior can be audited and
56
+ -- replayed after the fact. Sibling of the dream judgment-layer audit trail.
57
+ CREATE TABLE IF NOT EXISTS recall_runs (
58
+ id TEXT PRIMARY KEY,
59
+ query TEXT NOT NULL,
60
+ mode TEXT NOT NULL,
61
+ top_k INTEGER,
62
+ threshold REAL,
63
+ candidates TEXT NOT NULL, -- JSON: 召回候选数组(含 id/title/content/score/source)
64
+ created_at TEXT NOT NULL
65
+ );
66
+ CREATE INDEX IF NOT EXISTS idx_recall_runs_created ON recall_runs(created_at);
67
+ CREATE INDEX IF NOT EXISTS idx_recall_runs_query ON recall_runs(query);
68
+
69
+ -- recall_evals: retrieval evaluation/test snapshots, kept SEPARATE from the
70
+ -- recall_runs production audit so test runs never inflate the production trail.
71
+ -- One row per evaluateRetrieval call that opted into persistence
72
+ -- (config.evalPersistTestResults): the query, the expected ids the operator
73
+ -- marked relevant, the actual ids retrieval returned, and the computed
74
+ -- metrics (precision/recall/mrr). recall_run_id optionally links to the
75
+ -- recall_runs audit row that captured the same retrieval scene (null when the
76
+ -- eval did not also record a run). Bookkeeping like the other audit tables: it
77
+ -- never triggers write hooks.
78
+ CREATE TABLE IF NOT EXISTS recall_evals (
79
+ id TEXT PRIMARY KEY,
80
+ recall_run_id TEXT, -- FK → recall_runs.id (optional linkage)
81
+ query TEXT NOT NULL,
82
+ expected_ids TEXT NOT NULL, -- JSON: relevant ids expected by the evaluator
83
+ actual_ids TEXT NOT NULL, -- JSON: ids actually retrieved
84
+ metrics TEXT NOT NULL, -- JSON: { precision, recall, mrr, hit_count }
85
+ eval_type TEXT NOT NULL DEFAULT 'manual',
86
+ created_at TEXT NOT NULL,
87
+ FOREIGN KEY (recall_run_id) REFERENCES recall_runs(id)
88
+ );
89
+ CREATE INDEX IF NOT EXISTS idx_recall_evals_created ON recall_evals(created_at);
90
+ CREATE INDEX IF NOT EXISTS idx_recall_evals_run ON recall_evals(recall_run_id);
91
+
92
+ -- failure_memories: records user corrections / reflection failures. Captures
93
+ -- what a memory was (actual) vs what the user changed it to (expected)
94
+ -- so later reflection passes can mine recurring correction patterns.
95
+ -- before holds a JSON snapshot of the pre-change title/content/importance,
96
+ -- so a title-only or importance-only correction is still traceable.
97
+ CREATE TABLE IF NOT EXISTS failure_memories (
98
+ id TEXT PRIMARY KEY,
99
+ query TEXT,
100
+ expected TEXT,
101
+ actual TEXT,
102
+ before TEXT,
103
+ failure_type TEXT NOT NULL,
104
+ memory_id TEXT,
105
+ created_at TEXT NOT NULL
106
+ );
107
+ CREATE INDEX IF NOT EXISTS idx_failure_memories_created ON failure_memories(created_at);
108
+ CREATE INDEX IF NOT EXISTS idx_failure_memories_type ON failure_memories(failure_type);
109
+
110
+ -- receipt_chain: per-record receipt chain. One row per mutable verdict
111
+ -- (merge/conflict/update), carrying the input digest (the basis of the
112
+ -- decision, content-addressed) and the idempotency check counters
113
+ -- count_before → count_after. Replaying the same decision must reproduce the
114
+ -- same result; a digest match with a divergent outcome pinpoints drift to the
115
+ -- specific record/run. Sibling of the run-level dream audit trail.
116
+ CREATE TABLE IF NOT EXISTS receipt_chain (
117
+ receipt_id TEXT PRIMARY KEY,
118
+ run_id TEXT NOT NULL,
119
+ record_id TEXT NOT NULL,
120
+ kind TEXT NOT NULL, -- merge | conflict | update
121
+ input_digest TEXT NOT NULL,
122
+ winner_id TEXT,
123
+ loser_id TEXT,
124
+ keep_source TEXT,
125
+ sources TEXT, -- JSON: merge 全部参与 id 数组
126
+ verdict TEXT NOT NULL, -- live | revoked | historical
127
+ count_before INTEGER NOT NULL,
128
+ count_after INTEGER NOT NULL,
129
+ policy_epoch INTEGER NOT NULL DEFAULT 0,
130
+ created_at TEXT NOT NULL
131
+ );
132
+ CREATE INDEX IF NOT EXISTS idx_receipt_chain_record ON receipt_chain(record_id);
133
+ CREATE INDEX IF NOT EXISTS idx_receipt_chain_run ON receipt_chain(run_id);
134
+
135
+ -- conflict_pending: conflicts parked for manual review (conflict freeze mode,
136
+ -- opt-in via config.conflictFreezeEnabled). When enabled, the dream layer does
137
+ -- NOT auto-adjudicate winner/loser — the conflicting pair is parked here until
138
+ -- a human reviews it. resolveConflictPending stamps resolved_at (plus the chosen
139
+ -- winner) so the review action stays auditable. Like the other audit tables this
140
+ -- is bookkeeping: it never triggers write hooks.
141
+ CREATE TABLE IF NOT EXISTS conflict_pending (
142
+ id TEXT PRIMARY KEY,
143
+ run_id TEXT,
144
+ memory_a TEXT NOT NULL,
145
+ memory_b TEXT NOT NULL,
146
+ reason TEXT,
147
+ created_at TEXT NOT NULL,
148
+ resolved_at TEXT,
149
+ resolved_winner TEXT
150
+ );
151
+ CREATE INDEX IF NOT EXISTS idx_conflict_pending_unresolved ON conflict_pending(resolved_at);
152
+
153
+ -- llm_audit_logs: every background LLM call (autoDream consolidation + summary,
154
+ -- autoSummarize compression) is recorded here — tokens in/out, duration, status
155
+ -- and the trigger that caused it (Bug8). Failures are captured as status='error'
156
+ -- and never block the calling feature. retentionDays is enforced by a boot-time
157
+ -- purge (deleteOldLlmAudits). Bookkeeping like the other audit tables: it never
158
+ -- triggers write hooks.
159
+ CREATE TABLE IF NOT EXISTS llm_audit_logs (
160
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
161
+ timestamp TEXT NOT NULL,
162
+ trigger_source TEXT NOT NULL, -- autoDream | autoSummarize | manual ...
163
+ operation_type TEXT NOT NULL, -- dream_consolidate | dream_summarize | summarize_compress ...
164
+ model_id TEXT NOT NULL,
165
+ input_tokens INTEGER NOT NULL DEFAULT 0,
166
+ output_tokens INTEGER NOT NULL DEFAULT 0,
167
+ total_tokens INTEGER NOT NULL DEFAULT 0,
168
+ cost_usd REAL NOT NULL DEFAULT 0,
169
+ duration_ms INTEGER NOT NULL DEFAULT 0,
170
+ status TEXT NOT NULL, -- success | error | skipped
171
+ error_message TEXT,
172
+ related_memory_ids TEXT, -- JSON: ids the call operated on
173
+ metadata TEXT -- JSON: free-form extras
174
+ );
175
+ CREATE INDEX IF NOT EXISTS idx_llm_audit_timestamp ON llm_audit_logs(timestamp);
176
+ CREATE INDEX IF NOT EXISTS idx_llm_audit_source ON llm_audit_logs(trigger_source);
177
+
178
+ -- entity gene (v0.3.0): named entities mentioned across memories, with
179
+ -- time-boxed attributes (valid_from → valid_until) and typed relations.
180
+ -- Attributes follow the snapshot style: saveAttr invalidates the previous
181
+ -- value for the same entity+key before inserting a new row, so the current
182
+ -- value is always the row with valid_until IS NULL.
183
+ CREATE TABLE IF NOT EXISTS entities (
184
+ id TEXT PRIMARY KEY,
185
+ name TEXT NOT NULL,
186
+ type TEXT,
187
+ first_seen TEXT NOT NULL,
188
+ last_seen TEXT NOT NULL,
189
+ mention_count INTEGER DEFAULT 1,
190
+ canonical_memory_id TEXT
191
+ );
192
+ CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
193
+ CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(type);
194
+
195
+ CREATE TABLE IF NOT EXISTS entity_attrs (
196
+ id TEXT PRIMARY KEY,
197
+ entity_id TEXT NOT NULL,
198
+ attr_key TEXT NOT NULL,
199
+ attr_value TEXT NOT NULL,
200
+ memory_id TEXT,
201
+ valid_from TEXT NOT NULL,
202
+ valid_until TEXT,
203
+ confidence REAL DEFAULT 1.0,
204
+ source TEXT
205
+ );
206
+ CREATE INDEX IF NOT EXISTS idx_attrs_entity ON entity_attrs(entity_id);
207
+ CREATE INDEX IF NOT EXISTS idx_attrs_key ON entity_attrs(attr_key);
208
+ CREATE INDEX IF NOT EXISTS idx_attrs_valid ON entity_attrs(valid_from, valid_until);
209
+ CREATE INDEX IF NOT EXISTS idx_attrs_memory ON entity_attrs(memory_id);
210
+
211
+ CREATE TABLE IF NOT EXISTS entity_relations (
212
+ id TEXT PRIMARY KEY,
213
+ from_entity TEXT NOT NULL,
214
+ to_entity TEXT NOT NULL,
215
+ relation_type TEXT NOT NULL,
216
+ memory_id TEXT,
217
+ created_at TEXT NOT NULL,
218
+ metadata TEXT
219
+ );
220
+ CREATE INDEX IF NOT EXISTS idx_relations_from ON entity_relations(from_entity);
221
+ CREATE INDEX IF NOT EXISTS idx_relations_to ON entity_relations(to_entity);
222
+ CREATE INDEX IF NOT EXISTS idx_relations_type ON entity_relations(relation_type);
223
+
224
+ -- mirror 渲染状态 (F-NEW-03): 单行持久记录 mirror 同步失败/成功状态,使
225
+ -- syncMirror 失败不再只靠瞬时 console.warn —— dirty=1 提示镜像脏了需重渲染,
226
+ -- last_error/last_attempt 记录失败原因与最近尝试,success_at 记录最近成功。
227
+ -- 上层可据此在启动时重试、提供人工 reconcile 入口与健康状态查询。
228
+ -- v0.3.6: 新增 generation/applied_generation/type_status —— desired-applied
229
+ -- 建模镜像债务:generation 是期望同步轮次,applied_generation 是已成功应用
230
+ -- 轮次(成功清 dirty 必须 CAS/fence 到具体轮次,旧 worker 不能清新故障),
231
+ -- type_status 逐 type 记录部分成功状态。旧库经 PRAGMA table_info 检查后
232
+ -- ALTER 补列,幂等且不丢数据。
233
+ CREATE TABLE IF NOT EXISTS mirror_state (
234
+ id TEXT PRIMARY KEY, -- 单一状态行(用 'main')
235
+ dirty INTEGER NOT NULL DEFAULT 0, -- 1=镜像脏了需重渲染
236
+ last_error TEXT, -- 最近失败原因
237
+ last_attempt TEXT, -- 最近尝试时间(ISO)
238
+ success_at TEXT, -- 最近成功时间(ISO)
239
+ generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0 AND generation <= 9007199254740991 AND generation = CAST(generation AS INTEGER)), -- 期望的同步轮次(desired)
240
+ applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991 AND applied_generation = CAST(applied_generation AS INTEGER)), -- 已成功应用的轮次
241
+ type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
242
+ );
243
+ `;
244
+
245
+ const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern"]);
246
+
247
+ // Epistemic status: what kind of evidence a memory rests on. Defaults to
248
+ // 'subjective' so legacy rows (and rows without any signal) stay compatible.
249
+ const EPISTEMIC_STATUSES = new Set(["observation", "subjective", "inferred"]);
250
+ // Rule-based inference markers, checked in priority order (observation >
251
+ // inferred > subjective). The default fallback is 'subjective'.
252
+ const OBSERVATION_RE = /实测|观察到|观测|测得|测量|结果表明|数据显示|实验|统计|结果/;
253
+ const INFERRED_RE = /推断|推测出|推导|推论|由此可|据此|综上|意味着|所以|因此/;
254
+ const SUBJECTIVE_RE = /我推测|我猜|我觉得|我感觉|可能|大概|也许|认为|猜想|似乎|猜测|感觉/;
255
+
256
+ /**
257
+ * Heuristically infer a memory's epistemic status from its content (and the
258
+ * AI-generated types). summary/pattern entries are always 'inferred' (derived
259
+ * from other memories); otherwise content markers decide. Pure rule-based, so
260
+ * it never throws and always returns a value in EPISTEMIC_STATUSES.
261
+ */
262
+ function inferEpistemicStatus(memory) {
263
+ if (memory.type === "summary" || memory.type === "pattern") return "inferred";
264
+ const text = `${memory.title ?? ""} ${memory.content ?? ""}`;
265
+ if (OBSERVATION_RE.test(text)) return "observation";
266
+ if (INFERRED_RE.test(text)) return "inferred";
267
+ if (SUBJECTIVE_RE.test(text)) return "subjective";
268
+ return "subjective";
269
+ }
270
+
271
+ /** Resolve a requested epistemic_status: explicit valid value wins, otherwise
272
+ * re-infer from (possibly updated) content. Never returns an invalid value. */
273
+ function resolveEpistemicStatus(memory, patch) {
274
+ if (patch?.epistemic_status !== undefined) {
275
+ return EPISTEMIC_STATUSES.has(patch.epistemic_status) ? patch.epistemic_status : "subjective";
276
+ }
277
+ // Re-infer whenever any signal that feeds the heuristic changed: content
278
+ // (marker words), title (marker words), or type (summary/pattern are always
279
+ // inferred). Otherwise keep the stored status.
280
+ const changed = ["content", "title", "type"].some(
281
+ (k) => patch?.[k] !== undefined && patch[k] !== memory?.[k]
282
+ );
283
+ if (changed) {
284
+ return inferEpistemicStatus({ ...memory, ...patch });
285
+ }
286
+ return memory?.epistemic_status ?? "subjective";
287
+ }
288
+
289
+ // Per-type mirror sync receipts (peer blocker 4): a type is either committed
290
+ // (file written + fence applied), failed (last sync round errored for it), or
291
+ // pending (still owed a write).
292
+ const VALID_TYPE_STATUS = new Set(["committed", "failed", "pending"]);
293
+
294
+ // Pure helpers: no shared module state.
295
+
296
+ function sanitizePage(limit, offset, defaultLimit) {
297
+ const lim = Number.isInteger(limit) && limit > 0 ? limit : defaultLimit;
298
+ const off = Number.isInteger(offset) && offset > 0 ? offset : 0;
299
+ return { limit: lim, offset: off };
300
+ }
301
+
302
+ function escapeLike(q) {
303
+ return q.replace(/[\\%_]/g, (c) => `\\${c}`);
304
+ }
305
+
306
+ function parseTags(raw) {
307
+ try {
308
+ const arr = JSON.parse(raw);
309
+ return Array.isArray(arr) ? arr : [];
310
+ } catch {
311
+ return [];
312
+ }
313
+ }
314
+
315
+ function toRow(row) {
316
+ if (!row) return undefined;
317
+ return {
318
+ id: row.id,
319
+ type: row.type,
320
+ title: row.title,
321
+ content: row.content,
322
+ tags: parseTags(row.tags),
323
+ importance: row.importance,
324
+ forgotten: row.forgotten === 1,
325
+ archived: row.archived === 1,
326
+ session_disposed_at: row.session_disposed_at ?? undefined,
327
+ source: row.source ?? undefined,
328
+ session_id: row.session_id ?? undefined,
329
+ content_history: parseJsonArray(row.content_history),
330
+ quality_score: row.quality_score !== null && row.quality_score !== undefined ? Number(row.quality_score) : undefined,
331
+ epistemic_status: row.epistemic_status ?? "subjective",
332
+ created_at: row.created_at,
333
+ updated_at: row.updated_at,
334
+ last_accessed_at: row.last_accessed_at ?? undefined,
335
+ _full_content: row._full_content ?? undefined
336
+ };
337
+ }
338
+
339
+ function toDreamRun(row) {
340
+ if (!row) return undefined;
341
+ return {
342
+ id: row.id,
343
+ created_at: row.created_at,
344
+ status: row.status,
345
+ error: row.error ?? undefined,
346
+ provider: row.provider ?? undefined,
347
+ model: row.model ?? undefined,
348
+ snapshot_hash: row.snapshot_hash,
349
+ input_count: row.input_count,
350
+ input: row.input ? JSON.parse(row.input) : undefined,
351
+ decisions: row.decisions ? JSON.parse(row.decisions) : undefined,
352
+ outcome: row.outcome ? JSON.parse(row.outcome) : undefined,
353
+ applied: row.applied,
354
+ summary_stored: row.summary_stored === 1,
355
+ receipt: row.receipt,
356
+ policy_epoch: row.policy_epoch ?? 0,
357
+ run_type: row.run_type ?? "auto"
358
+ };
359
+ }
360
+
361
+ function toReceipt(row) {
362
+ if (!row) return undefined;
363
+ return {
364
+ receipt_id: row.receipt_id,
365
+ run_id: row.run_id,
366
+ record_id: row.record_id,
367
+ kind: row.kind,
368
+ input_digest: row.input_digest,
369
+ winner_id: row.winner_id ?? undefined,
370
+ loser_id: row.loser_id ?? undefined,
371
+ keep_source: row.keep_source ?? undefined,
372
+ sources: parseJsonArray(row.sources),
373
+ verdict: row.verdict,
374
+ count_before: row.count_before,
375
+ count_after: row.count_after,
376
+ policy_epoch: row.policy_epoch ?? 0,
377
+ created_at: row.created_at
378
+ };
379
+ }
380
+
381
+ function toConflictPending(row) {
382
+ if (!row) return undefined;
383
+ return {
384
+ id: row.id,
385
+ run_id: row.run_id ?? undefined,
386
+ memory_a: row.memory_a,
387
+ memory_b: row.memory_b,
388
+ reason: row.reason ?? undefined,
389
+ created_at: row.created_at,
390
+ resolved_at: row.resolved_at ?? undefined,
391
+ resolved_winner: row.resolved_winner ?? undefined
392
+ };
393
+ }
394
+
395
+ function toRecallRun(row) {
396
+ if (!row) return undefined;
397
+ return {
398
+ id: row.id,
399
+ query: row.query,
400
+ mode: row.mode,
401
+ topK: row.top_k,
402
+ threshold: row.threshold,
403
+ candidates: parseJsonArray(row.candidates),
404
+ created_at: row.created_at
405
+ };
406
+ }
407
+
408
+ function toRecallEval(row) {
409
+ if (!row) return undefined;
410
+ let metrics;
411
+ if (row.metrics != null) {
412
+ try { metrics = JSON.parse(row.metrics); } catch { metrics = undefined; }
413
+ }
414
+ return {
415
+ id: row.id,
416
+ recall_run_id: row.recall_run_id ?? undefined,
417
+ query: row.query,
418
+ expected_ids: parseJsonArray(row.expected_ids),
419
+ actual_ids: parseJsonArray(row.actual_ids),
420
+ metrics,
421
+ eval_type: row.eval_type,
422
+ created_at: row.created_at
423
+ };
424
+ }
425
+
426
+ function toEntity(row) {
427
+ if (!row) return undefined;
428
+ return {
429
+ id: row.id,
430
+ name: row.name,
431
+ type: row.type ?? undefined,
432
+ first_seen: row.first_seen,
433
+ last_seen: row.last_seen,
434
+ mention_count: row.mention_count,
435
+ canonical_memory_id: row.canonical_memory_id ?? undefined
436
+ };
437
+ }
438
+
439
+ function toAttr(row) {
440
+ if (!row) return undefined;
441
+ return {
442
+ id: row.id,
443
+ entity_id: row.entity_id,
444
+ attr_key: row.attr_key,
445
+ attr_value: row.attr_value,
446
+ memory_id: row.memory_id ?? undefined,
447
+ valid_from: row.valid_from,
448
+ valid_until: row.valid_until ?? undefined,
449
+ confidence: row.confidence,
450
+ source: row.source ?? undefined
451
+ };
452
+ }
453
+
454
+ function toRelation(row) {
455
+ if (!row) return undefined;
456
+ let metadata;
457
+ if (row.metadata != null) {
458
+ try {
459
+ metadata = JSON.parse(row.metadata);
460
+ } catch {
461
+ metadata = row.metadata;
462
+ }
463
+ }
464
+ return {
465
+ id: row.id,
466
+ from_entity: row.from_entity,
467
+ to_entity: row.to_entity,
468
+ relation_type: row.relation_type,
469
+ memory_id: row.memory_id ?? undefined,
470
+ created_at: row.created_at,
471
+ metadata
472
+ };
473
+ }
474
+
475
+ function toLlmAudit(row) {
476
+ if (!row) return undefined;
477
+ let metadata;
478
+ if (row.metadata != null) {
479
+ try {
480
+ metadata = JSON.parse(row.metadata);
481
+ } catch {
482
+ metadata = row.metadata;
483
+ }
484
+ }
485
+ return {
486
+ id: row.id,
487
+ timestamp: row.timestamp,
488
+ trigger_source: row.trigger_source,
489
+ operation_type: row.operation_type,
490
+ model_id: row.model_id,
491
+ input_tokens: row.input_tokens,
492
+ output_tokens: row.output_tokens,
493
+ total_tokens: row.total_tokens,
494
+ cost_usd: row.cost_usd,
495
+ duration_ms: row.duration_ms,
496
+ status: row.status,
497
+ error_message: row.error_message ?? undefined,
498
+ related_memory_ids: parseJsonArray(row.related_memory_ids),
499
+ metadata
500
+ };
501
+ }
502
+
503
+ function toMirrorState(row) {
504
+ if (!row) {
505
+ return {
506
+ dirty: false,
507
+ last_error: null,
508
+ last_attempt: null,
509
+ success_at: null,
510
+ generation: 0,
511
+ applied_generation: 0,
512
+ type_status: {}
513
+ };
514
+ }
515
+ let typeStatus = {};
516
+ if (row.type_status) {
517
+ try {
518
+ typeStatus = JSON.parse(row.type_status) || {};
519
+ } catch {
520
+ typeStatus = {};
521
+ }
522
+ }
523
+ return {
524
+ id: row.id,
525
+ dirty: row.dirty === 1,
526
+ last_error: row.last_error,
527
+ last_attempt: row.last_attempt,
528
+ success_at: row.success_at,
529
+ generation: Number(row.generation) || 0,
530
+ applied_generation: Number(row.applied_generation) || 0,
531
+ type_status: typeStatus
532
+ };
533
+ }
534
+
535
+ function parseJsonArray(raw) {
536
+ try {
537
+ const arr = JSON.parse(raw);
538
+ return Array.isArray(arr) ? arr : [];
539
+ } catch {
540
+ return [];
541
+ }
542
+ }
543
+
544
+ export function createStore(path) {
545
+ const db = new DatabaseSync(path);
546
+ // Set busy_timeout BEFORE the journal-mode switch (audit peer: 8-process WAL
547
+ // init). Switching a fresh DB to WAL takes an exclusive lock; when several
548
+ // processes open the same path simultaneously, that lock can fail with
549
+ // SQLITE_BUSY before the timeout is armed. With the timeout installed first,
550
+ // the WAL transition (and every later write) blocks and retries instead of
551
+ // failing outright, so concurrent init converges to a stable 447/447.
552
+ db.exec("PRAGMA busy_timeout = 5000;");
553
+ db.exec("PRAGMA journal_mode = WAL;");
554
+ db.exec(SCHEMA);
555
+
556
+ // Wiki-link dedup (v0.6.1): a (from_entity, to_entity) pair is unique only for
557
+ // relation_type='links_to'. This is a PARTIAL index scoped to links_to, so the
558
+ // append-only semantics of all other relation types (uses/depends_on/part_of/
559
+ // related_to/supersedes — the extractor and autoDream write these per-run
560
+ // without global dedup, and supersedes rows carry distinct metadata like
561
+ // attr_key/old_value) are preserved. Idempotent (IF NOT EXISTS), atomic, and
562
+ // race-safe. Legacy DBs have no links_to rows yet, so the index builds cleanly
563
+ // everywhere and never breaks plugin startup (a full-table UNIQUE index would
564
+ // fail on legacy duplicates).
565
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_relations_wikilink ON entity_relations(from_entity, to_entity, relation_type) WHERE relation_type = 'links_to'");
566
+
567
+ // Schema migrations for legacy databases (idempotent). Each ADD COLUMN is
568
+ // also race-safe: two concurrently-opening processes can both pass the
569
+ // PRAGMA table_info check before either ALTERs, so the ALTER itself is
570
+ // guarded against the "duplicate column name" error SQLite raises when the
571
+ // other process won the race (SQLite has no ADD COLUMN IF NOT EXISTS).
572
+ const addColumn = (table, column, ddl) => {
573
+ const cols = db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name);
574
+ if (!cols.includes(column)) {
575
+ try {
576
+ db.exec(ddl);
577
+ } catch (e) {
578
+ if (!/duplicate column name/i.test(String(e?.message ?? e))) throw e;
579
+ }
580
+ }
581
+ };
582
+
583
+ addColumn("memories", "archived", "ALTER TABLE memories ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
584
+ addColumn("memories", "session_disposed_at", "ALTER TABLE memories ADD COLUMN session_disposed_at TEXT");
585
+ addColumn("memories", "embedding", "ALTER TABLE memories ADD COLUMN embedding TEXT");
586
+ addColumn("memories", "last_accessed_at", "ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
587
+ addColumn("memories", "_full_content", "ALTER TABLE memories ADD COLUMN _full_content TEXT");
588
+ addColumn("memories", "epistemic_status", "ALTER TABLE memories ADD COLUMN epistemic_status TEXT NOT NULL DEFAULT 'subjective'");
589
+ addColumn("memories", "content_history", "ALTER TABLE memories ADD COLUMN content_history TEXT");
590
+ addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
591
+ addColumn("memories", "session_id", "ALTER TABLE memories ADD COLUMN session_id TEXT");
592
+
593
+ // Composite index for session-lifecycle queries (dispose/restore/listBySession).
594
+ // Created post-migration, NOT in SCHEMA: on legacy DBs both columns arrive via
595
+ // ADD COLUMN above, so the index would fail at db.exec(SCHEMA) time. CREATE
596
+ // INDEX IF NOT EXISTS is atomic, so the two-process race is safe here.
597
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id, session_disposed_at)");
598
+
599
+ // Legacy dream_runs without policy_epoch → backfill with the default epoch.
600
+ addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
601
+ addColumn("dream_runs", "run_type", "ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
602
+
603
+ // Legacy mirror_state without v0.3.6 generation columns → add each missing
604
+ // column idempotently (old DBs open cleanly, no data loss).
605
+ addColumn("mirror_state", "generation", "ALTER TABLE mirror_state ADD COLUMN generation INTEGER NOT NULL DEFAULT 0");
606
+ addColumn("mirror_state", "applied_generation", "ALTER TABLE mirror_state ADD COLUMN applied_generation INTEGER NOT NULL DEFAULT 0");
607
+ addColumn("mirror_state", "type_status", "ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
608
+
609
+ // Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
610
+ // (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
611
+ // enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
612
+ // round — so surface it as a hard error on open instead of silently reading it
613
+ // as a coherent generation. Fail-closed: the operator must repair or reset the
614
+ // state row rather than continue with a lie.
615
+ for (const col of ["generation", "applied_generation"]) {
616
+ const bad = db.prepare(
617
+ `SELECT id FROM mirror_state WHERE ${col} IS NOT NULL AND ${col} != CAST(${col} AS INTEGER) LIMIT 1`
618
+ ).get();
619
+ if (bad) {
620
+ throw new RangeError(
621
+ `mirror_state.${col} holds a non-integer value (legacy dirty state); ` +
622
+ `repair or reset the row before opening this database`
623
+ );
624
+ }
625
+ }
626
+
627
+ // Per-instance monotonic timestamp guard: consecutive writes within the same
628
+ // millisecond must still produce strictly increasing timestamps (test asserts
629
+ // updated_at != created_at). State lives in the store closure, not module scope.
630
+ let lastTs = "";
631
+ function nowIso() {
632
+ let ts = new Date().toISOString();
633
+ if (lastTs && ts <= lastTs) {
634
+ const d = new Date(lastTs);
635
+ d.setMilliseconds(d.getMilliseconds() + 1);
636
+ ts = d.toISOString();
637
+ }
638
+ lastTs = ts;
639
+ return ts;
640
+ }
641
+
642
+ function count(type, { includeForgotten = false, includeArchived = false, includeDisposed = false } = {}) {
643
+ const clauses = [];
644
+ const params = [];
645
+ if (type !== undefined) {
646
+ clauses.push("type = ?");
647
+ params.push(type);
648
+ }
649
+ if (!includeForgotten) {
650
+ clauses.push("forgotten = 0");
651
+ }
652
+ if (!includeArchived) {
653
+ clauses.push("archived = 0");
654
+ }
655
+ if (!includeDisposed) {
656
+ clauses.push("session_disposed_at IS NULL");
657
+ }
658
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
659
+ return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
660
+ }
661
+
662
+ function getById(id) {
663
+ const row = db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
664
+ return toRow(row);
665
+ }
666
+
667
+ /**
668
+ * Case-insensitive exact title lookup (v0.6.1 wiki-link). COLLATE NOCASE
669
+ * folds ASCII case (CJK titles are inherently case-free, so they match
670
+ * verbatim). Returns the first matching memory or undefined. Best-effort —
671
+ * used by wiki-link target resolution and the read APIs.
672
+ */
673
+ function findByTitle(title) {
674
+ if (typeof title !== "string" || !title.trim()) return undefined;
675
+ return toRow(db.prepare(
676
+ "SELECT * FROM memories WHERE title = ? COLLATE NOCASE LIMIT 1"
677
+ ).get(title.trim()));
678
+ }
679
+
680
+ function save(memory) {
681
+ const id = memory.id ?? randomUUID();
682
+ const type = memory.type;
683
+ if (!TYPES.has(type)) throw new Error(`invalid memory type: ${type}`);
684
+ if (memory.tags !== undefined && !Array.isArray(memory.tags)) {
685
+ throw new Error("tags must be an array");
686
+ }
687
+ const now = nowIso();
688
+ const tags = JSON.stringify(memory.tags ?? []);
689
+ const importance = Number.isInteger(memory.importance) ? memory.importance : 3;
690
+ const embedding = Array.isArray(memory.embedding) && memory.embedding.length
691
+ ? JSON.stringify(memory.embedding)
692
+ : null;
693
+ // Explicit valid status wins; otherwise infer from content/type. Falls back
694
+ // to 'subjective' (the column default) so legacy callers never break.
695
+ const epistemicStatus = EPISTEMIC_STATUSES.has(memory.epistemic_status)
696
+ ? memory.epistemic_status
697
+ : inferEpistemicStatus(memory);
698
+ runAtomically(() => {
699
+ db.prepare(
700
+ `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, session_id, content_history, quality_score, embedding, epistemic_status, created_at, updated_at)
701
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
702
+ ).run(
703
+ id,
704
+ type,
705
+ memory.title,
706
+ memory.content,
707
+ tags,
708
+ importance,
709
+ memory.archived ? 1 : 0,
710
+ memory.source ?? null,
711
+ memory.session_id ?? null,
712
+ JSON.stringify(memory.content_history ?? []),
713
+ Number.isFinite(memory.quality_score) ? memory.quality_score : null,
714
+ embedding,
715
+ epistemicStatus,
716
+ now,
717
+ now
718
+ );
719
+ // desired generation bumped in the same transaction as the write: once
720
+ // this commits, generation > applied_generation, so a crash right after
721
+ // (before syncMirror) is caught by recoverMirror on restart (peer
722
+ // blocker 1). ROLLBACK on error rolls this back with the write.
723
+ incrementGeneration();
724
+ });
725
+ return getById(id);
726
+ }
727
+
728
+ function update(id, patch) {
729
+ const existing = getById(id);
730
+ if (!existing) throw new Error(`memory not found: ${id}`);
731
+ const type = patch.type ?? existing.type;
732
+ if (!TYPES.has(type)) throw new Error(`invalid memory type: ${type}`);
733
+ if (patch.tags !== undefined && !Array.isArray(patch.tags)) {
734
+ throw new Error("tags must be an array");
735
+ }
736
+ const now = nowIso();
737
+ const embedding = patch.embedding !== undefined
738
+ ? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
739
+ : existing.embedding ?? null;
740
+ const epistemicStatus = resolveEpistemicStatus(existing, patch);
741
+ const contentHistory = Array.isArray(patch.content_history)
742
+ ? JSON.stringify(patch.content_history)
743
+ : (Array.isArray(existing.content_history) ? JSON.stringify(existing.content_history) : null);
744
+ const qualityScore = patch.quality_score !== undefined && Number.isFinite(patch.quality_score)
745
+ ? patch.quality_score
746
+ : (existing.quality_score ?? null);
747
+ runAtomically(() => {
748
+ db.prepare(
749
+ `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, updated_at=? WHERE id=?`
750
+ ).run(
751
+ type,
752
+ patch.title ?? existing.title,
753
+ patch.content ?? existing.content,
754
+ JSON.stringify(patch.tags ?? existing.tags),
755
+ Number.isInteger(patch.importance) ? patch.importance : existing.importance,
756
+ patch.source !== undefined ? patch.source : (existing.source ?? null),
757
+ contentHistory,
758
+ qualityScore,
759
+ embedding,
760
+ epistemicStatus,
761
+ now,
762
+ id
763
+ );
764
+ // Desired generation bumped in the same transaction as the update (peer
765
+ // blocker 1: crash between write and sync must still be recoverable).
766
+ incrementGeneration();
767
+ });
768
+ return getById(id);
769
+ }
770
+
771
+ function remove(id) {
772
+ runAtomically(() => {
773
+ db.prepare("DELETE FROM memories WHERE id = ?").run(id);
774
+ // Mirror sync must reflect the deletion; bump desired generation so a
775
+ // crash between the delete and syncMirror leaves a recoverable debt.
776
+ incrementGeneration();
777
+ });
778
+ }
779
+
780
+ /**
781
+ * Atomic compare-and-set update: applies `patch` only when the row still
782
+ * carries `expectedUpdatedAt` (the version token read by the caller). Returns
783
+ * the updated memory on success, or undefined when the row changed since the
784
+ * caller read it — the caller must re-read and retry. The version guard lives
785
+ * in the UPDATE's WHERE clause, so a concurrent read-modify-write across
786
+ * connections cannot silently overwrite a newer value (lost update).
787
+ */
788
+ function compareAndUpdate(id, expectedUpdatedAt, patch) {
789
+ const existing = getById(id);
790
+ if (!existing) throw new Error(`memory not found: ${id}`);
791
+ const type = patch.type ?? existing.type;
792
+ if (!TYPES.has(type)) throw new Error(`invalid memory type: ${type}`);
793
+ if (patch.tags !== undefined && !Array.isArray(patch.tags)) {
794
+ throw new Error("tags must be an array");
795
+ }
796
+ const now = nowIso();
797
+ const embedding = patch.embedding !== undefined
798
+ ? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
799
+ : existing.embedding ?? null;
800
+ const epistemicStatus = resolveEpistemicStatus(existing, patch);
801
+ const contentHistory = Array.isArray(patch.content_history)
802
+ ? JSON.stringify(patch.content_history)
803
+ : (Array.isArray(existing.content_history) ? JSON.stringify(existing.content_history) : null);
804
+ const qualityScore = patch.quality_score !== undefined && Number.isFinite(patch.quality_score)
805
+ ? patch.quality_score
806
+ : (existing.quality_score ?? null);
807
+ // The CAS UPDATE and the desired-generation bump must commit together (audit
808
+ // peer A): if the UPDATE autocommits first and the process dies before the
809
+ // increment, the store is mutated while generation == applied_generation and
810
+ // dirty == false — recoverMirror sees no debt and the mirror stays stale.
811
+ // Wrapping both in one transaction means a CAS miss rolls back cleanly too
812
+ // (no write, no generation bump).
813
+ let applied = false;
814
+ runAtomically(() => {
815
+ const result = db.prepare(
816
+ `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, updated_at=?
817
+ WHERE id=? AND updated_at=?`
818
+ ).run(
819
+ type,
820
+ patch.title ?? existing.title,
821
+ patch.content ?? existing.content,
822
+ JSON.stringify(patch.tags ?? existing.tags),
823
+ Number.isInteger(patch.importance) ? patch.importance : existing.importance,
824
+ patch.source !== undefined ? patch.source : (existing.source ?? null),
825
+ contentHistory,
826
+ qualityScore,
827
+ embedding,
828
+ epistemicStatus,
829
+ now,
830
+ id,
831
+ expectedUpdatedAt
832
+ );
833
+ if (result.changes === 0) return; // CAS miss: a concurrent write won
834
+ // Only bump desired generation on a successful CAS — a miss writes nothing.
835
+ incrementGeneration();
836
+ applied = true;
837
+ });
838
+ if (!applied) return undefined;
839
+ return getById(id);
840
+ }
841
+
842
+ function setForget(id, forgotten) {
843
+ runAtomically(() => {
844
+ db.prepare("UPDATE memories SET forgotten = ?, updated_at = ? WHERE id = ?")
845
+ .run(forgotten === true || forgotten === 1 ? 1 : 0, nowIso(), id);
846
+ incrementGeneration();
847
+ });
848
+ return getById(id);
849
+ }
850
+
851
+ function setArchived(id, archived) {
852
+ runAtomically(() => {
853
+ db.prepare("UPDATE memories SET archived = ?, updated_at = ? WHERE id = ?")
854
+ .run(archived ? 1 : 0, nowIso(), id);
855
+ incrementGeneration();
856
+ });
857
+ return getById(id);
858
+ }
859
+
860
+ // --- session lifecycle (v0.6.0) ------------------------------------------
861
+ // Session dispose is orthogonal to `archived`: memory_archive is the user/AI
862
+ // choosing to keep an entry long-term-but-quiet, while session_disposed_at
863
+ // marks entries hidden because the session they were born in was deleted
864
+ // (a reversible "undo" — restoreBySession clears it). They never clobber each
865
+ // other: restoreBySession must not resurrect user-archived memories.
866
+ // Mirrors list/search: disposed rows are hidden by default. A consumer that
867
+ // needs to see the full picture (e.g. a restore flow that tells the user
868
+ // "these N entries were hidden") opts in via includeDisposed.
869
+ function listBySession(sessionId, { includeDisposed = false } = {}) {
870
+ const disposedFilter = includeDisposed ? "" : "AND session_disposed_at IS NULL";
871
+ const rows = db.prepare(
872
+ `SELECT * FROM memories WHERE session_id = ? ${disposedFilter} ORDER BY updated_at DESC`
873
+ ).all(sessionId);
874
+ return rows.map(toRow);
875
+ }
876
+
877
+ // Idempotent by state guard, not timestamp compare (nowIso() differs every
878
+ // call, so a fresh-timestamp re-dispose would spuriously count): dispose only
879
+ // touches rows that are NOT yet disposed; restore only touches rows that ARE.
880
+ // updated_at is deliberately left alone — this is a lifecycle flag, not
881
+ // content — so a true flip is the sole trigger for a mirror generation.
882
+ function setDisposedBySession(sessionId, disposed) {
883
+ const at = disposed ? nowIso() : null;
884
+ let affected = 0;
885
+ runAtomically(() => {
886
+ const result = disposed
887
+ ? db.prepare(
888
+ "UPDATE memories SET session_disposed_at = ? WHERE session_id = ? AND session_disposed_at IS NULL"
889
+ ).run(at, sessionId)
890
+ : db.prepare(
891
+ "UPDATE memories SET session_disposed_at = NULL WHERE session_id = ? AND session_disposed_at IS NOT NULL"
892
+ ).run(sessionId);
893
+ affected = result.changes;
894
+ if (affected > 0) incrementGeneration();
895
+ });
896
+ return affected;
897
+ }
898
+
899
+ // --- sleep-mode storage support (v0.4.0) ---------------------------------
900
+ // touchLastAccess stamps the read time on recall/inject paths. It deliberately
901
+ // does NOT bump the mirror generation: reads must not mark the mirror dirty.
902
+ function touchLastAccess(id, at) {
903
+ if (!getById(id)) return false;
904
+ db.prepare("UPDATE memories SET last_accessed_at = ? WHERE id = ?")
905
+ .run(at ?? nowIso(), id);
906
+ return true;
907
+ }
908
+
909
+ // Shrink an aged memory to `summary`, parking its full body in _full_content.
910
+ // Idempotent: an already-demoted memory (non-null _full_content) is left
911
+ // untouched. minRefTimeMs guards the fast path — if last_accessed_at moved
912
+ // after the caller's snapshot (>= minRefTimeMs), the memory is hot again and
913
+ // is skipped. Returns the updated memory, or undefined when skipped/absent.
914
+ function demoteToSummary(id, summary, { minRefTimeMs } = {}) {
915
+ let changed = false;
916
+ runAtomically(() => {
917
+ const row = db.prepare("SELECT last_accessed_at, content, _full_content FROM memories WHERE id = ?").get(id);
918
+ if (!row || row._full_content) return;
919
+ if (minRefTimeMs !== undefined && row.last_accessed_at) {
920
+ const lastMs = Date.parse(row.last_accessed_at);
921
+ if (lastMs >= minRefTimeMs) return; // touched after snapshot — still hot
922
+ }
923
+ db.prepare(
924
+ "UPDATE memories SET content = ?, _full_content = ?, updated_at = ? WHERE id = ?"
925
+ ).run(summary, row.content, nowIso(), id);
926
+ incrementGeneration();
927
+ changed = true;
928
+ });
929
+ return changed ? getById(id) : undefined;
930
+ }
931
+
932
+ // Undo demoteToSummary: pull the parked body back into content.
933
+ function restoreContent(id) {
934
+ let changed = false;
935
+ runAtomically(() => {
936
+ const row = db.prepare("SELECT content, _full_content FROM memories WHERE id = ?").get(id);
937
+ if (!row || !row._full_content) return;
938
+ db.prepare(
939
+ "UPDATE memories SET content = ?, _full_content = NULL, updated_at = ? WHERE id = ?"
940
+ ).run(row._full_content, nowIso(), id);
941
+ incrementGeneration();
942
+ changed = true;
943
+ });
944
+ return changed ? getById(id) : undefined;
945
+ }
946
+
947
+ // Live memories that have not been touched since `cutMs` (never-touched ones
948
+ // fall back to created_at). Ordered by last access ascending — the coldest
949
+ // first. Used by sleep phase 2 to pick archival-demotion candidates.
950
+ function getUnrecalledSince(cutMs, { limit = 500 } = {}) {
951
+ const cutIso = new Date(cutMs).toISOString();
952
+ const rows = db.prepare(
953
+ `SELECT * FROM memories
954
+ WHERE forgotten = 0 AND archived = 0
955
+ AND session_disposed_at IS NULL
956
+ AND (last_accessed_at IS NULL OR last_accessed_at < ?)
957
+ ORDER BY COALESCE(last_accessed_at, created_at) ASC, id
958
+ LIMIT ?`
959
+ ).all(cutIso, limit);
960
+ return rows.map(toRow);
961
+ }
962
+
963
+ function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false, includeDisposed = false } = {}) {
964
+ const clauses = [];
965
+ const params = [];
966
+ if (type) {
967
+ clauses.push("type = ?");
968
+ params.push(type);
969
+ }
970
+ if (!includeForgotten) {
971
+ clauses.push("forgotten = 0");
972
+ }
973
+ if (!includeArchived) {
974
+ clauses.push("archived = 0");
975
+ }
976
+ if (!includeDisposed) {
977
+ clauses.push("session_disposed_at IS NULL");
978
+ }
979
+ const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
980
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
981
+ const rows = db.prepare(
982
+ `SELECT * FROM memories ${where} ORDER BY importance DESC, updated_at DESC, id LIMIT ? OFFSET ?`
983
+ ).all(...params, lim, off);
984
+ return rows.map(toRow);
985
+ }
986
+
987
+ function all() {
988
+ const rows = db.prepare("SELECT * FROM memories ORDER BY updated_at DESC").all();
989
+ return rows.map(toRow);
990
+ }
991
+
992
+ /** Set (or clear with null) the embedding vector of a memory. */
993
+ function setEmbedding(id, vector) {
994
+ const json = Array.isArray(vector) && vector.length ? JSON.stringify(vector) : null;
995
+ db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(json, id);
996
+ }
997
+
998
+ /** Batch fetch stored embeddings by id (v0.5.0 search-time semantic dedup).
999
+ * Returns a Map(id → number[]); rows without a parseable embedding are
1000
+ * simply absent from the map. */
1001
+ function getEmbeddings(ids) {
1002
+ const out = new Map();
1003
+ const list = (Array.isArray(ids) ? ids : []).filter(Boolean);
1004
+ for (let i = 0; i < list.length; i += 100) {
1005
+ const chunk = list.slice(i, i + 100);
1006
+ const rows = db.prepare(
1007
+ `SELECT id, embedding FROM memories
1008
+ WHERE embedding IS NOT NULL AND embedding != ''
1009
+ AND id IN (${chunk.map(() => "?").join(",")})`
1010
+ ).all(...chunk);
1011
+ for (const row of rows) {
1012
+ try {
1013
+ const vec = JSON.parse(row.embedding);
1014
+ if (Array.isArray(vec) && vec.length) out.set(row.id, vec);
1015
+ } catch { /* corrupt row: skip */ }
1016
+ }
1017
+ }
1018
+ return out;
1019
+ }
1020
+
1021
+ function embeddedCount() {
1022
+ return db.prepare(
1023
+ "SELECT count(*) AS c FROM memories WHERE embedding IS NOT NULL AND embedding != ''"
1024
+ ).get().c;
1025
+ }
1026
+
1027
+ /** Candidate rows still missing an embedding, for incremental re-indexing. */
1028
+ function needsEmbedding(limit = 50) {
1029
+ return db.prepare(
1030
+ `SELECT id, title, content FROM memories
1031
+ WHERE embedding IS NULL OR embedding = ''
1032
+ ORDER BY updated_at DESC LIMIT ?`
1033
+ ).all(limit);
1034
+ }
1035
+
1036
+ function search(query, { limit = 20, includeArchived = false, includeDisposed = false } = {}) {
1037
+ const q = String(query).trim();
1038
+ if (!q) return [];
1039
+ // Plain LIKE substring scan over title/content/tags (wildcards escaped so
1040
+ // user input matches literally). No FTS5: CJK substring matching needs
1041
+ // LIKE, and typical memory stores are small enough that a scan is fine.
1042
+ const like = `%${escapeLike(q)}%`;
1043
+ const { limit: lim } = sanitizePage(limit, 0, 20);
1044
+ const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
1045
+ const disposedFilter = includeDisposed ? "" : "session_disposed_at IS NULL AND ";
1046
+ const rows = db.prepare(
1047
+ `SELECT * FROM memories
1048
+ WHERE ${archivedFilter}${disposedFilter}forgotten = 0 AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')
1049
+ ORDER BY
1050
+ CASE WHEN title LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END,
1051
+ importance DESC,
1052
+ updated_at DESC,
1053
+ id
1054
+ LIMIT ?`
1055
+ ).all(like, like, like, like, lim);
1056
+ return rows.map(toRow);
1057
+ }
1058
+
1059
+ // --- vector search ------------------------------------------------------
1060
+
1061
+ function cosine(a, b) {
1062
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || !a.length) return 0;
1063
+ let dot = 0;
1064
+ let na = 0;
1065
+ let nb = 0;
1066
+ for (let i = 0; i < a.length; i++) {
1067
+ dot += a[i] * b[i];
1068
+ na += a[i] * a[i];
1069
+ nb += b[i] * b[i];
1070
+ }
1071
+ if (na === 0 || nb === 0) return 0;
1072
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
1073
+ }
1074
+
1075
+ /**
1076
+ * Brute-force cosine similarity over embedded rows. Returns rows decorated
1077
+ * with a `score` (0..1). Only rows with a stored embedding participate.
1078
+ */
1079
+ function searchVector(vector, { limit = 20, includeArchived = false, includeDisposed = false, threshold = 0 } = {}) {
1080
+ if (!Array.isArray(vector) || !vector.length) return [];
1081
+ const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
1082
+ const disposedFilter = includeDisposed ? "" : "session_disposed_at IS NULL AND ";
1083
+ const rows = db.prepare(
1084
+ `SELECT * FROM memories
1085
+ WHERE ${archivedFilter}${disposedFilter}forgotten = 0 AND embedding IS NOT NULL AND embedding != ''`
1086
+ ).all();
1087
+ const scored = [];
1088
+ for (const row of rows) {
1089
+ let v;
1090
+ try {
1091
+ v = JSON.parse(row.embedding);
1092
+ } catch {
1093
+ continue;
1094
+ }
1095
+ const score = cosine(vector, v);
1096
+ if (score >= threshold) scored.push({ row, score });
1097
+ }
1098
+ scored.sort((a, b) => b.score - a.score);
1099
+ const { limit: lim } = sanitizePage(limit, 0, 20);
1100
+ return scored.slice(0, lim).map(({ row, score }) => ({ ...toRow(row), score }));
1101
+ }
1102
+
1103
+ // --- autoDream audit trail ----------------------------------------------
1104
+
1105
+ /**
1106
+ * Persist one autoDream run. The audit row is machine-verifiable but never
1107
+ * triggers write hooks (it is bookkeeping, not a memory mutation): dream
1108
+ * records its own runs, and a notify here would loop back into the dream
1109
+ * scheduler. Writes are idempotent on run id (replay overwrites, never
1110
+ * duplicates) so the same logical run can be re-applied for verification.
1111
+ */
1112
+ function saveDreamRun(run) {
1113
+ const id = run.id ?? randomUUID();
1114
+ const now = nowIso();
1115
+ const policyEpoch = Number.isInteger(run.policy_epoch) ? run.policy_epoch : 0;
1116
+ const runType = run.run_type ?? "auto";
1117
+ db.prepare(
1118
+ `INSERT INTO dream_runs (id, created_at, status, error, provider, model, snapshot_hash,
1119
+ input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch, run_type)
1120
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1121
+ ON CONFLICT(id) DO UPDATE SET
1122
+ created_at=excluded.created_at, status=excluded.status, error=excluded.error,
1123
+ provider=excluded.provider, model=excluded.model, snapshot_hash=excluded.snapshot_hash,
1124
+ input_count=excluded.input_count, input=excluded.input, decisions=excluded.decisions,
1125
+ outcome=excluded.outcome, applied=excluded.applied, summary_stored=excluded.summary_stored,
1126
+ receipt=excluded.receipt, policy_epoch=excluded.policy_epoch, run_type=excluded.run_type`
1127
+ ).run(
1128
+ id,
1129
+ run.created_at ?? now,
1130
+ run.status,
1131
+ run.error ?? null,
1132
+ run.provider ?? null,
1133
+ run.model ?? null,
1134
+ run.snapshot_hash,
1135
+ run.input_count,
1136
+ run.input !== undefined ? JSON.stringify(run.input) : null,
1137
+ run.decisions !== undefined ? JSON.stringify(run.decisions) : null,
1138
+ run.outcome !== undefined ? JSON.stringify(run.outcome) : null,
1139
+ run.applied ?? 0,
1140
+ run.summary_stored ? 1 : 0,
1141
+ run.receipt,
1142
+ policyEpoch,
1143
+ runType
1144
+ );
1145
+ return getDreamRun(id);
1146
+ }
1147
+
1148
+ function getDreamRun(id) {
1149
+ const row = db.prepare("SELECT * FROM dream_runs WHERE id = ?").get(id);
1150
+ return toDreamRun(row);
1151
+ }
1152
+
1153
+ function listDreamRuns({ limit = 50, offset = 0 } = {}) {
1154
+ const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1155
+ const rows = db.prepare(
1156
+ "SELECT * FROM dream_runs ORDER BY created_at DESC, id LIMIT ? OFFSET ?"
1157
+ ).all(lim, off);
1158
+ return rows.map(toDreamRun);
1159
+ }
1160
+
1161
+ /**
1162
+ * Latest ruling-rule version seen on the audit trail. policy_epoch is a config
1163
+ * value stamped onto each run by the caller; reading the newest row's epoch
1164
+ * gives the current effective version, falling back to 0 (default) when the
1165
+ * trail is empty. Rules upgrades leave older runs with their original epoch,
1166
+ * so those decisions can be demoted to historical evidence.
1167
+ */
1168
+ function getLatestPolicyEpoch() {
1169
+ const row = db.prepare(
1170
+ "SELECT policy_epoch FROM dream_runs ORDER BY created_at DESC, id LIMIT 1"
1171
+ ).get();
1172
+ return row ? (row.policy_epoch ?? 0) : 0;
1173
+ }
1174
+
1175
+ // --- per-record receipt chain --------------------------------------------
1176
+
1177
+ /**
1178
+ * Persist one per-record receipt (a single merge/conflict/update verdict).
1179
+ * The run-level dream audit trail answers "did this run happen and with what
1180
+ * input"; the receipt chain drills down to each mutable verdict, carrying the
1181
+ * input digest (decision basis) plus count_before → count_after idempotency
1182
+ * checkpoints so replay drift can be located to the exact record/run. Like
1183
+ * the dream trail this is bookkeeping: it never triggers write hooks. Writes
1184
+ * are idempotent on receipt id (replay overwrites, never duplicates).
1185
+ */
1186
+ function saveReceipt(run) {
1187
+ const id = run.receipt_id ?? randomUUID();
1188
+ const now = nowIso();
1189
+ const policyEpoch = Number.isInteger(run.policy_epoch) ? run.policy_epoch : 0;
1190
+ db.prepare(
1191
+ `INSERT INTO receipt_chain (receipt_id, run_id, record_id, kind, input_digest,
1192
+ winner_id, loser_id, keep_source, sources, verdict, count_before, count_after,
1193
+ policy_epoch, created_at)
1194
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1195
+ ON CONFLICT(receipt_id) DO UPDATE SET
1196
+ run_id=excluded.run_id, record_id=excluded.record_id, kind=excluded.kind,
1197
+ input_digest=excluded.input_digest, winner_id=excluded.winner_id,
1198
+ loser_id=excluded.loser_id, keep_source=excluded.keep_source,
1199
+ sources=excluded.sources, verdict=excluded.verdict,
1200
+ count_before=excluded.count_before, count_after=excluded.count_after,
1201
+ policy_epoch=excluded.policy_epoch, created_at=excluded.created_at`
1202
+ ).run(
1203
+ id,
1204
+ run.run_id,
1205
+ run.record_id,
1206
+ run.kind,
1207
+ run.input_digest,
1208
+ run.winner_id ?? null,
1209
+ run.loser_id ?? null,
1210
+ run.keep_source ?? null,
1211
+ JSON.stringify(run.sources ?? []),
1212
+ run.verdict,
1213
+ run.count_before,
1214
+ run.count_after,
1215
+ policyEpoch,
1216
+ run.created_at ?? now
1217
+ );
1218
+ return getReceipt(id);
1219
+ }
1220
+
1221
+ function getReceipt(id) {
1222
+ const row = db.prepare("SELECT * FROM receipt_chain WHERE receipt_id = ?").get(id);
1223
+ return toReceipt(row);
1224
+ }
1225
+
1226
+ function listReceipts({ limit = 50, offset = 0, run_id } = {}) {
1227
+ const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1228
+ const clauses = [];
1229
+ const params = [];
1230
+ if (run_id) {
1231
+ clauses.push("run_id = ?");
1232
+ params.push(run_id);
1233
+ }
1234
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1235
+ const rows = db.prepare(
1236
+ `SELECT * FROM receipt_chain ${where} ORDER BY created_at DESC, receipt_id LIMIT ? OFFSET ?`
1237
+ ).all(...params, lim, off);
1238
+ return rows.map(toReceipt);
1239
+ }
1240
+
1241
+ // --- recall-layer audit trail -------------------------------------------
1242
+
1243
+ /**
1244
+ * Persist one recall run (the retrieval scene: query/mode/top-k/threshold +
1245
+ * the exact candidate list handed to the caller). Like the dream audit trail
1246
+ * this is bookkeeping, so it never triggers write hooks — a notify here would
1247
+ * loop back into search itself. Writes are idempotent on run id (replay
1248
+ * overwrites, never duplicates), matching saveDreamRun.
1249
+ */
1250
+ function saveRecallRun(run) {
1251
+ const id = run.id ?? randomUUID();
1252
+ db.prepare(
1253
+ `INSERT INTO recall_runs (id, query, mode, top_k, threshold, candidates, created_at)
1254
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1255
+ ON CONFLICT(id) DO UPDATE SET
1256
+ query=excluded.query, mode=excluded.mode, top_k=excluded.top_k,
1257
+ threshold=excluded.threshold, candidates=excluded.candidates,
1258
+ created_at=excluded.created_at`
1259
+ ).run(
1260
+ id,
1261
+ run.query,
1262
+ run.mode,
1263
+ run.topK ?? null,
1264
+ run.threshold ?? null,
1265
+ JSON.stringify(run.candidates ?? []),
1266
+ run.created_at ?? nowIso()
1267
+ );
1268
+ return getRecallRun(id);
1269
+ }
1270
+
1271
+ function getRecallRun(id) {
1272
+ const row = db.prepare("SELECT * FROM recall_runs WHERE id = ?").get(id);
1273
+ return toRecallRun(row);
1274
+ }
1275
+
1276
+ function listRecallRuns({ limit = 50, offset = 0, query } = {}) {
1277
+ const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1278
+ const clauses = [];
1279
+ const params = [];
1280
+ if (query) {
1281
+ clauses.push("query LIKE ? ESCAPE '\\'");
1282
+ params.push(`%${escapeLike(String(query))}%`);
1283
+ }
1284
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1285
+ const rows = db.prepare(
1286
+ `SELECT * FROM recall_runs ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`
1287
+ ).all(...params, lim, off);
1288
+ return rows.map(toRecallRun);
1289
+ }
1290
+
1291
+ // --- recall evaluation trail (方案 B: separate from the production audit) -
1292
+
1293
+ /**
1294
+ * Persist one retrieval-evaluation snapshot into recall_evals — the test/eval
1295
+ * sibling of recall_runs, deliberately stored apart so eval snapshots never
1296
+ * inflate the production recall audit. Like the other audit tables this is
1297
+ * bookkeeping: it never triggers write hooks. Writes are idempotent on id
1298
+ * (replay overwrites, never duplicates), matching saveRecallRun. recall_run_id
1299
+ * optionally links the eval to the recall_runs row that captured the same
1300
+ * retrieval scene (FK-referenced, null when no run was recorded).
1301
+ */
1302
+ function saveRecallEval(evalRow) {
1303
+ const id = evalRow.id ?? randomUUID();
1304
+ db.prepare(
1305
+ `INSERT INTO recall_evals (id, recall_run_id, query, expected_ids, actual_ids, metrics, eval_type, created_at)
1306
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1307
+ ON CONFLICT(id) DO UPDATE SET
1308
+ recall_run_id=excluded.recall_run_id, query=excluded.query,
1309
+ expected_ids=excluded.expected_ids, actual_ids=excluded.actual_ids,
1310
+ metrics=excluded.metrics, eval_type=excluded.eval_type,
1311
+ created_at=excluded.created_at`
1312
+ ).run(
1313
+ id,
1314
+ evalRow.recall_run_id ?? null,
1315
+ evalRow.query,
1316
+ JSON.stringify(evalRow.expected_ids ?? []),
1317
+ JSON.stringify(evalRow.actual_ids ?? []),
1318
+ JSON.stringify(evalRow.metrics ?? {}),
1319
+ evalRow.eval_type ?? "manual",
1320
+ evalRow.created_at ?? nowIso()
1321
+ );
1322
+ return getRecallEval(id);
1323
+ }
1324
+
1325
+ function getRecallEval(id) {
1326
+ const row = db.prepare("SELECT * FROM recall_evals WHERE id = ?").get(id);
1327
+ return toRecallEval(row);
1328
+ }
1329
+
1330
+ function listRecallEvals({ limit = 50, offset = 0, query } = {}) {
1331
+ const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1332
+ const clauses = [];
1333
+ const params = [];
1334
+ if (query) {
1335
+ clauses.push("query LIKE ? ESCAPE '\\'");
1336
+ params.push(`%${escapeLike(String(query))}%`);
1337
+ }
1338
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1339
+ const rows = db.prepare(
1340
+ `SELECT * FROM recall_evals ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`
1341
+ ).all(...params, lim, off);
1342
+ return rows.map(toRecallEval);
1343
+ }
1344
+
1345
+ // --- llm audit trail (Bug8) ---------------------------------------------
1346
+
1347
+ /**
1348
+ * Persist one LLM audit row (a background call's token/time/status receipt).
1349
+ * Bookkeeping like the other audit tables: it never triggers write hooks, so
1350
+ * recording a call can never loop back into the scheduler that made it. The
1351
+ * call itself is wrapped so a failure is captured (status='error') instead of
1352
+ * blocking the feature — only a throwing saveLlmAudit is swallowed, never the
1353
+ * LLM call.
1354
+ */
1355
+ function saveLlmAudit(entry) {
1356
+ const now = nowIso();
1357
+ const inTokens = Number.isFinite(entry.input_tokens) ? entry.input_tokens : 0;
1358
+ const outTokens = Number.isFinite(entry.output_tokens) ? entry.output_tokens : 0;
1359
+ db.prepare(
1360
+ `INSERT INTO llm_audit_logs (timestamp, trigger_source, operation_type, model_id,
1361
+ input_tokens, output_tokens, total_tokens, cost_usd, duration_ms, status,
1362
+ error_message, related_memory_ids, metadata)
1363
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1364
+ ).run(
1365
+ entry.timestamp ?? now,
1366
+ entry.trigger_source,
1367
+ entry.operation_type,
1368
+ entry.model_id,
1369
+ inTokens,
1370
+ outTokens,
1371
+ Number.isFinite(entry.total_tokens) ? entry.total_tokens : inTokens + outTokens,
1372
+ Number.isFinite(entry.cost_usd) ? entry.cost_usd : 0,
1373
+ Number.isFinite(entry.duration_ms) ? entry.duration_ms : 0,
1374
+ entry.status ?? "success",
1375
+ entry.error_message ?? null,
1376
+ JSON.stringify(entry.related_memory_ids ?? []),
1377
+ entry.metadata !== undefined
1378
+ ? (typeof entry.metadata === "string" ? entry.metadata : JSON.stringify(entry.metadata))
1379
+ : null
1380
+ );
1381
+ return toLlmAudit(db.prepare("SELECT * FROM llm_audit_logs ORDER BY id DESC LIMIT 1").get());
1382
+ }
1383
+
1384
+ function listLlmAudits({ limit = 50, offset = 0, source } = {}) {
1385
+ const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1386
+ const clauses = [];
1387
+ const params = [];
1388
+ if (source) {
1389
+ clauses.push("trigger_source = ?");
1390
+ params.push(source);
1391
+ }
1392
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1393
+ const rows = db.prepare(
1394
+ `SELECT * FROM llm_audit_logs ${where} ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?`
1395
+ ).all(...params, lim, off);
1396
+ return rows.map(toLlmAudit);
1397
+ }
1398
+
1399
+ function countLlmAudits({ source } = {}) {
1400
+ const clauses = [];
1401
+ const params = [];
1402
+ if (source) {
1403
+ clauses.push("trigger_source = ?");
1404
+ params.push(source);
1405
+ }
1406
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1407
+ return db.prepare(`SELECT count(*) AS c FROM llm_audit_logs ${where}`).get(...params).c;
1408
+ }
1409
+
1410
+ /**
1411
+ * Aggregate LLM spend over the last `days`: total calls/tokens/duration/cost,
1412
+ * broken down by trigger_source and by status. Used by the API's
1413
+ * /llm-audit/stats endpoint so the Web panel can show where budget goes.
1414
+ */
1415
+ function getLlmAuditStats({ days = 7 } = {}) {
1416
+ const since = new Date(Date.now() - days * 86400000).toISOString();
1417
+ const total = db.prepare(
1418
+ `SELECT count(*) AS c,
1419
+ COALESCE(SUM(input_tokens), 0) AS i,
1420
+ COALESCE(SUM(output_tokens), 0) AS o,
1421
+ COALESCE(SUM(total_tokens), 0) AS t,
1422
+ COALESCE(SUM(duration_ms), 0) AS d,
1423
+ COALESCE(SUM(cost_usd), 0) AS cst
1424
+ FROM llm_audit_logs WHERE timestamp >= ?`
1425
+ ).get(since);
1426
+ const bySource = db.prepare(
1427
+ `SELECT trigger_source AS source, count(*) AS c,
1428
+ COALESCE(SUM(total_tokens), 0) AS total_tokens
1429
+ FROM llm_audit_logs WHERE timestamp >= ?
1430
+ GROUP BY trigger_source ORDER BY total_tokens DESC`
1431
+ ).all(since);
1432
+ const byStatus = db.prepare(
1433
+ "SELECT status, count(*) AS c FROM llm_audit_logs WHERE timestamp >= ? GROUP BY status"
1434
+ ).all(since);
1435
+ return {
1436
+ days,
1437
+ since,
1438
+ total_calls: total.c,
1439
+ input_tokens: total.i,
1440
+ output_tokens: total.o,
1441
+ total_tokens: total.t,
1442
+ total_duration_ms: total.d,
1443
+ total_cost_usd: Number(total.cst),
1444
+ by_source: bySource,
1445
+ by_status: byStatus
1446
+ };
1447
+ }
1448
+
1449
+ /** Delete audit rows older than `before` (ISO string). Returns count removed. */
1450
+ function deleteOldLlmAudits(before) {
1451
+ return db.prepare("DELETE FROM llm_audit_logs WHERE timestamp < ?").run(before).changes;
1452
+ }
1453
+
1454
+ // --- failure memories ----------------------------------------------------
1455
+
1456
+ /**
1457
+ * Persist one failure record (user correction, failed expectation, etc.).
1458
+ * Like the dream audit trail this is bookkeeping: it never triggers write
1459
+ * hooks, so reflection mining of failures cannot loop back into the writer.
1460
+ */
1461
+ function saveFailure({ id, query, expected, actual, before, failure_type, memory_id }) {
1462
+ const now = nowIso();
1463
+ const beforeJson = before && typeof before === "object" ? JSON.stringify(before) : (before ?? null);
1464
+ db.prepare(
1465
+ `INSERT INTO failure_memories (id, query, expected, actual, before, failure_type, memory_id, created_at)
1466
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
1467
+ ).run(id ?? randomUUID(), query ?? null, expected ?? null, actual ?? null, beforeJson, failure_type, memory_id ?? null, now);
1468
+ return { id, query, expected, actual, before: before ?? null, failure_type, memory_id, created_at: now };
1469
+ }
1470
+
1471
+ function listFailures({ limit = 50, offset = 0, since, memory_id, failure_type } = {}) {
1472
+ const clauses = [];
1473
+ const params = [];
1474
+ if (since) { clauses.push("created_at >= ?"); params.push(since); }
1475
+ if (memory_id) { clauses.push("memory_id = ?"); params.push(memory_id); }
1476
+ if (failure_type) { clauses.push("failure_type = ?"); params.push(failure_type); }
1477
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1478
+ const lim = Number.isInteger(limit) && limit > 0 ? limit : 50;
1479
+ const off = Number.isInteger(offset) && offset > 0 ? offset : 0;
1480
+ return db.prepare(`SELECT * FROM failure_memories ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`).all(...params, lim, off)
1481
+ .map((row) => {
1482
+ let before;
1483
+ try { before = row.before ? JSON.parse(row.before) : null; } catch { before = null; }
1484
+ return { ...row, before };
1485
+ });
1486
+ }
1487
+
1488
+ /** Delete failure rows older than `before` (ISO string). Returns count removed. */
1489
+ function deleteOldFailures(before) {
1490
+ return db.prepare("DELETE FROM failure_memories WHERE created_at < ?").run(before).changes;
1491
+ }
1492
+
1493
+ // --- conflict freeze: pending manual review ------------------------------
1494
+
1495
+ /**
1496
+ * Park a detected conflict for human review (conflict freeze mode). The pair
1497
+ * order is normalized (sorted by id) so the same two memories are only ever
1498
+ * pending once — a re-detection in a later dream run is a no-op, never a
1499
+ * duplicate queue entry. Returns the pending row (freshly inserted, or the
1500
+ * existing unresolved row when the pair is already pending).
1501
+ */
1502
+ function saveConflictPending({ run_id, memory_a, memory_b, reason }) {
1503
+ const [a, b] = [memory_a, memory_b].sort();
1504
+ const existing = db.prepare(
1505
+ "SELECT * FROM conflict_pending WHERE memory_a = ? AND memory_b = ? AND resolved_at IS NULL LIMIT 1"
1506
+ ).get(a, b);
1507
+ if (existing) return toConflictPending(existing);
1508
+ const id = randomUUID();
1509
+ const now = nowIso();
1510
+ db.prepare(
1511
+ `INSERT INTO conflict_pending (id, run_id, memory_a, memory_b, reason, created_at)
1512
+ VALUES (?, ?, ?, ?, ?, ?)`
1513
+ ).run(id, run_id ?? null, a, b, reason ?? null, now);
1514
+ return toConflictPending(db.prepare("SELECT * FROM conflict_pending WHERE id = ?").get(id));
1515
+ }
1516
+
1517
+ /**
1518
+ * List pending conflicts, newest first. Unresolved rows only by default;
1519
+ * pass includeResolved to include resolved ones (audit view).
1520
+ */
1521
+ function listConflictPending({ limit = 50, offset = 0, includeResolved = false } = {}) {
1522
+ const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1523
+ const clauses = [];
1524
+ const params = [];
1525
+ if (!includeResolved) clauses.push("resolved_at IS NULL");
1526
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1527
+ const rows = db.prepare(
1528
+ `SELECT * FROM conflict_pending ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`
1529
+ ).all(...params, lim, off);
1530
+ return rows.map(toConflictPending);
1531
+ }
1532
+
1533
+ /**
1534
+ * Mark a pending conflict as reviewed. winner (optional) records which side
1535
+ * the human chose, keeping the resolution auditable. Returns the updated row,
1536
+ * or undefined for an unknown id.
1537
+ */
1538
+ function resolveConflictPending(id, { winner } = {}) {
1539
+ const row = db.prepare("SELECT * FROM conflict_pending WHERE id = ?").get(id);
1540
+ if (!row) return undefined;
1541
+ db.prepare("UPDATE conflict_pending SET resolved_at = ?, resolved_winner = ? WHERE id = ?")
1542
+ .run(nowIso(), winner ?? null, id);
1543
+ return toConflictPending(db.prepare("SELECT * FROM conflict_pending WHERE id = ?").get(id));
1544
+ }
1545
+
1546
+ /** Number of unresolved (awaiting review) pending conflicts. */
1547
+ function countConflictPending() {
1548
+ return db.prepare(
1549
+ "SELECT count(*) AS c FROM conflict_pending WHERE resolved_at IS NULL"
1550
+ ).get().c;
1551
+ }
1552
+
1553
+ function getFailureStats({ since } = {}) {
1554
+ const clause = since ? "WHERE created_at >= ?" : "";
1555
+ const params = since ? [since] : [];
1556
+ const rows = db.prepare(
1557
+ `SELECT failure_type, count(*) AS c FROM failure_memories ${clause} GROUP BY failure_type`
1558
+ ).all(...params);
1559
+ const stats = {};
1560
+ for (const row of rows) stats[row.failure_type] = row.c;
1561
+ return stats;
1562
+ }
1563
+
1564
+ // --- entity gene: named entities + time-boxed attrs + relations (v0.3.0) --
1565
+
1566
+ /**
1567
+ * Create a named entity. A fresh mention always records first_seen = now;
1568
+ * repeated sightings should call updateEntity (which bumps mention_count and
1569
+ * refreshes last_seen) rather than creating duplicate rows.
1570
+ */
1571
+ function createEntity({ name, type }) {
1572
+ const id = randomUUID();
1573
+ const now = nowIso();
1574
+ db.prepare(
1575
+ `INSERT INTO entities (id, name, type, first_seen, last_seen, mention_count, canonical_memory_id)
1576
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
1577
+ ).run(id, name, type ?? null, now, now, 1, null);
1578
+ return toEntity(db.prepare("SELECT * FROM entities WHERE id = ?").get(id));
1579
+ }
1580
+
1581
+ function findEntityByName(name) {
1582
+ return toEntity(db.prepare("SELECT * FROM entities WHERE name = ?").get(name));
1583
+ }
1584
+
1585
+ function findEntityById(id) {
1586
+ return toEntity(db.prepare("SELECT * FROM entities WHERE id = ?").get(id));
1587
+ }
1588
+
1589
+ /**
1590
+ * Apply a partial update to an entity, always refreshing last_seen. The
1591
+ * mention counter increments on every sighting unless the caller overrides
1592
+ * it explicitly via patch.mention_count (e.g. to correct a count).
1593
+ */
1594
+ function updateEntity(id, patch) {
1595
+ const old = findEntityById(id);
1596
+ if (!old) return undefined;
1597
+ const has = (k) => Object.prototype.hasOwnProperty.call(patch, k);
1598
+ const name = has("name") ? patch.name : old.name;
1599
+ const type = has("type") ? patch.type : old.type;
1600
+ const canonical_memory_id = has("canonical_memory_id")
1601
+ ? patch.canonical_memory_id
1602
+ : old.canonical_memory_id;
1603
+ const mention_count = has("mention_count")
1604
+ ? patch.mention_count
1605
+ : (old.mention_count ?? 1) + 1;
1606
+ const now = nowIso();
1607
+ db.prepare(
1608
+ `UPDATE entities SET name = ?, type = ?, last_seen = ?, mention_count = ?, canonical_memory_id = ? WHERE id = ?`
1609
+ ).run(name, type ?? null, now, mention_count, canonical_memory_id ?? null, id);
1610
+ return findEntityById(id);
1611
+ }
1612
+
1613
+ /**
1614
+ * Record an attribute value for an entity. The previous value for the same
1615
+ * entity+key is invalidated (valid_until = now) before the new row is
1616
+ * inserted, so exactly one row per entity+key is current (valid_until IS NULL).
1617
+ */
1618
+ function saveAttr({ entity_id, attr_key, attr_value, memory_id, confidence, source }) {
1619
+ const now = nowIso();
1620
+ invalidateOldAttr(entity_id, attr_key, now);
1621
+ const id = randomUUID();
1622
+ db.prepare(
1623
+ `INSERT INTO entity_attrs (id, entity_id, attr_key, attr_value, memory_id, valid_from, valid_until, confidence, source)
1624
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
1625
+ ).run(id, entity_id, attr_key, attr_value, memory_id ?? null, now, null, confidence ?? 1.0, source ?? null);
1626
+ return toAttr(db.prepare("SELECT * FROM entity_attrs WHERE id = ?").get(id));
1627
+ }
1628
+
1629
+ /** Mark every currently-valid attr row for entityId+attrKey as expired. Returns rows changed. */
1630
+ function invalidateOldAttr(entityId, attrKey, now) {
1631
+ return db.prepare(
1632
+ `UPDATE entity_attrs SET valid_until = ? WHERE entity_id = ? AND attr_key = ? AND valid_until IS NULL`
1633
+ ).run(now, entityId, attrKey).changes;
1634
+ }
1635
+
1636
+ /** Only the live value per attr_key (valid_until IS NULL). */
1637
+ function getCurrentAttrs(entityId) {
1638
+ return db.prepare(
1639
+ "SELECT * FROM entity_attrs WHERE entity_id = ? AND valid_until IS NULL"
1640
+ ).all(entityId).map(toAttr);
1641
+ }
1642
+
1643
+ /** Full history per attr_key, oldest first. */
1644
+ function getAttrHistory(entityId) {
1645
+ return db.prepare(
1646
+ "SELECT * FROM entity_attrs WHERE entity_id = ? ORDER BY valid_from"
1647
+ ).all(entityId).map(toAttr);
1648
+ }
1649
+
1650
+ /**
1651
+ * All attr rows carrying a reference to the given memory (any valid state),
1652
+ * oldest first. Used by autoDream's update path to record what an update
1653
+ * superseded (v0.3.0 Phase 4 / 4.3.1).
1654
+ */
1655
+ function getAttrsByMemory(memoryId) {
1656
+ return db.prepare(
1657
+ "SELECT * FROM entity_attrs WHERE memory_id = ? ORDER BY valid_from ASC"
1658
+ ).all(memoryId).map(toAttr);
1659
+ }
1660
+
1661
+ /**
1662
+ * Memories carrying a currently-valid attr matching key=value (deduped).
1663
+ * When value is empty/undefined, the attr_value filter is dropped and every
1664
+ * currently-valid memory for that attr_key is returned — the "attr:key"
1665
+ * (no =value) contract, v0.3.0. Only live rows (valid_until IS NULL) with a
1666
+ * memory reference participate, and each memory appears at most once.
1667
+ */
1668
+ function findMemoriesByAttr(key, value) {
1669
+ const empty = value === undefined || value === null || value === "";
1670
+ const sql = empty
1671
+ ? `SELECT DISTINCT memory_id FROM entity_attrs
1672
+ WHERE attr_key = ? AND valid_until IS NULL
1673
+ AND memory_id IS NOT NULL AND memory_id != ''`
1674
+ : `SELECT DISTINCT memory_id FROM entity_attrs
1675
+ WHERE attr_key = ? AND attr_value = ? AND valid_until IS NULL
1676
+ AND memory_id IS NOT NULL AND memory_id != ''`;
1677
+ const params = empty ? [key] : [key, value];
1678
+ const rows = db.prepare(sql).all(...params);
1679
+ const memories = [];
1680
+ const stmt = db.prepare("SELECT * FROM memories WHERE id = ?");
1681
+ for (const { memory_id } of rows) {
1682
+ const row = stmt.get(memory_id);
1683
+ if (row) memories.push(toRow(row));
1684
+ }
1685
+ return memories;
1686
+ }
1687
+
1688
+ // --- tag storage (v0.6.2) ------------------------------------------------
1689
+ // Tags ride the snapshot-style entity_attrs table (attr_key='tags'), so one
1690
+ // memory has exactly one live tags row; setMemoryTags invalidates any prior
1691
+ // live row and inserts a fresh one (idempotent overwrite). entity_id is the
1692
+ // memory id itself (the memory is its own tag entity), memory_id is kept so
1693
+ // the existing memory-scoped attr queries (getAttrsByMemory / findMemoriesByAttr)
1694
+ // and the bulk tag map all work without a special path.
1695
+
1696
+ /** Normalize an arbitrary tags input to a deduplicated string array.
1697
+ * Delegates to parser/tag.js sanitizeTags (shared validation with parseTags
1698
+ * and the autoDream tag-extractor): strips a leading `#`, trims, drops
1699
+ * non-strings/blanks/over-long/illegal-char tags. Kept as a thin alias so
1700
+ * the tag write path validates identically to the parser path. */
1701
+ function normalizeTags(tags) {
1702
+ return sanitizeTags(tags);
1703
+ }
1704
+
1705
+ /**
1706
+ * Set (overwrite) the live tag set for a memory. Exactly one tags row stays
1707
+ * live per memory: any prior live row is invalidated first, then one fresh
1708
+ * row is written (no-op when tags is empty — the invalidated row is removed
1709
+ * so "clear tags" = no live row). Returns the stored tag array.
1710
+ */
1711
+ function setMemoryTags(memoryId, tags) {
1712
+ const arr = normalizeTags(tags);
1713
+ const now = nowIso();
1714
+ // Atomic: the invalidation and the fresh row must land together, so a
1715
+ // mid-write crash never leaves the old live row gone without a replacement.
1716
+ // SAVEPOINT (not BEGIN) so this nests safely inside service.transaction().
1717
+ db.exec("SAVEPOINT set_memory_tags");
1718
+ try {
1719
+ db.prepare(
1720
+ `UPDATE entity_attrs SET valid_until = ?
1721
+ WHERE attr_key = 'tags' AND memory_id = ? AND valid_until IS NULL`
1722
+ ).run(now, memoryId);
1723
+ if (arr.length) {
1724
+ const id = randomUUID();
1725
+ db.prepare(
1726
+ `INSERT INTO entity_attrs (id, entity_id, attr_key, attr_value, memory_id, valid_from, valid_until, confidence, source)
1727
+ VALUES (?, ?, 'tags', ?, ?, ?, NULL, 1.0, 'manual')`
1728
+ ).run(id, memoryId, JSON.stringify(arr), memoryId, now);
1729
+ }
1730
+ db.exec("RELEASE set_memory_tags");
1731
+ } catch (e) {
1732
+ db.exec("ROLLBACK TO set_memory_tags");
1733
+ db.exec("RELEASE set_memory_tags");
1734
+ throw e;
1735
+ }
1736
+ return arr;
1737
+ }
1738
+
1739
+ /** Live tags for a memory ([] when none / unknown). */
1740
+ function getMemoryTags(memoryId) {
1741
+ const row = db.prepare(
1742
+ `SELECT attr_value FROM entity_attrs
1743
+ WHERE attr_key = 'tags' AND memory_id = ? AND valid_until IS NULL
1744
+ ORDER BY valid_from DESC LIMIT 1`
1745
+ ).get(memoryId);
1746
+ if (!row) return [];
1747
+ try {
1748
+ const arr = JSON.parse(row.attr_value);
1749
+ return Array.isArray(arr) ? arr : [];
1750
+ } catch {
1751
+ return [];
1752
+ }
1753
+ }
1754
+
1755
+ /** Bulk live-tags lookup for mirror rendering. Returns Map<memoryId, string[]>. */
1756
+ function getMemoryTagsMap(ids) {
1757
+ const out = new Map();
1758
+ const list = (Array.isArray(ids) ? ids : []).filter(Boolean);
1759
+ for (let i = 0; i < list.length; i += 100) {
1760
+ const chunk = list.slice(i, i + 100);
1761
+ const rows = db.prepare(
1762
+ `SELECT memory_id, attr_value FROM entity_attrs
1763
+ WHERE attr_key = 'tags' AND valid_until IS NULL
1764
+ AND memory_id IN (${chunk.map(() => "?").join(",")})`
1765
+ ).all(...chunk);
1766
+ for (const row of rows) {
1767
+ try {
1768
+ const arr = JSON.parse(row.attr_value);
1769
+ if (Array.isArray(arr) && arr.length) out.set(row.memory_id, arr);
1770
+ } catch { /* corrupt row: skip */ }
1771
+ }
1772
+ }
1773
+ return out;
1774
+ }
1775
+
1776
+ /**
1777
+ * Memories carrying a live tags row that contains EVERY requested tag
1778
+ * (AND semantics for a multi-tag query). attr_value is a JSON array, so the
1779
+ * match uses quoted `"tag"` substrings — `tag:lin` never collides with
1780
+ * `linux` because JSON array elements are quote-delimited. Only live rows
1781
+ * (valid_until IS NULL) with a memory reference participate; each memory
1782
+ * appears once.
1783
+ */
1784
+ function findMemoriesByTags(tags) {
1785
+ const list = normalizeTags(tags);
1786
+ if (!list.length) return [];
1787
+ const where = list.map(() => `attr_value LIKE ? ESCAPE '\\'`).join(" AND ");
1788
+ const params = list.map((t) => `%"${escapeLike(t)}"%`);
1789
+ const rows = db.prepare(
1790
+ `SELECT DISTINCT memory_id FROM entity_attrs
1791
+ WHERE attr_key = 'tags' AND valid_until IS NULL
1792
+ AND memory_id IS NOT NULL AND memory_id != ''
1793
+ AND (${where})`
1794
+ ).all(...params);
1795
+ // Same live-memory filter as getDirectory/store.search: forgotten/archived/
1796
+ // session-disposed memories are invisible to `tag:` recall.
1797
+ const stmt = db.prepare(
1798
+ "SELECT * FROM memories WHERE id = ? AND forgotten = 0 AND archived = 0 AND session_disposed_at IS NULL"
1799
+ );
1800
+ const memories = [];
1801
+ for (const { memory_id } of rows) {
1802
+ const row = stmt.get(memory_id);
1803
+ if (row) memories.push(toRow(row));
1804
+ }
1805
+ return memories;
1806
+ }
1807
+
1808
+ /**
1809
+ * Directory view (v0.6.3): group live memories by their entity_attrs-backed
1810
+ * tag set. A memory carrying N tags appears under all N tag folders; a memory
1811
+ * with no live tags lands in `untagged`. Only live rows participate —
1812
+ * forgotten, archived and session-disposed memories are excluded. Groups are
1813
+ * ordered by tag (locale-aware), group members and untagged follow the
1814
+ * canonical memory order (importance DESC, updated_at DESC, id).
1815
+ * @returns {{groups: {tag: string, memories: object[]}[], untagged: object[]}}
1816
+ */
1817
+ function getDirectory() {
1818
+ const rows = db.prepare(
1819
+ `SELECT * FROM memories
1820
+ WHERE forgotten = 0 AND archived = 0 AND session_disposed_at IS NULL
1821
+ ORDER BY importance DESC, updated_at DESC, id`
1822
+ ).all();
1823
+ const memories = rows.map(toRow);
1824
+ const tagMap = getMemoryTagsMap(memories.map((m) => m.id));
1825
+ const byTag = new Map(); // tag -> memory[]
1826
+ const untagged = [];
1827
+ for (const m of memories) {
1828
+ const tags = tagMap.get(m.id);
1829
+ if (!tags || tags.length === 0) {
1830
+ untagged.push(m);
1831
+ continue;
1832
+ }
1833
+ for (const tag of tags) {
1834
+ if (!byTag.has(tag)) byTag.set(tag, []);
1835
+ byTag.get(tag).push(m);
1836
+ }
1837
+ }
1838
+ const groups = [...byTag.entries()]
1839
+ .sort((a, b) => a[0].localeCompare(b[0]))
1840
+ .map(([tag, ms]) => ({ tag, memories: ms }));
1841
+ return { groups, untagged };
1842
+ }
1843
+
1844
+ /**
1845
+ * Record a typed relation between two entities. metadata (optional) is a
1846
+ * free-form JSON blob describing the relation. Relations are append-only —
1847
+ * callers that need idempotency (e.g. wiki-links, via saveWikiLinks) guard
1848
+ * with their own existence check plus the partial links_to unique index
1849
+ * (idx_relations_wikilink) as a race backstop.
1850
+ */
1851
+ function saveRelation({ from_entity, to_entity, relation_type, memory_id, metadata }) {
1852
+ const id = randomUUID();
1853
+ const now = nowIso();
1854
+ const metaStr = metadata === undefined
1855
+ ? null
1856
+ : typeof metadata === "string"
1857
+ ? metadata
1858
+ : JSON.stringify(metadata);
1859
+ db.prepare(
1860
+ `INSERT INTO entity_relations (id, from_entity, to_entity, relation_type, memory_id, created_at, metadata)
1861
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
1862
+ ).run(id, from_entity, to_entity, relation_type, memory_id ?? null, now, metaStr);
1863
+ return toRelation(db.prepare(
1864
+ "SELECT * FROM entity_relations WHERE from_entity = ? AND to_entity = ? AND relation_type = ? LIMIT 1"
1865
+ ).get(from_entity, to_entity, relation_type));
1866
+ }
1867
+
1868
+ /**
1869
+ * Record wiki-link relations (v0.6.1). For each target title, resolve the
1870
+ * target memory (case-insensitive title match via findByTitle) and write a
1871
+ * links_to relation:
1872
+ * from_entity = source memory title, to_entity = canonical target memory
1873
+ * title, relation_type = 'links_to', memory_id = source memory id.
1874
+ * Using the canonical resolved title keeps the graph case-consistent
1875
+ * ([[beta]] and [[Beta]] collapse onto the same to_entity), so backlink
1876
+ * lookups never fight the way a target was typed.
1877
+ * Fail-safe: a target with no matching memory is skipped (never an error).
1878
+ * Idempotent: an already-existing triple is a silent no-op (existence check
1879
+ * here + the idx_relations_wikilink partial unique index as a race backstop),
1880
+ * so `saved` only counts newly written relations. Returns { saved, skipped }.
1881
+ */
1882
+ function saveWikiLinks({ memoryId, title, targets }) {
1883
+ const saved = [];
1884
+ const skipped = [];
1885
+ const seen = new Set(); // canonical (lowercased) targets already handled
1886
+ const existsStmt = db.prepare(
1887
+ "SELECT id FROM entity_relations WHERE from_entity = ? AND to_entity = ? AND relation_type = ?"
1888
+ );
1889
+ const list = Array.isArray(targets)
1890
+ ? targets.filter((t) => typeof t === "string" && t.trim())
1891
+ : [];
1892
+ for (const raw of list) {
1893
+ const target = raw.trim();
1894
+ const key = target.toLowerCase();
1895
+ if (seen.has(key)) continue; // dedupe within a single call (case-insensitive)
1896
+ seen.add(key);
1897
+ const targetMem = findByTitle(target);
1898
+ if (!targetMem) {
1899
+ skipped.push(target); // 目标不存在 → 跳过(Fail-safe)
1900
+ continue;
1901
+ }
1902
+ const toEntity = targetMem.title;
1903
+ if (existsStmt.get(title, toEntity, "links_to")) continue; // already linked → no-op
1904
+ saved.push(saveRelation({
1905
+ from_entity: title,
1906
+ to_entity: toEntity,
1907
+ relation_type: "links_to",
1908
+ memory_id: memoryId,
1909
+ metadata: { target_memory_id: targetMem.id }
1910
+ }));
1911
+ }
1912
+ return { saved, skipped };
1913
+ }
1914
+
1915
+ /**
1916
+ * Re-point every attr row whose memory_id is fromMemoryId to toMemoryId
1917
+ * (autoDream merge migration, v0.3.0 Phase 4 / 4.3.2). When the keeper
1918
+ * already carries a live attr for the same entity+key, the source row is
1919
+ * superseded and invalidated instead (the keeper's value wins). Returns
1920
+ * { migrated, invalidated }.
1921
+ */
1922
+ function migrateAttrsToMemory(fromMemoryId, toMemoryId, now) {
1923
+ let migrated = 0;
1924
+ let invalidated = 0;
1925
+ const attrs = db.prepare(
1926
+ "SELECT * FROM entity_attrs WHERE memory_id = ?"
1927
+ ).all(fromMemoryId);
1928
+ for (const attr of attrs) {
1929
+ // 仅当 keeper 已有同 entity+key 的当前有效属性才视为被替代(限定 memory_id,
1930
+ // 避免把 loser 自身的 live 行误判为 keeper 行)。
1931
+ const keeperLive = db.prepare(
1932
+ "SELECT id FROM entity_attrs WHERE entity_id = ? AND attr_key = ? AND valid_until IS NULL AND memory_id = ?"
1933
+ ).get(attr.entity_id, attr.attr_key, toMemoryId);
1934
+ if (keeperLive) {
1935
+ db.prepare(
1936
+ "UPDATE entity_attrs SET valid_until = ? WHERE id = ?"
1937
+ ).run(now, attr.id);
1938
+ invalidated++;
1939
+ } else {
1940
+ db.prepare(
1941
+ "UPDATE entity_attrs SET memory_id = ? WHERE id = ?"
1942
+ ).run(toMemoryId, attr.id);
1943
+ migrated++;
1944
+ }
1945
+ }
1946
+ return { migrated, invalidated };
1947
+ }
1948
+
1949
+ /** Relations where the entity appears on either side (from or to). */
1950
+ function getRelations(entityId) {
1951
+ return db.prepare(
1952
+ "SELECT * FROM entity_relations WHERE from_entity = ? OR to_entity = ?"
1953
+ ).all(entityId, entityId).map(toRelation);
1954
+ }
1955
+
1956
+ /** All entities (optionally name-filtered, newest first). Used by sleep phase 4
1957
+ * orphan detection: an entity with zero relations is a candidate for relation
1958
+ * completion. */
1959
+ function listEntities({ limit = 1000 } = {}) {
1960
+ const rows = db.prepare(
1961
+ "SELECT * FROM entities ORDER BY last_seen DESC, name ASC LIMIT ?"
1962
+ ).all(limit);
1963
+ return rows.map(toEntity);
1964
+ }
1965
+
1966
+ // --- mirror sync state (F-NEW-03) -----------------------------------------
1967
+
1968
+ /**
1969
+ * Upsert the single mirror_state row (id='main'). patch accepts
1970
+ * {dirty?, last_error?, last_attempt?, success_at?, generation?,
1971
+ * applied_generation?, type_status?} — only the keys present on the object
1972
+ * are written, everything else is left untouched (partial upsert). type_status
1973
+ * is stored as JSON text (objects are serialized on write), generation /
1974
+ * applied_generation are coerced to non-negative integers. Returns the freshly
1975
+ * read state row (default shape when absent).
1976
+ */
1977
+ function setMirrorState(patch) {
1978
+ const ALLOWED = new Set([
1979
+ "dirty",
1980
+ "last_error",
1981
+ "last_attempt",
1982
+ "success_at",
1983
+ "generation",
1984
+ "applied_generation",
1985
+ "type_status"
1986
+ ]);
1987
+ const keys = Object.keys(patch).filter(
1988
+ (key) => ALLOWED.has(key) && Object.prototype.hasOwnProperty.call(patch, key)
1989
+ );
1990
+ if (keys.length === 0) {
1991
+ db.prepare(
1992
+ "INSERT INTO mirror_state (id) VALUES ('main') ON CONFLICT(id) DO NOTHING"
1993
+ ).run();
1994
+ return getMirrorState();
1995
+ }
1996
+ // 列同时出现在 INSERT 与 ON CONFLICT 里(excluded.*),保证首次插入也写入
1997
+ // patch 值,而不只是默认值;未传入的列保持不变(partial upsert)。
1998
+ const cols = [];
1999
+ const values = [];
2000
+ const updates = [];
2001
+ for (const key of keys) {
2002
+ let value = patch[key];
2003
+ if (key === "dirty") {
2004
+ value = value ? 1 : 0;
2005
+ } else if (key === "generation" || key === "applied_generation") {
2006
+ // Fail-closed integer enforcement (audit peer F): never truncate. A
2007
+ // fractional value like 1.5 previously passed the JS gate via
2008
+ // Math.trunc while SQLite's CHECK (>= 0) silently accepted it too, so a
2009
+ // dirty legacy row could carry a non-integer generation that reads as a
2010
+ // coherent applied round. Reject non-integers outright — the caller must
2011
+ // pass a whole number, and a stale dirty value stays visible instead of
2012
+ // being "repaired" into a misleading clean integer.
2013
+ value = Number(value);
2014
+ if (!Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
2015
+ throw new RangeError(`mirror_state.${key} out of range: ${value}`);
2016
+ }
2017
+ } else if (key === "type_status" && value != null && typeof value !== "string") {
2018
+ value = JSON.stringify(value);
2019
+ }
2020
+ cols.push(key);
2021
+ values.push(value);
2022
+ updates.push(`${key} = excluded.${key}`);
2023
+ }
2024
+ const placeholders = cols.map(() => "?").join(", ");
2025
+ db.prepare(
2026
+ `INSERT INTO mirror_state (id, ${cols.join(", ")}) VALUES ('main', ${placeholders})
2027
+ ON CONFLICT(id) DO UPDATE SET ${updates.join(", ")}`
2028
+ ).run(...values);
2029
+ return getMirrorState();
2030
+ }
2031
+
2032
+ /** Current mirror state; default {dirty:0, last_error:null, last_attempt:null, success_at:null, generation:0, applied_generation:0, type_status:{}} when absent. */
2033
+ function getMirrorState() {
2034
+ const row = db.prepare("SELECT * FROM mirror_state WHERE id = 'main'").get();
2035
+ return toMirrorState(row);
2036
+ }
2037
+
2038
+ /**
2039
+ * Mark the mirror dirty after a failed sync (dirty=1 + last_error +
2040
+ * last_attempt). v0.3.6: also bumps the desired generation so the debt is
2041
+ * bound to a specific sync round; applied_generation is left untouched
2042
+ * (the round was NOT applied). A stale worker that started earlier cannot
2043
+ * clear this newer debt — only a clean fenced to a generation at least as
2044
+ * recent as this one may.
2045
+ */
2046
+ function markMirrorDirty(error, now) {
2047
+ // Bump the desired generation atomically first — the new debt must be bound
2048
+ // to a fresh round so a stale worker cannot fence-clean it. Even if this
2049
+ // write fails (peer blocker 2), generation still advanced, so recoverMirror
2050
+ // sees generation > applied_generation and retries rather than false-clean.
2051
+ incrementGeneration();
2052
+ return setMirrorState({
2053
+ dirty: 1,
2054
+ last_error: error,
2055
+ last_attempt: now ?? nowIso()
2056
+ });
2057
+ }
2058
+
2059
+ /**
2060
+ * Fenced clean (CAS): mark the mirror clean for a specific generation.
2061
+ * First records that generation `gen` has been applied
2062
+ * (applied_generation = MAX(applied_generation, gen)), then clears dirty only
2063
+ * when the current desired generation has not advanced past gen — a stale
2064
+ * worker cleaning an older round must not wipe a newer failure's debt.
2065
+ * Returns the resulting state (dirty stays set when the fence holds).
2066
+ */
2067
+ function markMirrorCleanForGeneration(gen, now) {
2068
+ const current = getMirrorState();
2069
+ const applied = Math.max(current.applied_generation || 0, gen);
2070
+ const patch = { applied_generation: applied };
2071
+ if (applied >= gen && (current.generation || 0) <= gen) {
2072
+ patch.dirty = 0;
2073
+ patch.last_error = null;
2074
+ patch.success_at = now ?? nowIso();
2075
+ }
2076
+ return setMirrorState(patch);
2077
+ }
2078
+
2079
+ /** Convenience: mark the mirror clean for the current desired generation (backward-compatible with pre-v0.3.6 callers). */
2080
+ function markMirrorClean(now) {
2081
+ const current = getMirrorState();
2082
+ return markMirrorCleanForGeneration(current.generation || 0, now);
2083
+ }
2084
+
2085
+ /** Convenience: clear only the dirty flag + last_error, leaving success_at untouched (manual reconcile / retry path). */
2086
+ function clearMirrorDirty() {
2087
+ return setMirrorState({ dirty: 0, last_error: null });
2088
+ }
2089
+
2090
+ /**
2091
+ * Record per-type mirror status (partial success bookkeeping). `status` is a
2092
+ * patch {status: 'committed'|'failed'|'pending', applied_gen?, last_error?}
2093
+ * replacing the entry for `type` (other types untouched). Standardizing on an
2094
+ * explicit status gives per-type committed/failed/pending receipts — a type
2095
+ * whose file was written while a sibling failed is recorded as such, not
2096
+ * collapsed into a bulk "dirty" (peer blocker 4). Returns the updated state.
2097
+ */
2098
+ function setTypeStatus(type, status) {
2099
+ if (!VALID_TYPE_STATUS.has(status?.status)) {
2100
+ throw new TypeError(`setTypeStatus: status must be one of committed|failed|pending, got ${status?.status}`);
2101
+ }
2102
+ const current = getMirrorState();
2103
+ const statuses = current.type_status || {};
2104
+ statuses[type] = {
2105
+ status: status.status,
2106
+ ...(status.applied_gen !== undefined ? { applied_gen: status.applied_gen } : {}),
2107
+ ...(status.last_error !== undefined ? { last_error: status.last_error } : {})
2108
+ };
2109
+ return setMirrorState({ type_status: JSON.stringify(statuses) });
2110
+ }
2111
+
2112
+ /** Per-type mirror status map {type: {dirty, applied_gen, last_error}}, {} when unset. */
2113
+ function getTypeStatus() {
2114
+ const current = getMirrorState();
2115
+ return current.type_status || {};
2116
+ }
2117
+
2118
+ /** Run fn atomically: when the connection is already inside a transaction
2119
+ * (service.transaction's BEGIN), just run it — the outer COMMIT covers us.
2120
+ * Otherwise wrap in BEGIN/COMMIT so a memory write and its desired-generation
2121
+ * bump commit together: a crash between them can never leave a mutated store
2122
+ * with generation == applied (audit peer blocker 1, "crash window"). */
2123
+ function runAtomically(fn) {
2124
+ if (db.isTransaction) return fn();
2125
+ db.exec("BEGIN");
2126
+ try {
2127
+ const result = fn();
2128
+ db.exec("COMMIT");
2129
+ return result;
2130
+ } catch (error) {
2131
+ try { db.exec("ROLLBACK"); } catch { /* connection may be closed */ }
2132
+ throw error;
2133
+ }
2134
+ }
2135
+
2136
+ /** Bump the desired generation atomically (SQLite single-statement increment,
2137
+ * no SELECT-then-UPSERT race: peer blocker 3 lost 10 of 91 concurrent
2138
+ * increments under an 8-process probe). Returns the new mirror state.
2139
+ * Guards the upper bound: generation must stay within MAX_SAFE_INTEGER so
2140
+ * reads never hit ERR_OUT_OF_RANGE (peer blocker 6). */
2141
+ function incrementGeneration() {
2142
+ return runAtomically(() => {
2143
+ // Ensure the singleton row exists before incrementing (UPDATE alone would
2144
+ // match nothing on a fresh DB).
2145
+ db.prepare("INSERT OR IGNORE INTO mirror_state (id) VALUES ('main')").run();
2146
+ const row = db.prepare(
2147
+ "UPDATE mirror_state SET generation = generation + 1 WHERE id = 'main' AND generation < ? RETURNING generation"
2148
+ ).get(Number.MAX_SAFE_INTEGER);
2149
+ if (!row) throw new RangeError("mirror_state.generation exceeded MAX_SAFE_INTEGER");
2150
+ return getMirrorState();
2151
+ });
2152
+ }
2153
+
2154
+ return {
2155
+ db,
2156
+ count,
2157
+ getById,
2158
+ save,
2159
+ update,
2160
+ compareAndUpdate,
2161
+ remove,
2162
+ setForget,
2163
+ setArchived,
2164
+ listBySession,
2165
+ setDisposedBySession,
2166
+ touchLastAccess,
2167
+ demoteToSummary,
2168
+ restoreContent,
2169
+ getUnrecalledSince,
2170
+ list,
2171
+ all,
2172
+ search,
2173
+ setEmbedding,
2174
+ getEmbeddings,
2175
+ embeddedCount,
2176
+ needsEmbedding,
2177
+ searchVector,
2178
+ saveDreamRun,
2179
+ getDreamRun,
2180
+ listDreamRuns,
2181
+ getLatestPolicyEpoch,
2182
+ saveReceipt,
2183
+ getReceipt,
2184
+ listReceipts,
2185
+ saveRecallRun,
2186
+ getRecallRun,
2187
+ listRecallRuns,
2188
+ saveRecallEval,
2189
+ getRecallEval,
2190
+ listRecallEvals,
2191
+ saveLlmAudit,
2192
+ listLlmAudits,
2193
+ countLlmAudits,
2194
+ getLlmAuditStats,
2195
+ deleteOldLlmAudits,
2196
+ saveFailure,
2197
+ listFailures,
2198
+ getFailureStats,
2199
+ deleteOldFailures,
2200
+ saveConflictPending,
2201
+ listConflictPending,
2202
+ resolveConflictPending,
2203
+ countConflictPending,
2204
+ createEntity,
2205
+ findEntityByName,
2206
+ findEntityById,
2207
+ listEntities,
2208
+ updateEntity,
2209
+ saveAttr,
2210
+ invalidateOldAttr,
2211
+ getCurrentAttrs,
2212
+ getAttrHistory,
2213
+ getAttrsByMemory,
2214
+ findMemoriesByAttr,
2215
+ setMemoryTags,
2216
+ getMemoryTags,
2217
+ getMemoryTagsMap,
2218
+ findMemoriesByTags,
2219
+ getDirectory,
2220
+ saveRelation,
2221
+ saveWikiLinks,
2222
+ findByTitle,
2223
+ migrateAttrsToMemory,
2224
+ getRelations,
2225
+ setMirrorState,
2226
+ getMirrorState,
2227
+ markMirrorDirty,
2228
+ markMirrorClean,
2229
+ markMirrorCleanForGeneration,
2230
+ clearMirrorDirty,
2231
+ setTypeStatus,
2232
+ getTypeStatus,
2233
+ incrementGeneration,
2234
+ close() {
2235
+ db.close();
2236
+ }
2237
+ };
2238
+ }