@modusensus/dsh-mneme 0.2.11 → 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 +93 -2
- 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 +93 -2
- package/src/store.js +292 -0
- package/test/entities.test.js +522 -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
|
|
@@ -261,6 +343,7 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
261
343
|
syncMirror();
|
|
262
344
|
notifyWrite();
|
|
263
345
|
scheduleEmbed(created);
|
|
346
|
+
scheduleEntityExtraction(created);
|
|
264
347
|
return { action: "created", memory: created };
|
|
265
348
|
}
|
|
266
349
|
|
|
@@ -417,6 +500,7 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
417
500
|
transaction,
|
|
418
501
|
setDreamHook(fn) { dreamHook = fn; },
|
|
419
502
|
setEmbedder(emb) { embedder = emb; },
|
|
503
|
+
setEntityExtractor(fn) { entityExtractor = fn; },
|
|
420
504
|
setVectorIndex(vi) { vectorIndex = vi; },
|
|
421
505
|
setReranker(rn) { reranker = rn; },
|
|
422
506
|
setRecallRecorder(fn) { recallRecorder = fn; },
|
|
@@ -517,6 +601,13 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
517
601
|
saveConflictPending: (r) => store.saveConflictPending(r),
|
|
518
602
|
listConflictPending: (opts) => store.listConflictPending(opts),
|
|
519
603
|
resolveConflictPending: (id, o) => store.resolveConflictPending(id, o),
|
|
520
|
-
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)
|
|
521
612
|
};
|
|
522
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
|
}
|