@modusensus/dsh-mneme 0.2.10 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -13,6 +13,7 @@ import { createEmbedderByProvider } from "./local-embedder.js";
13
13
  import { LocalReranker } from "./reranker.js";
14
14
  import { createVectorIndex } from "./vector-index.js";
15
15
  import { Config } from "./config.js";
16
+ import { extractEntities } from "./entities/extractor.js";
16
17
  import { mkdirSync } from "node:fs";
17
18
  import { join } from "node:path";
18
19
  import { homedir } from "node:os";
@@ -164,6 +165,45 @@ export const apply = (ctx, config) => {
164
165
  service.setDreamHook(() => dream.maybeSchedule(service));
165
166
  }
166
167
 
168
+ // Entity gene extraction (v0.3.0): wire the extractor into the service as a
169
+ // hook so saveWithDedupe can fire-and-forget an extraction pass on fresh
170
+ // writes. The service never sees ctx.llm — index.js adapts it here into the
171
+ // callLLM(messages, options) => Promise<string> contract the extractor
172
+ // expects, reusing the same ctx.llm.stream consumption pattern as dream.js.
173
+ // Explicit opt-in only (entityExtractionEnabled defaults to false); any LLM
174
+ // failure degrades inside the extractor to { ok:false }, never a write error.
175
+ if (cfg.entityExtractionEnabled && ctx.llm) {
176
+ const streamEntityText = async (messages, options = {}) => {
177
+ let route = null;
178
+ if (options.model) {
179
+ route = { model: options.model };
180
+ } else {
181
+ try {
182
+ const sel = ctx.agentDefaultModel?.currentSelection?.();
183
+ if (sel?.provider && sel?.model) route = sel;
184
+ } catch { /* fall through to no route */ }
185
+ }
186
+ let text = "";
187
+ for await (const chunk of ctx.llm.stream({
188
+ ...(route ?? {}),
189
+ purpose: "entity-extract",
190
+ maxTokens: 4096,
191
+ messages
192
+ })) {
193
+ if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
194
+ if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) return undefined;
195
+ }
196
+ return text;
197
+ };
198
+ service.setEntityExtractor((memory) =>
199
+ extractEntities(memory, { store, config: cfg, callLLM: streamEntityText })
200
+ .catch((err) => {
201
+ ctx.logger?.warn?.(`[dsh-mneme] entity extraction failed: ${String(err)}`);
202
+ return { ok: false, error: String(err) };
203
+ })
204
+ );
205
+ }
206
+
167
207
  const disposers = [];
168
208
 
