@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/README.md +23 -4
- package/lib/config.js +14 -0
- package/lib/dream/decisions.js +39 -7
- package/lib/dream.js +1 -1
- package/lib/entities/extractor.js +242 -0
- package/lib/index.js +40 -0
- package/lib/service.js +114 -4
- package/lib/store.js +292 -0
- package/package.json +1 -1
- package/src/config.js +14 -0
- package/src/dream/decisions.js +39 -7
- package/src/dream.js +1 -1
- package/src/entities/extractor.js +242 -0
- package/src/index.js +40 -0
- package/src/service.js +114 -4
- package/src/store.js +292 -0
- package/test/entities.test.js +522 -0
- package/test/fnew-0112.test.js +316 -0
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|