169
209
  ctx.inject(["systemPrompt"], (promptCtx) => {
package/src/service.js CHANGED
@@ -13,6 +13,14 @@ export function createService({ store, mirror, config, onWrite }) {
13
13
  // any content write it fire-and-forgets a re-embed of the row so vector
14
14
  // search stays in sync; failures are swallowed inside the embedder.
15
15
  let embedder = null;
16
+
17
+ // Optional entity extractor, installed via setEntityExtractor after creation
18
+ // (index.js injects it so the service never depends on the LLM directly).
19
+ // After a new memory is saved it fire-and-forgets an extraction pass for the
20
+ // entity gene (v0.3.0); failures are swallowed so a broken extraction never
21
+ // surfaces as a write failure. Extraction only runs when
22
+ // config.entityExtractionEnabled is true.
23
+ let entityExtractor = null;
16
24
  let vectorIndex = null;
17
25
  let reranker = null;
18
26
 
@@ -36,6 +44,25 @@ export function createService({ store, mirror, config, onWrite }) {
36
44
  }
37
45
  }
38
46
 
47
+ /**
48
+ * Fire-and-forget entity extraction for a freshly saved memory (entity gene
49
+ * v0.3.0). Opt-in via config.entityExtractionEnabled; the extractor is
50
+ * injected as a hook so the service never needs a direct LLM reference.
51
+ * The hook itself is expected to resolve to { ok:boolean } and never throw;
52
+ * a thrown rejection is swallowed here as a final fail-safe.
53
+ */
54
+ function scheduleEntityExtraction(memory) {
55
+ if (txDepth > 0) return; // deferred to the transaction's commit
56
+ if (!config.entityExtractionEnabled || !entityExtractor) return;
57
+ try {
58
+ entityExtractor(memory).catch((err) => {
59
+ console.warn("entity extraction failed:", err);
60
+ });
61
+ } catch (err) {
62
+ console.warn("entity extraction failed:", err);
63
+ }
64
+ }
65
+
39
66
  /**
40
67
  * Cross-encoder rerank over a candidate list (best effort). Reranker
41
68
  * failures degrade to the original candidate order — reranking is an
@@ -58,6 +85,48 @@ export function createService({ store, mirror, config, onWrite }) {
58
85
  }
59
86
  }
60
87
 
88
+ /**
89
+ * Search for memories attached to a named entity (v0.3.0 Phase 3).
90
+ * 合并优先级(桉桉确认):entity_attrs.memory_id 精确关联 = 1.0 > 关键词提及 = 0.7;
91
+ * attr 命中不覆盖,keyword 只补充召回,最后按 _score 降序取 topK。
92
+ * @param {string} entityName
93
+ * @param {object} [options]
94
+ * @param {number} [options.topK=20]
95
+ * @returns {any[]}
96
+ */
97
+ function searchByEntity(entityName, { topK = 20 } = {}) {
98
+ const entity = store.findEntityByName(entityName);
99
+ if (!entity) return [];
100
+ const attrs = store.getCurrentAttrs(entity.id);
101
+ const memoryIds = [...new Set(attrs.map((a) => a.memory_id).filter(Boolean))];
102
+ const attrHits = memoryIds.map((id) => store.getById(id)).filter(Boolean);
103
+ const keywordHits = store.search(entityName, { limit: topK });
104
+ const merged = new Map();
105
+ for (const mem of attrHits) merged.set(mem.id, { ...mem, _source: "entity_attr", _score: 1.0 });
106
+ for (const mem of keywordHits) {
107
+ if (!merged.has(mem.id)) merged.set(mem.id, { ...mem, _source: "keyword", _score: 0.7 });
108
+ }
109
+ return Array.from(merged.values()).sort((a, b) => b._score - a._score).slice(0, topK);
110
+ }
111
+
112
+ /**
113
+ * Search for memories by attribute key/value (v0.3.0 Phase 3).
114
+ * value 为空时由 store.findMemoriesByAttr 返回该 key 的全部有效记忆。
115
+ * @param {string} key
116
+ * @param {string | undefined} value
117
+ * @param {object} [options]
118
+ * @param {number} [options.topK=20]
119
+ * @returns {any[]}
120
+ */
121
+ function searchByAttr(key, value, { topK = 20 } = {}) {
122
+ if (!key) return [];
123
+ // value 可能为 undefined(attr:key 无 = 值):归一为空串后交给
124
+ // store.findMemoriesByAttr —— 空 value 契约 = 返回该 attr_key 的全部
125
+ // 当前有效记忆(v0.3.0,store.js 已实现)。
126
+ const rows = store.findMemoriesByAttr(key, value ?? "");
127
+ return rows.slice(0, topK);
128
+ }
129
+
61
130
  /**
62
131
  * Semantic-aware memory search: keyword recall (store.search) plus optional
63
132
  * vector recall + rerank. mode:
@@ -85,9 +154,22 @@ export function createService({ store, mirror, config, onWrite }) {
85
154
  return base * (0.5 + (row.importance ?? 3) / 10);
86
155
  }
87
156
 
88
- async function searchMemories(query, { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = false } = {}) {
157
+ async function searchMemories(query, options = {}) {
158
+ const { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = false } = options;
89
159
  const q = String(query ?? "").trim();
90
160
  if (!q) return [];
161
+
162
+ // entity:/attr: 前缀路由(v0.3.0 Phase 3)。entitySearchEnabled 关闭时走原逻辑。
163
+ if (config?.entitySearchEnabled) {
164
+ if (q.startsWith("entity:")) {
165
+ return searchByEntity(q.slice(7).trim(), options);
166
+ }
167
+ if (q.startsWith("attr:")) {
168
+ const [key, value] = q.slice(5).split("=");
169
+ return searchByAttr(key, value, options);
170
+ }
171
+ }
172
+
91
173
  const lim = topK > 0 ? topK : 20;
92
174
 
93
175
  // Keyword results, decorated with a score so they can be weight-blended
@@ -221,7 +303,11 @@ export function createService({ store, mirror, config, onWrite }) {
221
303
  throw error;
222
304
  } finally {
223
305
  txDepth--;
224
- syncMirror();
306
+ try {
307
+ syncMirror();
308
+ } catch (error) {
309
+ console.warn("syncMirror failed after transaction:", error);
310
+ }
225
311
  notifyWrite();
226
312
  }
227
313
  }
@@ -257,6 +343,7 @@ export function createService({ store, mirror, config, onWrite }) {
257
343
  syncMirror();
258
344
  notifyWrite();
259
345
  scheduleEmbed(created);
346
+ scheduleEntityExtraction(created);
260
347
  return { action: "created", memory: created };
261
348
  }
262
349
 
@@ -293,6 +380,17 @@ export function createService({ store, mirror, config, onWrite }) {
293
380
  if (typeof edit.title === "string" && edit.title.trim()) patch.title = edit.title.trim();
294
381
  if (typeof edit.content === "string" && edit.content.trim()) patch.content = edit.content.trim();
295
382
  if (Object.keys(patch).length) {
383
+ // 启动回灌(F-NEW-01):digest 存在且匹配 = 文件自渲染后无人触碰(旧机器
384
+ // 镜像),机器 wins,DB 的 New 必须保留,静默改回 Old 是 bug。
385
+ const digestMatches = typeof edit.digest === "string"
386
+ && typeof edit.title === "string"
387
+ && typeof edit.content === "string"
388
+ && createHash("sha256").update(`${edit.title}\x00${edit.content}`).digest("hex") === edit.digest;
389
+ if (digestMatches) continue;
390
+ // 文件 == store(无实际变化)时不覆盖,也不计入 applied。
391
+ const hasDiff = (patch.title !== undefined && existing.title !== patch.title)
392
+ || (patch.content !== undefined && existing.content !== patch.content);
393
+ if (!hasDiff) continue;
296
394
  store.update(edit.id, patch);
297
395
  applied++;
298
396
  }
@@ -387,7 +485,11 @@ export function createService({ store, mirror, config, onWrite }) {
387
485
  */
388
486
  function syncMirror() {
389
487
  if (txDepth > 0 || !mirror) return; // deferred to the transaction's commit
390
- mirror.sync(reconcileHumanEdits(store.list({ limit: 500, includeForgotten: false })));
488
+ try {
489
+ mirror.sync(reconcileHumanEdits(store.list({ limit: 500, includeForgotten: false })));
490
+ } catch (error) {
491
+ console.warn("syncMirror failed:", error);
492
+ }
391
493
  }
392
494
 
393
495
  return {
@@ -398,6 +500,7 @@ export function createService({ store, mirror, config, onWrite }) {
398
500
  transaction,
399
501
  setDreamHook(fn) { dreamHook = fn; },
400
502
  setEmbedder(emb) { embedder = emb; },
503
+ setEntityExtractor(fn) { entityExtractor = fn; },
401
504
  setVectorIndex(vi) { vectorIndex = vi; },
402
505
  setReranker(rn) { reranker = rn; },
403
506
  setRecallRecorder(fn) { recallRecorder = fn; },
@@ -498,6 +601,13 @@ export function createService({ store, mirror, config, onWrite }) {
498
601
  saveConflictPending: (r) => store.saveConflictPending(r),
499
602
  listConflictPending: (opts) => store.listConflictPending(opts),
500
603
  resolveConflictPending: (id, o) => store.resolveConflictPending(id, o),
501
- countConflictPending: () => store.countConflictPending()
604
+ countConflictPending: () => store.countConflictPending(),
605
+ // Entity gene (v0.3.0) passthroughs for the autoDream apply path
606
+ // (applyDecisions): records supersedes relations after an update and
607
+ // migrates entity_attrs on merge. Bookkeeping writes like the audit
608
+ // passthroughs above — never write-hook-triggering memory mutations.
609
+ saveRelation: (r) => store.saveRelation(r),
610
+ getAttrsByMemory: (id) => store.getAttrsByMemory(id),
611
+ migrateAttrsToMemory: (fromId, toId, now) => store.migrateAttrsToMemory(fromId, toId, now)
502
612
  };
503
613
  }
package/src/store.js CHANGED
@@ -118,6 +118,52 @@ CREATE TABLE IF NOT EXISTS conflict_pending (
118
118
  resolved_winner TEXT
119
119
  );
120
120
  CREATE INDEX IF NOT EXISTS idx_conflict_pending_unresolved ON conflict_pending(resolved_at);
121
+
122
+ -- entity gene (v0.3.0): named entities mentioned across memories, with
123
+ -- time-boxed attributes (valid_from → valid_until) and typed relations.
124
+ -- Attributes follow the snapshot style: saveAttr invalidates the previous
125
+ -- value for the same entity+key before inserting a new row, so the current
126
+ -- value is always the row with valid_until IS NULL.
127
+ CREATE TABLE IF NOT EXISTS entities (
128
+ id TEXT PRIMARY KEY,
129
+ name TEXT NOT NULL,
130
+ type TEXT,
131
+ first_seen TEXT NOT NULL,
132
+ last_seen TEXT NOT NULL,
133
+ mention_count INTEGER DEFAULT 1,
134
+ canonical_memory_id TEXT
135
+ );
136
+ CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
137
+ CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(type);
138
+
139
+ CREATE TABLE IF NOT EXISTS entity_attrs (
140
+ id TEXT PRIMARY KEY,
141
+ entity_id TEXT NOT NULL,
142
+ attr_key TEXT NOT NULL,
143
+ attr_value TEXT NOT NULL,
144
+ memory_id TEXT,
145
+ valid_from TEXT NOT NULL,
146
+ valid_until TEXT,
147
+ confidence REAL DEFAULT 1.0,
148
+ source TEXT
149
+ );
150
+ CREATE INDEX IF NOT EXISTS idx_attrs_entity ON entity_attrs(entity_id);
151
+ CREATE INDEX IF NOT EXISTS idx_attrs_key ON entity_attrs(attr_key);
152
+ CREATE INDEX IF NOT EXISTS idx_attrs_valid ON entity_attrs(valid_from, valid_until);
153
+ CREATE INDEX IF NOT EXISTS idx_attrs_memory ON entity_attrs(memory_id);
154
+
155
+ CREATE TABLE IF NOT EXISTS entity_relations (
156
+ id TEXT PRIMARY KEY,
157
+ from_entity TEXT NOT NULL,
158
+ to_entity TEXT NOT NULL,
159
+ relation_type TEXT NOT NULL,
160
+ memory_id TEXT,
161
+ created_at TEXT NOT NULL,
162
+ metadata TEXT
163
+ );
164
+ CREATE INDEX IF NOT EXISTS idx_relations_from ON entity_relations(from_entity);
165
+ CREATE INDEX IF NOT EXISTS idx_relations_to ON entity_relations(to_entity);
166
+ CREATE INDEX IF NOT EXISTS idx_relations_type ON entity_relations(relation_type);
121
167
  `;
122
168
 
123
169
  const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
@@ -228,6 +274,55 @@ function toRecallRun(row) {
228
274
  };
229
275
  }
230
276
 
277
+ function toEntity(row) {
278
+ if (!row) return undefined;
279
+ return {
280
+ id: row.id,
281
+ name: row.name,
282
+ type: row.type ?? undefined,
283
+ first_seen: row.first_seen,
284
+ last_seen: row.last_seen,
285
+ mention_count: row.mention_count,
286
+ canonical_memory_id: row.canonical_memory_id ?? undefined
287
+ };
288
+ }
289
+
290
+ function toAttr(row) {
291
+ if (!row) return undefined;
292
+ return {
293
+ id: row.id,
294
+ entity_id: row.entity_id,
295
+ attr_key: row.attr_key,
296
+ attr_value: row.attr_value,
297
+ memory_id: row.memory_id ?? undefined,
298
+ valid_from: row.valid_from,
299
+ valid_until: row.valid_until ?? undefined,
300
+ confidence: row.confidence,
301
+ source: row.source ?? undefined
302
+ };
303
+ }
304
+
305
+ function toRelation(row) {
306
+ if (!row) return undefined;
307
+ let metadata;
308
+ if (row.metadata != null) {
309
+ try {
310
+ metadata = JSON.parse(row.metadata);
311
+ } catch {
312
+ metadata = row.metadata;
313
+ }
314
+ }
315
+ return {
316
+ id: row.id,
317
+ from_entity: row.from_entity,
318
+ to_entity: row.to_entity,
319
+ relation_type: row.relation_type,
320
+ memory_id: row.memory_id ?? undefined,
321
+ created_at: row.created_at,
322
+ metadata
323
+ };
324
+ }
325
+
231
326
  function parseJsonArray(raw) {
232
327
  try {
233
328
  const arr = JSON.parse(raw);
@@ -805,6 +900,190 @@ export function createStore(path) {
805
900
  return stats;
806
901
  }
807
902
 
903
+ // --- entity gene: named entities + time-boxed attrs + relations (v0.3.0) --
904
+
905
+ /**
906
+ * Create a named entity. A fresh mention always records first_seen = now;
907
+ * repeated sightings should call updateEntity (which bumps mention_count and
908
+ * refreshes last_seen) rather than creating duplicate rows.
909
+ */
910
+ function createEntity({ name, type }) {
911
+ const id = randomUUID();
912
+ const now = nowIso();
913
+ db.prepare(
914
+ `INSERT INTO entities (id, name, type, first_seen, last_seen, mention_count, canonical_memory_id)
915
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
916
+ ).run(id, name, type ?? null, now, now, 1, null);
917
+ return toEntity(db.prepare("SELECT * FROM entities WHERE id = ?").get(id));
918
+ }
919
+
920
+ function findEntityByName(name) {
921
+ return toEntity(db.prepare("SELECT * FROM entities WHERE name = ?").get(name));
922
+ }
923
+
924
+ function findEntityById(id) {
925
+ return toEntity(db.prepare("SELECT * FROM entities WHERE id = ?").get(id));
926
+ }
927
+
928
+ /**
929
+ * Apply a partial update to an entity, always refreshing last_seen. The
930
+ * mention counter increments on every sighting unless the caller overrides
931
+ * it explicitly via patch.mention_count (e.g. to correct a count).
932
+ */
933
+ function updateEntity(id, patch) {
934
+ const old = findEntityById(id);
935
+ if (!old) return undefined;
936
+ const has = (k) => Object.prototype.hasOwnProperty.call(patch, k);
937
+ const name = has("name") ? patch.name : old.name;
938
+ const type = has("type") ? patch.type : old.type;
939
+ const canonical_memory_id = has("canonical_memory_id")
940
+ ? patch.canonical_memory_id
941
+ : old.canonical_memory_id;
942
+ const mention_count = has("mention_count")
943
+ ? patch.mention_count
944
+ : (old.mention_count ?? 1) + 1;
945
+ const now = nowIso();
946
+ db.prepare(
947
+ `UPDATE entities SET name = ?, type = ?, last_seen = ?, mention_count = ?, canonical_memory_id = ? WHERE id = ?`
948
+ ).run(name, type ?? null, now, mention_count, canonical_memory_id ?? null, id);
949
+ return findEntityById(id);
950
+ }
951
+
952
+ /**
953
+ * Record an attribute value for an entity. The previous value for the same
954
+ * entity+key is invalidated (valid_until = now) before the new row is
955
+ * inserted, so exactly one row per entity+key is current (valid_until IS NULL).
956
+ */
957
+ function saveAttr({ entity_id, attr_key, attr_value, memory_id, confidence, source }) {
958
+ const now = nowIso();
959
+ invalidateOldAttr(entity_id, attr_key, now);
960
+ const id = randomUUID();
961
+ db.prepare(
962
+ `INSERT INTO entity_attrs (id, entity_id, attr_key, attr_value, memory_id, valid_from, valid_until, confidence, source)
963
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
964
+ ).run(id, entity_id, attr_key, attr_value, memory_id ?? null, now, null, confidence ?? 1.0, source ?? null);
965
+ return toAttr(db.prepare("SELECT * FROM entity_attrs WHERE id = ?").get(id));
966
+ }
967
+
968
+ /** Mark every currently-valid attr row for entityId+attrKey as expired. Returns rows changed. */
969
+ function invalidateOldAttr(entityId, attrKey, now) {
970
+ return db.prepare(
971
+ `UPDATE entity_attrs SET valid_until = ? WHERE entity_id = ? AND attr_key = ? AND valid_until IS NULL`
972
+ ).run(now, entityId, attrKey).changes;
973
+ }
974
+
975
+ /** Only the live value per attr_key (valid_until IS NULL). */
976
+ function getCurrentAttrs(entityId) {
977
+ return db.prepare(
978
+ "SELECT * FROM entity_attrs WHERE entity_id = ? AND valid_until IS NULL"
979
+ ).all(entityId).map(toAttr);
980
+ }
981
+
982
+ /** Full history per attr_key, oldest first. */
983
+ function getAttrHistory(entityId) {
984
+ return db.prepare(
985
+ "SELECT * FROM entity_attrs WHERE entity_id = ? ORDER BY valid_from"
986
+ ).all(entityId).map(toAttr);
987
+ }
988
+
989
+ /**
990
+ * All attr rows carrying a reference to the given memory (any valid state),
991
+ * oldest first. Used by autoDream's update path to record what an update
992
+ * superseded (v0.3.0 Phase 4 / 4.3.1).
993
+ */
994
+ function getAttrsByMemory(memoryId) {
995
+ return db.prepare(
996
+ "SELECT * FROM entity_attrs WHERE memory_id = ? ORDER BY valid_from ASC"
997
+ ).all(memoryId).map(toAttr);
998
+ }
999
+
1000
+ /**
1001
+ * Memories carrying a currently-valid attr matching key=value (deduped).
1002
+ * When value is empty/undefined, the attr_value filter is dropped and every
1003
+ * currently-valid memory for that attr_key is returned — the "attr:key"
1004
+ * (no =value) contract, v0.3.0. Only live rows (valid_until IS NULL) with a
1005
+ * memory reference participate, and each memory appears at most once.
1006
+ */
1007
+ function findMemoriesByAttr(key, value) {
1008
+ const empty = value === undefined || value === null || value === "";
1009
+ const sql = empty
1010
+ ? `SELECT DISTINCT memory_id FROM entity_attrs
1011
+ WHERE attr_key = ? AND valid_until IS NULL
1012
+ AND memory_id IS NOT NULL AND memory_id != ''`
1013
+ : `SELECT DISTINCT memory_id FROM entity_attrs
1014
+ WHERE attr_key = ? AND attr_value = ? AND valid_until IS NULL
1015
+ AND memory_id IS NOT NULL AND memory_id != ''`;
1016
+ const params = empty ? [key] : [key, value];
1017
+ const rows = db.prepare(sql).all(...params);
1018
+ const memories = [];
1019
+ const stmt = db.prepare("SELECT * FROM memories WHERE id = ?");
1020
+ for (const { memory_id } of rows) {
1021
+ const row = stmt.get(memory_id);
1022
+ if (row) memories.push(toRow(row));
1023
+ }
1024
+ return memories;
1025
+ }
1026
+
1027
+ /**
1028
+ * Record a typed relation between two entities. metadata (optional) is a
1029
+ * free-form JSON blob describing the relation. Relations are append-only.
1030
+ */
1031
+ function saveRelation({ from_entity, to_entity, relation_type, memory_id, metadata }) {
1032
+ const id = randomUUID();
1033
+ const now = nowIso();
1034
+ const metaStr = metadata === undefined
1035
+ ? null
1036
+ : typeof metadata === "string"
1037
+ ? metadata
1038
+ : JSON.stringify(metadata);
1039
+ db.prepare(
1040
+ `INSERT INTO entity_relations (id, from_entity, to_entity, relation_type, memory_id, created_at, metadata)
1041
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
1042
+ ).run(id, from_entity, to_entity, relation_type, memory_id ?? null, now, metaStr);
1043
+ return toRelation(db.prepare("SELECT * FROM entity_relations WHERE id = ?").get(id));
1044
+ }
1045
+
1046
+ /**
1047
+ * Re-point every attr row whose memory_id is fromMemoryId to toMemoryId
1048
+ * (autoDream merge migration, v0.3.0 Phase 4 / 4.3.2). When the keeper
1049
+ * already carries a live attr for the same entity+key, the source row is
1050
+ * superseded and invalidated instead (the keeper's value wins). Returns
1051
+ * { migrated, invalidated }.
1052
+ */
1053
+ function migrateAttrsToMemory(fromMemoryId, toMemoryId, now) {
1054
+ let migrated = 0;
1055
+ let invalidated = 0;
1056
+ const attrs = db.prepare(
1057
+ "SELECT * FROM entity_attrs WHERE memory_id = ?"
1058
+ ).all(fromMemoryId);
1059
+ for (const attr of attrs) {
1060
+ // 仅当 keeper 已有同 entity+key 的当前有效属性才视为被替代(限定 memory_id,
1061
+ // 避免把 loser 自身的 live 行误判为 keeper 行)。
1062
+ const keeperLive = db.prepare(
1063
+ "SELECT id FROM entity_attrs WHERE entity_id = ? AND attr_key = ? AND valid_until IS NULL AND memory_id = ?"
1064
+ ).get(attr.entity_id, attr.attr_key, toMemoryId);
1065
+ if (keeperLive) {
1066
+ db.prepare(
1067
+ "UPDATE entity_attrs SET valid_until = ? WHERE id = ?"
1068
+ ).run(now, attr.id);
1069
+ invalidated++;
1070
+ } else {
1071
+ db.prepare(
1072
+ "UPDATE entity_attrs SET memory_id = ? WHERE id = ?"
1073
+ ).run(toMemoryId, attr.id);
1074
+ migrated++;
1075
+ }
1076
+ }
1077
+ return { migrated, invalidated };
1078
+ }
1079
+
1080
+ /** Relations where the entity appears on either side (from or to). */
1081
+ function getRelations(entityId) {
1082
+ return db.prepare(
1083
+ "SELECT * FROM entity_relations WHERE from_entity = ? OR to_entity = ?"
1084
+ ).all(entityId, entityId).map(toRelation);
1085
+ }
1086
+
808
1087
  return {
809
1088
  db,
810
1089
  count,
@@ -840,6 +1119,19 @@ export function createStore(path) {
840
1119
  listConflictPending,
841
1120
  resolveConflictPending,
842
1121
  countConflictPending,
1122
+ createEntity,
1123
+ findEntityByName,
1124
+ findEntityById,
1125
+ updateEntity,
1126
+ saveAttr,
1127
+ invalidateOldAttr,
1128
+ getCurrentAttrs,
1129
+ getAttrHistory,
1130
+ getAttrsByMemory,
1131
+ findMemoriesByAttr,
1132
+ saveRelation,
1133
+ migrateAttrsToMemory,
1134
+ getRelations,
843
1135
  close() {
844
1136
  db.close();
845
1137
  }