@world-engines/ladybug-bridge 0.1.0-alpha.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/LICENSE +46 -0
- package/README.md +3 -0
- package/dist/index.d.ts +704 -0
- package/dist/index.js +3781 -0
- package/dist/view-access-policy.d.ts +153 -0
- package/dist/view-access-policy.js +835 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3781 @@
|
|
|
1
|
+
import lbug, {} from "@ladybugdb/wasm-core";
|
|
2
|
+
import { isSerializedPropEnvelope } from "@world-engines/protocol-ts/entity-prop-envelope";
|
|
3
|
+
export { canonicalLadybugRelationId, createViewAccessPolicySubject, derivedGraphViewAccessPolicy, deriveViewAccessPolicySubjectId, deriveViewAccessPolicySubjectIdForTarget, isViewReadable, isViewWritable, resolveEdgeViewAccessMode, resolveOverlayLeafViewAccessMode, resolveViewAccessMode, sealViewAccessPolicy, validateViewAccessPolicy, viewAccessPolicyAddressForTarget, VIEW_ACCESS_POLICY_DENY_ROOT, VIEW_ACCESS_POLICY_ROOT_SUBJECT_ID, VIEW_ACCESS_POLICY_SCHEMA_VERSION, } from "./view-access-policy.js";
|
|
4
|
+
const ENTITY_ANALYZER_PROJECTION_CONTRACT_VERSION = 1;
|
|
5
|
+
const worldMemoryHostAuthorities = new WeakSet();
|
|
6
|
+
export function createWorldMemoryHostAuthority() {
|
|
7
|
+
const authority = Object.freeze({});
|
|
8
|
+
worldMemoryHostAuthorities.add(authority);
|
|
9
|
+
return authority;
|
|
10
|
+
}
|
|
11
|
+
// Issue 17 tips4 middle-layer structured recall view (director / LLM traversal-friendly)
|
|
12
|
+
// Emitted alongside GraphRagResult by the wasm-core recall_and_expand_structured helper
|
|
13
|
+
// so agents can enumerate hit entities plus discover [Object] expansion hints and trigger_lock
|
|
14
|
+
// signals without having to re-scan raw nested props themselves.
|
|
15
|
+
//
|
|
16
|
+
// Wire shape mirrors the Rust `RecallStructured` type (serde untagged) in
|
|
17
|
+
// `src/player-client/wasm-core/src/storage/retrieval_diagnostics.rs`:
|
|
18
|
+
// - Leaf primitives serialize as raw JSON values (string / number / bool / null / array)
|
|
19
|
+
// - Object markers serialize as { type: "object", inner_keys: [...], expandable: true }
|
|
20
|
+
// - trigger_lock is omitted when absent (Option::None → serde `skip_serializing_if`)
|
|
21
|
+
export const STRUCTURED_TRIGGER_LOCK_REASON_LOCKED_UNTIL = "locked_until";
|
|
22
|
+
export const STRUCTURED_TRIGGER_LOCK_REASON_ENTER_WHEN = "enter_when";
|
|
23
|
+
export function isStructuredObjectValue(v) {
|
|
24
|
+
return (typeof v === "object"
|
|
25
|
+
&& v !== null
|
|
26
|
+
&& v.type === "object"
|
|
27
|
+
&& Array.isArray(v.inner_keys));
|
|
28
|
+
}
|
|
29
|
+
const ENTITY_TABLE = "Entity";
|
|
30
|
+
const ENTITY_LEXICAL_PROJECTION_TABLE = "EntityLexicalProjection";
|
|
31
|
+
const RELATION_TABLE = "Relation";
|
|
32
|
+
const LATCH_TABLE = "TriggerLeafLatch";
|
|
33
|
+
const ATTR_HISTORY_TABLE = "AttrHistory";
|
|
34
|
+
const TRANSCRIPT_MESSAGE_TABLE = "Message";
|
|
35
|
+
const WM_EPISODE_TABLE = "WmEpisode";
|
|
36
|
+
const WM_CLAIM_ROOT_TABLE = "WmClaimRoot";
|
|
37
|
+
const WM_CLAIM_TABLE = "WmClaim";
|
|
38
|
+
const WM_DERIVED_TABLE = "WmDerived";
|
|
39
|
+
export const WM_EPISODE_VECTOR_INDEX = "WM_EPISODE_VECTOR_INDEX";
|
|
40
|
+
export const WM_EPISODE_FTS_INDEX = "WM_EPISODE_FTS_INDEX";
|
|
41
|
+
export const WM_DERIVED_VECTOR_INDEX = "WM_DERIVED_VECTOR_INDEX";
|
|
42
|
+
export const WM_DERIVED_FTS_INDEX = "WM_DERIVED_FTS_INDEX";
|
|
43
|
+
const WORLD_MEMORY_EMBEDDING_DIM = 4096;
|
|
44
|
+
const WORLD_MEMORY_SCHEMA_VERSION = 1;
|
|
45
|
+
const WORLD_MEMORY_TABLE_PATTERN = /\bWm(?:Episode|ClaimRoot|Claim|Derived)(?:SupportsClaim|SupersedesClaim|ContradictsClaim|FromEpisode|FromClaim|MentionsEntity|AboutEntity)?\b/i;
|
|
46
|
+
const MEMORY_RELATION_DEFINITIONS = Object.freeze({
|
|
47
|
+
WmEpisodeSupportsClaim: { sourceTable: WM_EPISODE_TABLE, targetTable: WM_CLAIM_TABLE },
|
|
48
|
+
WmClaimSupersedesClaim: { sourceTable: WM_CLAIM_TABLE, targetTable: WM_CLAIM_TABLE },
|
|
49
|
+
WmClaimContradictsClaim: { sourceTable: WM_CLAIM_TABLE, targetTable: WM_CLAIM_TABLE },
|
|
50
|
+
WmDerivedFromEpisode: { sourceTable: WM_DERIVED_TABLE, targetTable: WM_EPISODE_TABLE },
|
|
51
|
+
WmDerivedFromClaim: { sourceTable: WM_DERIVED_TABLE, targetTable: WM_CLAIM_TABLE },
|
|
52
|
+
WmEpisodeMentionsEntity: { sourceTable: WM_EPISODE_TABLE, targetTable: ENTITY_TABLE },
|
|
53
|
+
WmDerivedMentionsEntity: { sourceTable: WM_DERIVED_TABLE, targetTable: ENTITY_TABLE },
|
|
54
|
+
WmClaimAboutEntity: { sourceTable: WM_CLAIM_TABLE, targetTable: ENTITY_TABLE }
|
|
55
|
+
});
|
|
56
|
+
const ATTR_HISTORY_MAX_RETAIN = 20;
|
|
57
|
+
const VECTOR_INDEX = "emb_idx";
|
|
58
|
+
const FTS_INDEX = "fts_idx";
|
|
59
|
+
const TRANSCRIPT_FTS_INDEX = "transcript_fts_idx";
|
|
60
|
+
const FALLBACK_VECTOR_METRIC = "cosine";
|
|
61
|
+
const FALLBACK_FTS_STEMMER = "none";
|
|
62
|
+
const ENTITY_FTS_TOKENIZER = "simple";
|
|
63
|
+
const IN_MEMORY_PATH = ":memory:";
|
|
64
|
+
const MAX_DEPTH = 64;
|
|
65
|
+
// 场景初始快照 (作者 editor 预置的实体/关系) 经 bulkUpsert 加载时打的 messageId/turnId sentinel
|
|
66
|
+
// 与 Rust 侧 `world_db::SNAPSHOT_PROVENANCE_MESSAGE_ID` 保持字符串字面量一致 (镜像常量 两端各自定义)
|
|
67
|
+
// 标记"非某条对话消息产生的写入" rollbackAfterMessage 拒绝把它当合法 pivot 目标 防止把它误判成
|
|
68
|
+
// 可回滚到的历史消息 (sentinel 命中 messageId 精确等值查询是安全的 不影响真实消息用 UUID/ULID 的场景)
|
|
69
|
+
export const SNAPSHOT_PROVENANCE_MESSAGE_ID = "__initial_snapshot__";
|
|
70
|
+
const VECTOR_METRIC_WHITELIST = [
|
|
71
|
+
"cosine",
|
|
72
|
+
"l2",
|
|
73
|
+
"l2sq",
|
|
74
|
+
"dotproduct"
|
|
75
|
+
];
|
|
76
|
+
const HNSW_MU_MIN = 10;
|
|
77
|
+
const HNSW_MU_MAX = 200;
|
|
78
|
+
const HNSW_ML_MIN = 30;
|
|
79
|
+
const HNSW_ML_MAX = 300;
|
|
80
|
+
const HNSW_EFC_MIN = 50;
|
|
81
|
+
const HNSW_EFC_MAX = 500;
|
|
82
|
+
const HNSW_EFS_MIN = 20;
|
|
83
|
+
const HNSW_EFS_MAX = 500;
|
|
84
|
+
const BM25_K1_MIN = 0.5;
|
|
85
|
+
const BM25_K1_MAX = 3.0;
|
|
86
|
+
const BM25_B_MIN = 0.0;
|
|
87
|
+
const BM25_B_MAX = 1.0;
|
|
88
|
+
function pickEnum(value, whitelist, fallback) {
|
|
89
|
+
if (value === undefined)
|
|
90
|
+
return fallback;
|
|
91
|
+
return whitelist.includes(value) ? value : fallback;
|
|
92
|
+
}
|
|
93
|
+
function clampInt(value, min, max) {
|
|
94
|
+
if (value === undefined)
|
|
95
|
+
return undefined;
|
|
96
|
+
if (!Number.isFinite(value))
|
|
97
|
+
return undefined;
|
|
98
|
+
const rounded = Math.round(value);
|
|
99
|
+
if (rounded < min)
|
|
100
|
+
return min;
|
|
101
|
+
if (rounded > max)
|
|
102
|
+
return max;
|
|
103
|
+
return rounded;
|
|
104
|
+
}
|
|
105
|
+
function clampDouble(value, min, max) {
|
|
106
|
+
if (value === undefined)
|
|
107
|
+
return undefined;
|
|
108
|
+
if (!Number.isFinite(value))
|
|
109
|
+
return undefined;
|
|
110
|
+
if (value < min)
|
|
111
|
+
return min;
|
|
112
|
+
if (value > max)
|
|
113
|
+
return max;
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
function escapeStopwordsToken(value) {
|
|
117
|
+
return value.replace(/[^A-Za-z0-9_\-]/g, "");
|
|
118
|
+
}
|
|
119
|
+
function asString(value) {
|
|
120
|
+
return typeof value === "string" ? value : String(value ?? "");
|
|
121
|
+
}
|
|
122
|
+
function asNullableString(value) {
|
|
123
|
+
if (value === null || value === undefined)
|
|
124
|
+
return null;
|
|
125
|
+
return typeof value === "string" ? value : String(value);
|
|
126
|
+
}
|
|
127
|
+
function asNumber(value) {
|
|
128
|
+
if (typeof value === "number")
|
|
129
|
+
return value;
|
|
130
|
+
if (typeof value === "bigint")
|
|
131
|
+
return Number(value);
|
|
132
|
+
const parsed = Number(value);
|
|
133
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
134
|
+
}
|
|
135
|
+
function asNumberArray(value) {
|
|
136
|
+
if (value === null || value === undefined)
|
|
137
|
+
return [];
|
|
138
|
+
if (!Array.isArray(value))
|
|
139
|
+
throw new Error("expected finite number array");
|
|
140
|
+
const result = [];
|
|
141
|
+
for (const entry of value) {
|
|
142
|
+
if (typeof entry !== "number" || !Number.isFinite(entry)) {
|
|
143
|
+
throw new Error("expected finite number array");
|
|
144
|
+
}
|
|
145
|
+
result.push(entry);
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
function asLatchState(value) {
|
|
150
|
+
const s = asString(value);
|
|
151
|
+
return s === "open" || s === "closed" || s === "dead" ? s : "open";
|
|
152
|
+
}
|
|
153
|
+
function asLatchSource(value) {
|
|
154
|
+
const s = asString(value);
|
|
155
|
+
return s === "judge" ? "judge" : "deterministic";
|
|
156
|
+
}
|
|
157
|
+
function asPivot(value) {
|
|
158
|
+
if (value === null || value === undefined)
|
|
159
|
+
return null;
|
|
160
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
161
|
+
return value;
|
|
162
|
+
if (typeof value === "bigint")
|
|
163
|
+
return Number(value);
|
|
164
|
+
const parsed = Number(value);
|
|
165
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
166
|
+
}
|
|
167
|
+
function parseProps(value) {
|
|
168
|
+
if (typeof value !== "string")
|
|
169
|
+
return value ?? null;
|
|
170
|
+
if (value.length === 0)
|
|
171
|
+
return null;
|
|
172
|
+
let parsed;
|
|
173
|
+
try {
|
|
174
|
+
parsed = JSON.parse(value);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
isSerializedPropEnvelope(parsed);
|
|
180
|
+
return parsed;
|
|
181
|
+
}
|
|
182
|
+
function encodeProps(props) {
|
|
183
|
+
if (typeof props === "string") {
|
|
184
|
+
try {
|
|
185
|
+
isSerializedPropEnvelope(JSON.parse(props));
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (error instanceof SyntaxError)
|
|
189
|
+
return props;
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
return props;
|
|
193
|
+
}
|
|
194
|
+
const value = props ?? {};
|
|
195
|
+
isSerializedPropEnvelope(value);
|
|
196
|
+
return JSON.stringify(value);
|
|
197
|
+
}
|
|
198
|
+
function assertAnalyzerProjection(projection) {
|
|
199
|
+
if (projection === undefined)
|
|
200
|
+
return null;
|
|
201
|
+
if (projection.contractVersion !== ENTITY_ANALYZER_PROJECTION_CONTRACT_VERSION) {
|
|
202
|
+
throw new Error("entity analyzer projection contractVersion mismatch");
|
|
203
|
+
}
|
|
204
|
+
if (projection.analyzerId.trim().length === 0 || projection.analyzerVersion.trim().length === 0) {
|
|
205
|
+
throw new Error("entity analyzer projection identity must be non-empty");
|
|
206
|
+
}
|
|
207
|
+
const allowed = new Set(["ready", "unavailable", "failed"]);
|
|
208
|
+
if (!allowed.has(projection.lexicalStatus) || !allowed.has(projection.relationalStatus)) {
|
|
209
|
+
throw new Error("entity analyzer projection contains an invalid arm status");
|
|
210
|
+
}
|
|
211
|
+
if (projection.lexicalStatus !== "ready" && projection.lexicalText.length > 0) {
|
|
212
|
+
throw new Error("non-ready lexical projection must not carry lexicalText");
|
|
213
|
+
}
|
|
214
|
+
if (projection.relationalStatus !== "ready" && projection.relationSignals.length > 0) {
|
|
215
|
+
throw new Error("non-ready relational projection must not carry relation signals");
|
|
216
|
+
}
|
|
217
|
+
for (const token of projection.tokens) {
|
|
218
|
+
if (!Number.isSafeInteger(token.start) ||
|
|
219
|
+
!Number.isSafeInteger(token.end) ||
|
|
220
|
+
token.start < 0 ||
|
|
221
|
+
token.end < token.start) {
|
|
222
|
+
throw new Error("entity analyzer projection token range is invalid");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (projection.lexicalStatus === "ready") {
|
|
226
|
+
const terms = new Set();
|
|
227
|
+
for (const token of projection.tokens) {
|
|
228
|
+
if (token.normalized.length > 0)
|
|
229
|
+
terms.add(token.normalized);
|
|
230
|
+
if (token.stem.length > 0)
|
|
231
|
+
terms.add(token.stem);
|
|
232
|
+
}
|
|
233
|
+
if ([...terms].join(" ") !== projection.lexicalText) {
|
|
234
|
+
throw new Error("entity analyzer projection lexicalText mismatch");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const signal of projection.relationSignals) {
|
|
238
|
+
if (signal.version !== projection.analyzerVersion) {
|
|
239
|
+
throw new Error("entity analyzer relation signal version mismatch");
|
|
240
|
+
}
|
|
241
|
+
if (signal.tokenIndexes.some((index) => !Number.isSafeInteger(index) || index < 0 || index >= projection.tokens.length)) {
|
|
242
|
+
throw new Error("entity analyzer relation signal token index is invalid");
|
|
243
|
+
}
|
|
244
|
+
if (signal.kind === "relation" &&
|
|
245
|
+
(signal.canonical === undefined || signal.canonical.trim().length === 0)) {
|
|
246
|
+
throw new Error("entity analyzer relation signal canonical kind is missing");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return projection;
|
|
250
|
+
}
|
|
251
|
+
function encodeAnalyzerProjection(projection) {
|
|
252
|
+
return canonicalJson(projection);
|
|
253
|
+
}
|
|
254
|
+
async function sha256HexUtf8(value) {
|
|
255
|
+
const subtle = globalThis.crypto?.subtle;
|
|
256
|
+
if (!subtle)
|
|
257
|
+
throw new Error("ladybug state digest requires Web Crypto");
|
|
258
|
+
const digest = new Uint8Array(await subtle.digest("SHA-256", new TextEncoder().encode(value)));
|
|
259
|
+
return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
260
|
+
}
|
|
261
|
+
function assertMemoryIdentifier(field, value) {
|
|
262
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
263
|
+
throw new Error(`world memory ${field} must be a non-empty string`);
|
|
264
|
+
}
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
function assertMemoryInteger(field, value, nullable = false) {
|
|
268
|
+
if (!Number.isSafeInteger(value) || (!nullable && value < 0)) {
|
|
269
|
+
throw new Error(`world memory ${field} must be a safe integer${nullable ? "" : " >= 0"}`);
|
|
270
|
+
}
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
function assertMemoryNullableInteger(field, value) {
|
|
274
|
+
return value === null ? null : assertMemoryInteger(field, value, true);
|
|
275
|
+
}
|
|
276
|
+
function assertMemoryConfidence(value) {
|
|
277
|
+
if (!Number.isFinite(value) || value < 0 || value > 1) {
|
|
278
|
+
throw new Error("world memory confidence must be finite and in [0, 1]");
|
|
279
|
+
}
|
|
280
|
+
return value;
|
|
281
|
+
}
|
|
282
|
+
function assertMemorySchemaVersion(value) {
|
|
283
|
+
if (value !== WORLD_MEMORY_SCHEMA_VERSION) {
|
|
284
|
+
throw new Error(`world memory schema_version must be ${WORLD_MEMORY_SCHEMA_VERSION}`);
|
|
285
|
+
}
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
function snapshotCanonicalStringIds(field, values, allowEmpty) {
|
|
289
|
+
if (!Array.isArray(values))
|
|
290
|
+
throw new Error(`world memory ${field} must be an array`);
|
|
291
|
+
const normalized = values.map((value) => assertMemoryIdentifier(field, value));
|
|
292
|
+
if (!allowEmpty && normalized.length === 0) {
|
|
293
|
+
throw new Error(`world memory ${field} must not be empty`);
|
|
294
|
+
}
|
|
295
|
+
if (new Set(normalized).size !== normalized.length) {
|
|
296
|
+
throw new Error(`world memory ${field} must not contain duplicates`);
|
|
297
|
+
}
|
|
298
|
+
const sorted = [...normalized].sort((left, right) => left.localeCompare(right, "und"));
|
|
299
|
+
if (sorted.some((value, index) => value !== normalized[index])) {
|
|
300
|
+
throw new Error(`world memory ${field} must use canonical sorted order`);
|
|
301
|
+
}
|
|
302
|
+
return Object.freeze(sorted);
|
|
303
|
+
}
|
|
304
|
+
function encodeCanonicalStringIds(field, values, allowEmpty) {
|
|
305
|
+
return JSON.stringify(snapshotCanonicalStringIds(field, values, allowEmpty));
|
|
306
|
+
}
|
|
307
|
+
function decodeCanonicalStringIds(field, value, allowEmpty) {
|
|
308
|
+
if (typeof value !== "string")
|
|
309
|
+
throw new Error(`world memory ${field} storage value is not a string`);
|
|
310
|
+
let parsed;
|
|
311
|
+
try {
|
|
312
|
+
parsed = JSON.parse(value);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
throw new Error(`world memory ${field} storage value is not JSON`);
|
|
316
|
+
}
|
|
317
|
+
if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) {
|
|
318
|
+
throw new Error(`world memory ${field} storage value is not a string array`);
|
|
319
|
+
}
|
|
320
|
+
const ids = snapshotCanonicalStringIds(field, parsed, allowEmpty);
|
|
321
|
+
if (JSON.stringify(ids) !== value) {
|
|
322
|
+
throw new Error(`world memory ${field} storage value is not canonical`);
|
|
323
|
+
}
|
|
324
|
+
return ids;
|
|
325
|
+
}
|
|
326
|
+
function canonicalJson(value) {
|
|
327
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
328
|
+
return JSON.stringify(value);
|
|
329
|
+
}
|
|
330
|
+
if (typeof value === "number") {
|
|
331
|
+
if (!Number.isFinite(value))
|
|
332
|
+
throw new Error("world memory canonical JSON contains a non-finite number");
|
|
333
|
+
return JSON.stringify(Object.is(value, -0) ? 0 : value);
|
|
334
|
+
}
|
|
335
|
+
if (Array.isArray(value))
|
|
336
|
+
return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
|
|
337
|
+
if (typeof value === "object") {
|
|
338
|
+
const record = value;
|
|
339
|
+
const keys = Object.keys(record).sort((left, right) => left.localeCompare(right, "und"));
|
|
340
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
|
|
341
|
+
}
|
|
342
|
+
throw new Error("world memory canonical JSON contains an unsupported value");
|
|
343
|
+
}
|
|
344
|
+
function assertCanonicalJson(value) {
|
|
345
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
346
|
+
throw new Error("world memory canonical_value must be non-empty canonical JSON");
|
|
347
|
+
}
|
|
348
|
+
let parsed;
|
|
349
|
+
try {
|
|
350
|
+
parsed = JSON.parse(value);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
throw new Error("world memory canonical_value must be valid JSON");
|
|
354
|
+
}
|
|
355
|
+
if (canonicalJson(parsed) !== value) {
|
|
356
|
+
throw new Error("world memory canonical_value must use canonical JSON encoding");
|
|
357
|
+
}
|
|
358
|
+
return value;
|
|
359
|
+
}
|
|
360
|
+
function snapshotMemoryEmbedding(value) {
|
|
361
|
+
if (!Array.isArray(value) || value.length !== WORLD_MEMORY_EMBEDDING_DIM) {
|
|
362
|
+
throw new Error(`world memory embedding must contain ${WORLD_MEMORY_EMBEDDING_DIM} values`);
|
|
363
|
+
}
|
|
364
|
+
const embedding = value.map((entry) => {
|
|
365
|
+
if (!Number.isFinite(entry))
|
|
366
|
+
throw new Error("world memory embedding values must be finite");
|
|
367
|
+
return entry;
|
|
368
|
+
});
|
|
369
|
+
return Object.freeze(embedding);
|
|
370
|
+
}
|
|
371
|
+
function assertMemorySynopsis(value) {
|
|
372
|
+
assertMemoryIdentifier("synopsis", value);
|
|
373
|
+
if ([...value].length > 800)
|
|
374
|
+
throw new Error("world memory synopsis exceeds 800 Unicode scalars");
|
|
375
|
+
return value;
|
|
376
|
+
}
|
|
377
|
+
function assertMemoryRelationKind(value) {
|
|
378
|
+
if (!Object.prototype.hasOwnProperty.call(MEMORY_RELATION_DEFINITIONS, value)) {
|
|
379
|
+
throw new Error(`unknown world memory relation kind ${value}`);
|
|
380
|
+
}
|
|
381
|
+
return value;
|
|
382
|
+
}
|
|
383
|
+
function snapshotMemoryClosureIds(field, values) {
|
|
384
|
+
if (values === undefined)
|
|
385
|
+
return Object.freeze([]);
|
|
386
|
+
return snapshotCanonicalStringIds(field, values, true);
|
|
387
|
+
}
|
|
388
|
+
function assertDepth(depth) {
|
|
389
|
+
if (!Number.isInteger(depth) || depth < 0 || depth > MAX_DEPTH) {
|
|
390
|
+
throw new Error(`depth must be an integer in [0, ${MAX_DEPTH}] got ${depth}`);
|
|
391
|
+
}
|
|
392
|
+
return depth;
|
|
393
|
+
}
|
|
394
|
+
function assertTopK(topK) {
|
|
395
|
+
if (!Number.isInteger(topK) || topK <= 0) {
|
|
396
|
+
throw new Error(`top_k must be a positive integer got ${topK}`);
|
|
397
|
+
}
|
|
398
|
+
return topK;
|
|
399
|
+
}
|
|
400
|
+
function assertTranscriptIdentifier(field, value) {
|
|
401
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
402
|
+
throw new Error(`transcript ${field} must be a non-empty string`);
|
|
403
|
+
}
|
|
404
|
+
return value;
|
|
405
|
+
}
|
|
406
|
+
function assertTranscriptScope(scope) {
|
|
407
|
+
assertTranscriptIdentifier("scenario_id", scope.scenarioId);
|
|
408
|
+
assertTranscriptIdentifier("save_id", scope.saveId);
|
|
409
|
+
assertTranscriptIdentifier("worldline_id", scope.worldlineId);
|
|
410
|
+
return scope;
|
|
411
|
+
}
|
|
412
|
+
function snapshotTranscriptScope(scope) {
|
|
413
|
+
assertTranscriptScope(scope);
|
|
414
|
+
return Object.freeze({
|
|
415
|
+
scenarioId: scope.scenarioId,
|
|
416
|
+
saveId: scope.saveId,
|
|
417
|
+
worldlineId: scope.worldlineId
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
const TRANSCRIPT_SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
421
|
+
function snapshotTranscriptJournalIdentity(identity) {
|
|
422
|
+
assertTranscriptIdentifier("journal_idempotency_key", identity.idempotencyKey);
|
|
423
|
+
if (!TRANSCRIPT_SHA256_HEX.test(identity.payloadSha256)) {
|
|
424
|
+
throw new Error("transcript journal_payload_sha256 must be lowercase sha256 hex");
|
|
425
|
+
}
|
|
426
|
+
assertTranscriptIdentifier("journal_phase", identity.phase);
|
|
427
|
+
if (!Number.isSafeInteger(identity.phaseRevision) || identity.phaseRevision <= 0) {
|
|
428
|
+
throw new Error("transcript journal_phase_revision must be a positive safe integer");
|
|
429
|
+
}
|
|
430
|
+
return Object.freeze({
|
|
431
|
+
idempotencyKey: identity.idempotencyKey,
|
|
432
|
+
payloadSha256: identity.payloadSha256,
|
|
433
|
+
phase: identity.phase,
|
|
434
|
+
phaseRevision: identity.phaseRevision
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
function transcriptJournalIdentityMatches(left, right) {
|
|
438
|
+
return left.idempotencyKey === right.idempotencyKey
|
|
439
|
+
&& left.payloadSha256 === right.payloadSha256
|
|
440
|
+
&& left.phase === right.phase
|
|
441
|
+
&& left.phaseRevision === right.phaseRevision;
|
|
442
|
+
}
|
|
443
|
+
function assertActiveReachableMessageIds(values, requiredMessageId) {
|
|
444
|
+
if (!Array.isArray(values)) {
|
|
445
|
+
throw new Error("transcript active_reachable_message_ids must be an array");
|
|
446
|
+
}
|
|
447
|
+
const normalized = values.map((value) => assertTranscriptIdentifier("active_reachable_message_id", value));
|
|
448
|
+
const unique = [...new Set(normalized)];
|
|
449
|
+
if (unique.length !== normalized.length) {
|
|
450
|
+
throw new Error("transcript message authority contains duplicate ids");
|
|
451
|
+
}
|
|
452
|
+
if (requiredMessageId !== undefined && !unique.includes(requiredMessageId)) {
|
|
453
|
+
throw new Error("transcript message is not active and reachable");
|
|
454
|
+
}
|
|
455
|
+
return Object.freeze(unique);
|
|
456
|
+
}
|
|
457
|
+
function sameTranscriptAuthority(left, right) {
|
|
458
|
+
if (left.scope.scenarioId !== right.scope.scenarioId
|
|
459
|
+
|| left.scope.saveId !== right.scope.saveId
|
|
460
|
+
|| left.scope.worldlineId !== right.scope.worldlineId
|
|
461
|
+
|| left.operation !== right.operation
|
|
462
|
+
|| left.lineageDigest !== right.lineageDigest
|
|
463
|
+
|| left.mutationPayloadSha256 !== right.mutationPayloadSha256
|
|
464
|
+
|| left.activeReachableMessageIds.length !== right.activeReachableMessageIds.length
|
|
465
|
+
|| left.authorizedMutationMessageIds.length !== right.authorizedMutationMessageIds.length
|
|
466
|
+
|| left.requiredUpserts.length !== right.requiredUpserts.length
|
|
467
|
+
|| !sameTranscriptSuffixSpec(left.suffix, right.suffix)) {
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
if ((left.journal === null) !== (right.journal === null))
|
|
471
|
+
return false;
|
|
472
|
+
if (left.journal !== null && right.journal !== null && !transcriptJournalIdentityMatches(left.journal, right.journal)) {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
return left.activeReachableMessageIds.every((value, index) => value === right.activeReachableMessageIds[index])
|
|
476
|
+
&& left.authorizedMutationMessageIds.every((value, index) => value === right.authorizedMutationMessageIds[index])
|
|
477
|
+
&& left.requiredUpserts.every((value, index) => {
|
|
478
|
+
const other = right.requiredUpserts[index];
|
|
479
|
+
return other !== undefined
|
|
480
|
+
&& value.messageId === other.messageId
|
|
481
|
+
&& value.sourceLedgerSeq === other.sourceLedgerSeq;
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
function assertTranscriptTimestamp(field, value) {
|
|
485
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
486
|
+
throw new Error(`transcript ${field} must be a non-negative safe integer`);
|
|
487
|
+
}
|
|
488
|
+
return value;
|
|
489
|
+
}
|
|
490
|
+
function assertTranscriptLedgerSeq(value, allowZero = false) {
|
|
491
|
+
if (!Number.isSafeInteger(value) || value < (allowZero ? 0 : 1)) {
|
|
492
|
+
throw new Error(`transcript source_ledger_seq must be a ${allowZero ? "non-negative" : "positive"} safe integer`);
|
|
493
|
+
}
|
|
494
|
+
return value;
|
|
495
|
+
}
|
|
496
|
+
function snapshotTranscriptSuffixSpec(value, allowNull = false) {
|
|
497
|
+
if (value === null) {
|
|
498
|
+
if (allowNull)
|
|
499
|
+
return null;
|
|
500
|
+
throw new Error("transcript suffix mutation is required");
|
|
501
|
+
}
|
|
502
|
+
if (typeof value !== "object") {
|
|
503
|
+
throw new Error("transcript suffix mutation is invalid");
|
|
504
|
+
}
|
|
505
|
+
if (value.mode !== "tombstone" && value.mode !== "delete") {
|
|
506
|
+
throw new Error(`transcript suffix mode invalid: ${String(value.mode)}`);
|
|
507
|
+
}
|
|
508
|
+
return Object.freeze({
|
|
509
|
+
afterSourceLedgerSeq: assertTranscriptLedgerSeq(value.afterSourceLedgerSeq, true),
|
|
510
|
+
mode: value.mode,
|
|
511
|
+
updatedAtMs: assertTranscriptTimestamp("suffix_updated_at_ms", value.updatedAtMs)
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
function sameTranscriptSuffixSpec(left, right) {
|
|
515
|
+
if (left === null || right === null)
|
|
516
|
+
return left === right;
|
|
517
|
+
return left.afterSourceLedgerSeq === right.afterSourceLedgerSeq
|
|
518
|
+
&& left.mode === right.mode
|
|
519
|
+
&& left.updatedAtMs === right.updatedAtMs;
|
|
520
|
+
}
|
|
521
|
+
function assertTranscriptRole(value) {
|
|
522
|
+
if (value !== "user" && value !== "assistant") {
|
|
523
|
+
throw new Error(`transcript role must be user or assistant got ${value}`);
|
|
524
|
+
}
|
|
525
|
+
return value;
|
|
526
|
+
}
|
|
527
|
+
function assertTranscriptStatus(value) {
|
|
528
|
+
if (value !== "streaming"
|
|
529
|
+
&& value !== "done"
|
|
530
|
+
&& value !== "failed"
|
|
531
|
+
&& value !== "aborted"
|
|
532
|
+
&& value !== "tombstoned") {
|
|
533
|
+
throw new Error(`transcript status invalid: ${value}`);
|
|
534
|
+
}
|
|
535
|
+
return value;
|
|
536
|
+
}
|
|
537
|
+
function validateTranscriptRecord(record) {
|
|
538
|
+
assertTranscriptScope(record);
|
|
539
|
+
assertTranscriptIdentifier("message_id", record.messageId);
|
|
540
|
+
assertTranscriptIdentifier("turn_id", record.turnId);
|
|
541
|
+
assertTranscriptRole(record.role);
|
|
542
|
+
assertTranscriptStatus(record.status);
|
|
543
|
+
if (typeof record.content !== "string")
|
|
544
|
+
throw new Error("transcript content must be a string");
|
|
545
|
+
assertTranscriptTimestamp("created_at_ms", record.createdAtMs);
|
|
546
|
+
assertTranscriptTimestamp("updated_at_ms", record.updatedAtMs);
|
|
547
|
+
if (record.updatedAtMs < record.createdAtMs) {
|
|
548
|
+
throw new Error("transcript updated_at_ms precedes created_at_ms");
|
|
549
|
+
}
|
|
550
|
+
assertTranscriptLedgerSeq(record.sourceLedgerSeq);
|
|
551
|
+
if (record.status === "done" && record.content.length === 0) {
|
|
552
|
+
throw new Error("transcript done content must not be empty");
|
|
553
|
+
}
|
|
554
|
+
return record;
|
|
555
|
+
}
|
|
556
|
+
function snapshotTranscriptMutationRecords(scope, messages) {
|
|
557
|
+
if (!Array.isArray(messages)) {
|
|
558
|
+
throw new Error("transcript mutation messages must be an array");
|
|
559
|
+
}
|
|
560
|
+
return Object.freeze(messages.map((message) => Object.freeze(validateTranscriptRecord({
|
|
561
|
+
...scope,
|
|
562
|
+
messageId: message.messageId,
|
|
563
|
+
turnId: message.turnId,
|
|
564
|
+
role: message.role,
|
|
565
|
+
content: message.content,
|
|
566
|
+
status: message.status,
|
|
567
|
+
createdAtMs: message.createdAtMs,
|
|
568
|
+
updatedAtMs: message.updatedAtMs,
|
|
569
|
+
sourceLedgerSeq: message.sourceLedgerSeq
|
|
570
|
+
}))));
|
|
571
|
+
}
|
|
572
|
+
async function transcriptMutationPayloadSha256FromRecords(scope, records, suffix) {
|
|
573
|
+
const payloadImage = JSON.stringify([
|
|
574
|
+
scope.scenarioId,
|
|
575
|
+
scope.saveId,
|
|
576
|
+
scope.worldlineId,
|
|
577
|
+
records.map((record) => [
|
|
578
|
+
record.messageId,
|
|
579
|
+
record.turnId,
|
|
580
|
+
record.role,
|
|
581
|
+
record.content,
|
|
582
|
+
record.status,
|
|
583
|
+
record.createdAtMs,
|
|
584
|
+
record.updatedAtMs,
|
|
585
|
+
record.sourceLedgerSeq
|
|
586
|
+
]),
|
|
587
|
+
suffix === null
|
|
588
|
+
? null
|
|
589
|
+
: [suffix.afterSourceLedgerSeq, suffix.mode, suffix.updatedAtMs]
|
|
590
|
+
]);
|
|
591
|
+
return sha256HexUtf8(`ladybug-transcript-mutation-payload-v1\u0000${payloadImage}`);
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Produces the host journal's canonical digest for one ordered transcript
|
|
595
|
+
* mutation. The bridge recomputes the same digest before touching Ladybug.
|
|
596
|
+
*/
|
|
597
|
+
export async function computeTranscriptMutationPayloadSha256(input) {
|
|
598
|
+
const scope = snapshotTranscriptScope(input.scope);
|
|
599
|
+
const records = snapshotTranscriptMutationRecords(scope, input.messages);
|
|
600
|
+
const suffix = input.suffix === undefined
|
|
601
|
+
? null
|
|
602
|
+
: snapshotTranscriptSuffixSpec(input.suffix);
|
|
603
|
+
return transcriptMutationPayloadSha256FromRecords(scope, records, suffix);
|
|
604
|
+
}
|
|
605
|
+
function transcriptIdentityMatches(left, right) {
|
|
606
|
+
return left.scenarioId === right.scenarioId
|
|
607
|
+
&& left.saveId === right.saveId
|
|
608
|
+
&& left.worldlineId === right.worldlineId
|
|
609
|
+
&& left.messageId === right.messageId
|
|
610
|
+
&& left.turnId === right.turnId
|
|
611
|
+
&& left.role === right.role
|
|
612
|
+
&& left.createdAtMs === right.createdAtMs
|
|
613
|
+
&& left.sourceLedgerSeq === right.sourceLedgerSeq;
|
|
614
|
+
}
|
|
615
|
+
function transcriptRecordMatches(left, right) {
|
|
616
|
+
return transcriptIdentityMatches(left, right)
|
|
617
|
+
&& left.content === right.content
|
|
618
|
+
&& left.status === right.status
|
|
619
|
+
&& left.updatedAtMs === right.updatedAtMs;
|
|
620
|
+
}
|
|
621
|
+
function assertTranscriptRecordSetIdentities(records) {
|
|
622
|
+
const messageIds = new Set();
|
|
623
|
+
const ledgerSeqs = new Set();
|
|
624
|
+
for (const record of records) {
|
|
625
|
+
if (messageIds.has(record.messageId)) {
|
|
626
|
+
throw new Error("transcript scope contains duplicate message_id");
|
|
627
|
+
}
|
|
628
|
+
if (ledgerSeqs.has(record.sourceLedgerSeq)) {
|
|
629
|
+
throw new Error("transcript scope contains duplicate source_ledger_seq");
|
|
630
|
+
}
|
|
631
|
+
messageIds.add(record.messageId);
|
|
632
|
+
ledgerSeqs.add(record.sourceLedgerSeq);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
function buildIndexedSearchText(content) {
|
|
636
|
+
const normalized = content.normalize("NFKC").toLocaleLowerCase("und");
|
|
637
|
+
const tokens = new Set();
|
|
638
|
+
const segments = normalized.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+|[\p{Letter}\p{Number}]+/gu) ?? [];
|
|
639
|
+
for (const segment of segments) {
|
|
640
|
+
const chars = [...segment];
|
|
641
|
+
const isCjk = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(segment);
|
|
642
|
+
if (!isCjk) {
|
|
643
|
+
tokens.add(segment);
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
for (let width = 1; width <= Math.min(3, chars.length); width += 1) {
|
|
647
|
+
for (let offset = 0; offset + width <= chars.length; offset += 1) {
|
|
648
|
+
tokens.add(chars.slice(offset, offset + width).join(""));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return [...tokens].join(" ");
|
|
653
|
+
}
|
|
654
|
+
async function collectRows(result) {
|
|
655
|
+
if (!result.isSuccess()) {
|
|
656
|
+
const message = await result.getErrorMessage();
|
|
657
|
+
await result.close();
|
|
658
|
+
throw new Error(message);
|
|
659
|
+
}
|
|
660
|
+
const rows = await result.getAllObjects();
|
|
661
|
+
await result.close();
|
|
662
|
+
return rows;
|
|
663
|
+
}
|
|
664
|
+
export class LadybugBridge {
|
|
665
|
+
dim;
|
|
666
|
+
variant;
|
|
667
|
+
db = null;
|
|
668
|
+
conn = null;
|
|
669
|
+
ready = false;
|
|
670
|
+
vectorIndexBuilt = false;
|
|
671
|
+
ftsIndexBuilt = false;
|
|
672
|
+
vectorIndexedEntityCount = 0;
|
|
673
|
+
ftsIndexedEntityCount = 0;
|
|
674
|
+
memoryEpisodeVectorIndexBuilt = false;
|
|
675
|
+
memoryEpisodeFtsIndexBuilt = false;
|
|
676
|
+
memoryDerivedVectorIndexBuilt = false;
|
|
677
|
+
memoryDerivedFtsIndexBuilt = false;
|
|
678
|
+
transcriptFtsBuilt = false;
|
|
679
|
+
transcriptQueue = Promise.resolve();
|
|
680
|
+
transcriptOperationReservations = 0;
|
|
681
|
+
transcriptOperationActive = false;
|
|
682
|
+
graphRollbackInProgress = false;
|
|
683
|
+
initializing = false;
|
|
684
|
+
closing = false;
|
|
685
|
+
transcriptReachabilityResolver;
|
|
686
|
+
worldMemoryHostAuthority;
|
|
687
|
+
// Trusted WorldMemory operations share one queue so capture/restore never
|
|
688
|
+
// expose a mixed view across mutation slots.
|
|
689
|
+
worldMemoryQueue = Promise.resolve();
|
|
690
|
+
worldMemoryOperationReservations = 0;
|
|
691
|
+
worldMemoryRevision = 0;
|
|
692
|
+
_writeSeq = 0;
|
|
693
|
+
tx = null;
|
|
694
|
+
assertWorldMemoryHostAuthority(authority) {
|
|
695
|
+
if (this.worldMemoryHostAuthority === null
|
|
696
|
+
|| authority !== this.worldMemoryHostAuthority
|
|
697
|
+
|| !worldMemoryHostAuthorities.has(authority)) {
|
|
698
|
+
throw new Error("world memory trusted host authority mismatch");
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
createWorldMemoryHostAdapter(authority) {
|
|
702
|
+
this.assertWorldMemoryHostAuthority(authority);
|
|
703
|
+
return Object.freeze({
|
|
704
|
+
upsertMemoryEpisode: (row) => this.withWorldMemoryHostLock(async () => { await this.upsertMemoryEpisode(row); this.advanceWorldMemoryRevision(); }),
|
|
705
|
+
upsertMemoryClaimRoot: (row) => this.withWorldMemoryHostLock(async () => { await this.upsertMemoryClaimRoot(row); this.advanceWorldMemoryRevision(); }),
|
|
706
|
+
upsertMemoryClaim: (row) => this.withWorldMemoryHostLock(async () => { await this.upsertMemoryClaim(row); this.advanceWorldMemoryRevision(); }),
|
|
707
|
+
upsertMemoryDerived: (row) => this.withWorldMemoryHostLock(async () => { await this.upsertMemoryDerived(row); this.advanceWorldMemoryRevision(); }),
|
|
708
|
+
upsertMemoryRelation: (row) => this.withWorldMemoryHostLock(async () => { await this.upsertMemoryRelation(row); this.advanceWorldMemoryRevision(); }),
|
|
709
|
+
getClaimRoot: (id) => this.withWorldMemoryHostLock(() => this.getClaimRoot(id)),
|
|
710
|
+
getClaimsByRoot: (rootId) => this.withWorldMemoryHostLock(() => this.getClaimsByRoot(rootId)),
|
|
711
|
+
resolveClaimsForEntities: (entityIds) => this.withWorldMemoryHostLock(() => this.resolveClaimsForEntities(entityIds)),
|
|
712
|
+
vectorSearchEpisodes: (query, topK, config) => this.withWorldMemoryHostLock(() => this.vectorSearchEpisodes(query, topK, config)),
|
|
713
|
+
fullTextSearchEpisodes: (query, topK, config) => this.withWorldMemoryHostLock(() => this.fullTextSearchEpisodes(query, topK, config)),
|
|
714
|
+
vectorSearchDerived: (query, topK, config) => this.withWorldMemoryHostLock(() => this.vectorSearchDerived(query, topK, config)),
|
|
715
|
+
fullTextSearchDerived: (query, topK, config) => this.withWorldMemoryHostLock(() => this.fullTextSearchDerived(query, topK, config)),
|
|
716
|
+
deleteMemoryClosure: (closure) => this.withWorldMemoryHostLock(async () => {
|
|
717
|
+
const result = await this.deleteMemoryClosure(closure);
|
|
718
|
+
this.advanceWorldMemoryRevision();
|
|
719
|
+
return result;
|
|
720
|
+
}),
|
|
721
|
+
snapshotWorldMemory: () => this.withWorldMemoryHostLock(() => this.snapshotWorldMemory()),
|
|
722
|
+
captureWorldMemoryView: () => this.withWorldMemoryHostLock(() => this.captureWorldMemoryView()),
|
|
723
|
+
worldMemoryStateDigest: () => this.withWorldMemoryHostLock(async () => (this.worldMemorySnapshotSha256(await this.snapshotWorldMemory()))),
|
|
724
|
+
stateIdentity: () => this.withWorldMemoryHostLock(() => this.captureStateIdentity()),
|
|
725
|
+
restoreWorldMemorySnapshot: (snapshot) => this.withWorldMemoryHostLock(async () => { await this.restoreWorldMemorySnapshotFromHost(snapshot); this.advanceWorldMemoryRevision(); })
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
createWasmRuntimeAdapter(authority) {
|
|
729
|
+
this.assertWorldMemoryHostAuthority(authority);
|
|
730
|
+
return Object.freeze({
|
|
731
|
+
allEntities: this.allEntities.bind(this),
|
|
732
|
+
allRelations: this.allRelations.bind(this),
|
|
733
|
+
attrHistoryCheck: this.attrHistoryCheck.bind(this),
|
|
734
|
+
beginTransaction: () => this.withWorldMemoryHostLock(async () => { this.beginTransaction(); }),
|
|
735
|
+
bulkUpsertEntities: this.bulkUpsertEntities.bind(this),
|
|
736
|
+
bulkUpsertRelations: this.bulkUpsertRelations.bind(this),
|
|
737
|
+
close: this.close.bind(this),
|
|
738
|
+
commitTransaction: () => this.withWorldMemoryHostLock(async () => { this.commitTransaction(); }),
|
|
739
|
+
createFtsIndex: this.createFtsIndex.bind(this),
|
|
740
|
+
createVectorIndex: this.createVectorIndex.bind(this),
|
|
741
|
+
deleteEntity: this.deleteEntity.bind(this),
|
|
742
|
+
deleteRelation: this.deleteRelation.bind(this),
|
|
743
|
+
diagnostics: this.diagnostics.bind(this),
|
|
744
|
+
dropFtsIndex: this.dropFtsIndex.bind(this),
|
|
745
|
+
dropVectorIndex: this.dropVectorIndex.bind(this),
|
|
746
|
+
entityCount: this.entityCount.bind(this),
|
|
747
|
+
findByKind: this.findByKind.bind(this),
|
|
748
|
+
fullTextSearch: this.fullTextSearch.bind(this),
|
|
749
|
+
getById: this.getById.bind(this),
|
|
750
|
+
hasEmbedding: this.hasEmbedding.bind(this),
|
|
751
|
+
hasFtsIndex: this.hasFtsIndex.bind(this),
|
|
752
|
+
hasVectorIndex: this.hasVectorIndex.bind(this),
|
|
753
|
+
init: this.init.bind(this),
|
|
754
|
+
latchClear: this.latchClear.bind(this),
|
|
755
|
+
latchCommitBatch: this.latchCommitBatch.bind(this),
|
|
756
|
+
latchLoad: this.latchLoad.bind(this),
|
|
757
|
+
latchRollback: this.latchRollback.bind(this),
|
|
758
|
+
neighbors: this.neighbors.bind(this),
|
|
759
|
+
pruneAttrHistory: this.pruneAttrHistory.bind(this),
|
|
760
|
+
query: this.queryForWasmRuntime.bind(this),
|
|
761
|
+
queryAttrHistory: this.queryAttrHistory.bind(this),
|
|
762
|
+
recallAndExpand: this.recallAndExpand.bind(this),
|
|
763
|
+
recallCandidateRecord: this.recallCandidateRecord.bind(this),
|
|
764
|
+
relationCount: this.relationCount.bind(this),
|
|
765
|
+
rollbackAfterMessage: this.rollbackAfterMessage.bind(this),
|
|
766
|
+
rollbackAttrHistoryAfterMessage: this.rollbackAttrHistoryAfterMessage.bind(this),
|
|
767
|
+
rollbackTransaction: () => this.withWorldMemoryHostLock(() => this.rollbackTransaction()),
|
|
768
|
+
worldMemoryStateDigest: () => this.withWorldMemoryHostLock(async () => (this.worldMemorySnapshotSha256(await this.snapshotWorldMemory()))),
|
|
769
|
+
restoreWorldMemorySnapshotInTransaction: (snapshot) => this.withWorldMemoryHostLock(async () => {
|
|
770
|
+
await this.restoreWorldMemorySnapshotInTransaction(snapshot);
|
|
771
|
+
this.advanceWorldMemoryRevision();
|
|
772
|
+
}),
|
|
773
|
+
stateDigest: this.stateDigest.bind(this),
|
|
774
|
+
upsertAttrHistory: this.upsertAttrHistory.bind(this),
|
|
775
|
+
upsertEntity: this.upsertEntity.bind(this),
|
|
776
|
+
upsertRelation: this.upsertRelation.bind(this),
|
|
777
|
+
vectorSearch: this.vectorSearch.bind(this)
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
nextSeq() {
|
|
781
|
+
this._writeSeq += 1;
|
|
782
|
+
return this._writeSeq;
|
|
783
|
+
}
|
|
784
|
+
async withWorldMemoryHostLock(fn) {
|
|
785
|
+
if (this.closing)
|
|
786
|
+
throw new Error("world memory host operation cannot start while ladybug bridge is closing");
|
|
787
|
+
this.worldMemoryOperationReservations += 1;
|
|
788
|
+
const previous = this.worldMemoryQueue;
|
|
789
|
+
let release;
|
|
790
|
+
this.worldMemoryQueue = new Promise((resolve) => { release = resolve; });
|
|
791
|
+
try {
|
|
792
|
+
await previous;
|
|
793
|
+
return await fn();
|
|
794
|
+
}
|
|
795
|
+
finally {
|
|
796
|
+
this.worldMemoryOperationReservations -= 1;
|
|
797
|
+
release();
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
advanceWorldMemoryRevision() {
|
|
801
|
+
if (this.worldMemoryRevision >= Number.MAX_SAFE_INTEGER)
|
|
802
|
+
throw new Error("world memory revision exhausted");
|
|
803
|
+
this.worldMemoryRevision += 1;
|
|
804
|
+
}
|
|
805
|
+
async captureWorldMemoryView() {
|
|
806
|
+
if (this.tx !== null || this.graphRollbackInProgress)
|
|
807
|
+
throw new Error("world memory view capture requires no active transaction");
|
|
808
|
+
const snapshot = await this.snapshotWorldMemory();
|
|
809
|
+
const snapshotSha256 = await this.worldMemorySnapshotSha256(snapshot);
|
|
810
|
+
return Object.freeze({ snapshot, identity: Object.freeze({ snapshotSha256, revision: this.worldMemoryRevision }) });
|
|
811
|
+
}
|
|
812
|
+
async worldMemorySnapshotSha256(snapshot) {
|
|
813
|
+
return await sha256HexUtf8(`ladybug-world-memory-view-v1\u0000${canonicalJson(snapshot)}`);
|
|
814
|
+
}
|
|
815
|
+
async captureStateIdentity() {
|
|
816
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
817
|
+
const writeSequence = this._writeSeq;
|
|
818
|
+
const physicalStateSha256 = await this.stateDigest();
|
|
819
|
+
if (this._writeSeq === writeSequence)
|
|
820
|
+
return Object.freeze({ physicalStateSha256, writeSequence });
|
|
821
|
+
}
|
|
822
|
+
throw new Error("world memory state changed while capturing identity");
|
|
823
|
+
}
|
|
824
|
+
async withTranscriptLock(fn) {
|
|
825
|
+
if (this.closing) {
|
|
826
|
+
throw new Error("transcript operation cannot start while ladybug bridge is closing");
|
|
827
|
+
}
|
|
828
|
+
this.transcriptOperationReservations += 1;
|
|
829
|
+
const previous = this.transcriptQueue;
|
|
830
|
+
let release;
|
|
831
|
+
this.transcriptQueue = new Promise((resolve) => {
|
|
832
|
+
release = resolve;
|
|
833
|
+
});
|
|
834
|
+
try {
|
|
835
|
+
await previous;
|
|
836
|
+
return await fn();
|
|
837
|
+
}
|
|
838
|
+
finally {
|
|
839
|
+
this.transcriptOperationReservations -= 1;
|
|
840
|
+
release();
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
async withTranscriptOperationIsolation(fn) {
|
|
844
|
+
if (this.graphRollbackInProgress) {
|
|
845
|
+
throw new Error("transcript operation cannot overlap graph rollback replay");
|
|
846
|
+
}
|
|
847
|
+
if (this.tx !== null) {
|
|
848
|
+
throw new Error("transcript operation cannot overlap a graph transaction");
|
|
849
|
+
}
|
|
850
|
+
if (this.transcriptOperationActive) {
|
|
851
|
+
throw new Error("transcript operation isolation is already active");
|
|
852
|
+
}
|
|
853
|
+
this.transcriptOperationActive = true;
|
|
854
|
+
try {
|
|
855
|
+
return await fn();
|
|
856
|
+
}
|
|
857
|
+
finally {
|
|
858
|
+
this.transcriptOperationActive = false;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
async resolveTranscriptAuthority(scope, operation, journal, requiredMessageIds = []) {
|
|
862
|
+
const resolver = this.transcriptReachabilityResolver;
|
|
863
|
+
if (resolver === null) {
|
|
864
|
+
throw new Error("transcript reachability authority is unavailable");
|
|
865
|
+
}
|
|
866
|
+
const authorizedScope = snapshotTranscriptScope(scope);
|
|
867
|
+
const authorizedJournal = journal === null ? null : snapshotTranscriptJournalIdentity(journal);
|
|
868
|
+
if (operation === "mutation" && authorizedJournal === null) {
|
|
869
|
+
throw new Error("transcript mutation journal identity is required");
|
|
870
|
+
}
|
|
871
|
+
if (operation === "recall" && authorizedJournal !== null) {
|
|
872
|
+
throw new Error("transcript recall cannot carry mutation journal authority");
|
|
873
|
+
}
|
|
874
|
+
const snapshot = await resolver(Object.freeze({
|
|
875
|
+
scope: authorizedScope,
|
|
876
|
+
operation,
|
|
877
|
+
journal: authorizedJournal
|
|
878
|
+
}));
|
|
879
|
+
if (typeof snapshot !== "object" || snapshot === null) {
|
|
880
|
+
throw new Error("transcript reachability authority returned no snapshot");
|
|
881
|
+
}
|
|
882
|
+
assertTranscriptScope(snapshot);
|
|
883
|
+
if (snapshot.scenarioId !== authorizedScope.scenarioId
|
|
884
|
+
|| snapshot.saveId !== authorizedScope.saveId
|
|
885
|
+
|| snapshot.worldlineId !== authorizedScope.worldlineId) {
|
|
886
|
+
throw new Error("transcript reachability authority scope mismatch");
|
|
887
|
+
}
|
|
888
|
+
const lineageDigest = assertTranscriptIdentifier("lineage_digest", snapshot.lineageDigest);
|
|
889
|
+
const activeReachableMessageIds = assertActiveReachableMessageIds(snapshot.activeReachableMessageIds);
|
|
890
|
+
if (snapshot.pendingJournal !== null && (typeof snapshot.pendingJournal !== "object"
|
|
891
|
+
|| snapshot.pendingJournal === undefined)) {
|
|
892
|
+
throw new Error("transcript pending journal authority is invalid");
|
|
893
|
+
}
|
|
894
|
+
if (operation === "recall") {
|
|
895
|
+
if (snapshot.pendingJournal !== null) {
|
|
896
|
+
throw new Error("transcript recall blocked by pending journal");
|
|
897
|
+
}
|
|
898
|
+
if (requiredMessageIds.length > 0) {
|
|
899
|
+
throw new Error("transcript recall cannot request mutation message authority");
|
|
900
|
+
}
|
|
901
|
+
return Object.freeze({
|
|
902
|
+
scope: authorizedScope,
|
|
903
|
+
operation,
|
|
904
|
+
journal: null,
|
|
905
|
+
lineageDigest,
|
|
906
|
+
mutationPayloadSha256: null,
|
|
907
|
+
activeReachableMessageIds,
|
|
908
|
+
authorizedMutationMessageIds: Object.freeze([]),
|
|
909
|
+
requiredUpserts: Object.freeze([]),
|
|
910
|
+
suffix: null
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
const pending = snapshot.pendingJournal;
|
|
914
|
+
if (pending === null || authorizedJournal === null) {
|
|
915
|
+
throw new Error("transcript mutation is not backed by a pending journal");
|
|
916
|
+
}
|
|
917
|
+
const pendingIdentity = snapshotTranscriptJournalIdentity(pending.identity);
|
|
918
|
+
if (!transcriptJournalIdentityMatches(pendingIdentity, authorizedJournal)) {
|
|
919
|
+
throw new Error("transcript mutation journal authority mismatch");
|
|
920
|
+
}
|
|
921
|
+
if (!TRANSCRIPT_SHA256_HEX.test(pending.mutationPayloadSha256)) {
|
|
922
|
+
throw new Error("transcript mutation_payload_sha256 must be lowercase sha256 hex");
|
|
923
|
+
}
|
|
924
|
+
const authorizedMutationMessageIds = assertActiveReachableMessageIds(pending.authorizedMutationMessageIds);
|
|
925
|
+
const mutationSet = new Set(authorizedMutationMessageIds);
|
|
926
|
+
for (const messageId of activeReachableMessageIds) {
|
|
927
|
+
if (!mutationSet.has(messageId)) {
|
|
928
|
+
throw new Error("transcript pending journal omits an active reachable message");
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
for (const messageId of requiredMessageIds) {
|
|
932
|
+
assertTranscriptIdentifier("required_mutation_message_id", messageId);
|
|
933
|
+
if (!mutationSet.has(messageId)) {
|
|
934
|
+
throw new Error("transcript message is not authorized by the pending journal");
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (!Array.isArray(pending.requiredUpserts)) {
|
|
938
|
+
throw new Error("transcript pending journal required_upserts must be an array");
|
|
939
|
+
}
|
|
940
|
+
const requiredUpserts = Object.freeze(pending.requiredUpserts.map((entry) => {
|
|
941
|
+
if (typeof entry !== "object" || entry === null) {
|
|
942
|
+
throw new Error("transcript pending journal upsert identity is invalid");
|
|
943
|
+
}
|
|
944
|
+
const messageId = assertTranscriptIdentifier("required_upsert_message_id", entry.messageId);
|
|
945
|
+
if (!mutationSet.has(messageId)) {
|
|
946
|
+
throw new Error("transcript required upsert is not mutation-authorized");
|
|
947
|
+
}
|
|
948
|
+
return Object.freeze({
|
|
949
|
+
messageId,
|
|
950
|
+
sourceLedgerSeq: assertTranscriptLedgerSeq(entry.sourceLedgerSeq)
|
|
951
|
+
});
|
|
952
|
+
}));
|
|
953
|
+
const requiredUpsertIds = requiredUpserts.map((entry) => entry.messageId);
|
|
954
|
+
if (new Set(requiredUpsertIds).size !== requiredUpsertIds.length) {
|
|
955
|
+
throw new Error("transcript pending journal contains duplicate required upserts");
|
|
956
|
+
}
|
|
957
|
+
const requiredUpsertSeqs = requiredUpserts.map((entry) => entry.sourceLedgerSeq);
|
|
958
|
+
if (new Set(requiredUpsertSeqs).size !== requiredUpsertSeqs.length) {
|
|
959
|
+
throw new Error("transcript pending journal contains duplicate required upsert ledger identities");
|
|
960
|
+
}
|
|
961
|
+
const suffix = snapshotTranscriptSuffixSpec(pending.suffix, true);
|
|
962
|
+
return Object.freeze({
|
|
963
|
+
scope: authorizedScope,
|
|
964
|
+
operation,
|
|
965
|
+
journal: pendingIdentity,
|
|
966
|
+
lineageDigest,
|
|
967
|
+
mutationPayloadSha256: pending.mutationPayloadSha256,
|
|
968
|
+
activeReachableMessageIds,
|
|
969
|
+
authorizedMutationMessageIds,
|
|
970
|
+
requiredUpserts,
|
|
971
|
+
suffix
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
async assertTranscriptReachabilityCoverage(scope, reachable, allowedMissingMessageIds = new Set(), records) {
|
|
975
|
+
const scoped = records ?? await this.listTranscriptScope(scope);
|
|
976
|
+
const present = new Map(scoped.map((record) => [record.messageId, record]));
|
|
977
|
+
const missing = reachable.filter((messageId) => !allowedMissingMessageIds.has(messageId) && !present.has(messageId));
|
|
978
|
+
if (missing.length > 0) {
|
|
979
|
+
throw new Error(`transcript active lineage is incomplete: ${missing.join(",")}`);
|
|
980
|
+
}
|
|
981
|
+
const tombstoned = reachable.filter((messageId) => !allowedMissingMessageIds.has(messageId) && present.get(messageId)?.status === "tombstoned");
|
|
982
|
+
if (tombstoned.length > 0) {
|
|
983
|
+
throw new Error(`transcript active lineage is tombstoned: ${tombstoned.join(",")}`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
isInTransaction() {
|
|
987
|
+
return this.tx !== null || this.graphRollbackInProgress;
|
|
988
|
+
}
|
|
989
|
+
// Nested begin joins the outer tx (depth counter). Only the outermost commit
|
|
990
|
+
// clears the log; inner commit just decrements. Rollback at any depth unwinds
|
|
991
|
+
// the entire outer tx (2PC-style flat semantics, consistent with SQLite's
|
|
992
|
+
// BEGIN/COMMIT without savepoints).
|
|
993
|
+
beginTransaction() {
|
|
994
|
+
if (this.closing) {
|
|
995
|
+
throw new Error("graph transaction cannot start while ladybug bridge is closing");
|
|
996
|
+
}
|
|
997
|
+
if (this.graphRollbackInProgress) {
|
|
998
|
+
throw new Error("graph transaction cannot start during rollback replay");
|
|
999
|
+
}
|
|
1000
|
+
if (this.transcriptOperationReservations > 0 || this.transcriptOperationActive) {
|
|
1001
|
+
throw new Error("graph transaction cannot overlap a transcript operation");
|
|
1002
|
+
}
|
|
1003
|
+
if (this.tx !== null) {
|
|
1004
|
+
this.tx = { active: true, ops: this.tx.ops, depth: this.tx.depth + 1 };
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
this.tx = { active: true, ops: [], depth: 1 };
|
|
1008
|
+
}
|
|
1009
|
+
commitTransaction() {
|
|
1010
|
+
if (this.graphRollbackInProgress) {
|
|
1011
|
+
throw new Error("graph transaction commit cannot overlap rollback replay");
|
|
1012
|
+
}
|
|
1013
|
+
if (this.transcriptOperationReservations > 0 || this.transcriptOperationActive) {
|
|
1014
|
+
throw new Error("graph transaction commit cannot overlap a transcript operation");
|
|
1015
|
+
}
|
|
1016
|
+
if (this.tx === null)
|
|
1017
|
+
throw new Error("no active transaction to commit");
|
|
1018
|
+
if (this.tx.depth > 1) {
|
|
1019
|
+
this.tx = { active: true, ops: this.tx.ops, depth: this.tx.depth - 1 };
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
this.tx = null;
|
|
1023
|
+
}
|
|
1024
|
+
async rollbackTransaction() {
|
|
1025
|
+
if (this.graphRollbackInProgress) {
|
|
1026
|
+
throw new Error("graph transaction rollback replay is already in progress");
|
|
1027
|
+
}
|
|
1028
|
+
if (this.transcriptOperationReservations > 0 || this.transcriptOperationActive) {
|
|
1029
|
+
throw new Error("graph transaction rollback cannot overlap a transcript operation");
|
|
1030
|
+
}
|
|
1031
|
+
if (this.tx === null)
|
|
1032
|
+
throw new Error("no active transaction to rollback");
|
|
1033
|
+
const original = this.tx;
|
|
1034
|
+
const ops = original.ops.slice().reverse();
|
|
1035
|
+
this.tx = null; // detach so undo replay does not re-record itself
|
|
1036
|
+
this.graphRollbackInProgress = true;
|
|
1037
|
+
try {
|
|
1038
|
+
for (const op of ops) {
|
|
1039
|
+
await this.applyUndoOp(op);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
catch (error) {
|
|
1043
|
+
// Every inverse is idempotent (MERGE/SET or exact DELETE). Restoring the
|
|
1044
|
+
// original log makes a transient bridge failure safely retryable instead
|
|
1045
|
+
// of stranding Rust with an active batch and no compensating state.
|
|
1046
|
+
this.tx = original;
|
|
1047
|
+
throw error;
|
|
1048
|
+
}
|
|
1049
|
+
finally {
|
|
1050
|
+
this.graphRollbackInProgress = false;
|
|
1051
|
+
}
|
|
1052
|
+
this.advanceWorldMemoryRevision();
|
|
1053
|
+
}
|
|
1054
|
+
async withTransaction(fn) {
|
|
1055
|
+
this.beginTransaction();
|
|
1056
|
+
try {
|
|
1057
|
+
const out = await fn();
|
|
1058
|
+
this.commitTransaction();
|
|
1059
|
+
return out;
|
|
1060
|
+
}
|
|
1061
|
+
catch (e) {
|
|
1062
|
+
await this.rollbackTransaction();
|
|
1063
|
+
throw e;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
async applyUndoOp(op) {
|
|
1067
|
+
if (op.kind === "world-memory-restore") {
|
|
1068
|
+
await this.restoreWorldMemorySnapshot(op.snapshot);
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (op.kind === "transcript-restore-scope") {
|
|
1072
|
+
await this.restoreTranscriptScope(op.scope, op.records, true);
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (op.kind === "attr-history-restore") {
|
|
1076
|
+
await this.runDuringRollback(`MATCH (a:${ATTR_HISTORY_TABLE} {entityId: $entityId, attrPath: $attrPath}) DETACH DELETE a;`, { entityId: op.entityId, attrPath: op.attrPath });
|
|
1077
|
+
await this.restoreAttrHistoryRowsDuringRollback(op.rows);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
if (op.kind === "attr-history-restore-all") {
|
|
1081
|
+
await this.queryDuringRollback(`MATCH (a:${ATTR_HISTORY_TABLE}) DETACH DELETE a;`);
|
|
1082
|
+
await this.restoreAttrHistoryRowsDuringRollback(op.rows);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (op.kind === "latch-restore-scenario") {
|
|
1086
|
+
await this.runDuringRollback(`MATCH (l:${LATCH_TABLE} {scenarioId: $scenarioId}) DETACH DELETE l;`, { scenarioId: op.scenarioId });
|
|
1087
|
+
await this.restoreLatchRowsDuringRollback(op.scenarioId, op.rows);
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
if (op.kind === "entity-delete") {
|
|
1091
|
+
await this.runDuringRollback(`MATCH (e:${ENTITY_TABLE} {id: $id}) DETACH DELETE e;`, { id: op.id });
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
if (op.kind === "entity-lexical-projection-delete") {
|
|
1095
|
+
await this.runDuringRollback(`MATCH (p:${ENTITY_LEXICAL_PROJECTION_TABLE} {entityId: $entityId}) DETACH DELETE p;`, { entityId: op.entityId });
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
if (op.kind === "entity-lexical-projection-restore") {
|
|
1099
|
+
await this.runDuringRollback(`MERGE (p:${ENTITY_LEXICAL_PROJECTION_TABLE} {entityId: $entityId}) ` +
|
|
1100
|
+
"SET p.lexicalText = $lexicalText, p.projectionJson = $projectionJson, " +
|
|
1101
|
+
"p.analyzerVersion = $analyzerVersion, p.lexicalStatus = $lexicalStatus, " +
|
|
1102
|
+
"p.relationalStatus = $relationalStatus, p.writeSeq = $writeSeq;", {
|
|
1103
|
+
entityId: op.entityId,
|
|
1104
|
+
lexicalText: op.lexicalText,
|
|
1105
|
+
projectionJson: op.projectionJson,
|
|
1106
|
+
analyzerVersion: op.analyzerVersion,
|
|
1107
|
+
lexicalStatus: op.lexicalStatus,
|
|
1108
|
+
relationalStatus: op.relationalStatus,
|
|
1109
|
+
writeSeq: op.writeSeq
|
|
1110
|
+
});
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (op.kind === "entity-restore") {
|
|
1114
|
+
if (op.embedding && !this.vectorIndexBuilt) {
|
|
1115
|
+
await this.runDuringRollback(`MERGE (e:${ENTITY_TABLE} {id: $id}) ` +
|
|
1116
|
+
"SET e.kind = $kind, e.props = $props, e.embedding = $embedding, " +
|
|
1117
|
+
"e.messageId = $messageId, e.turnId = $turnId, e.writeSeq = $writeSeq;", {
|
|
1118
|
+
id: op.id,
|
|
1119
|
+
kind: op.entityKind,
|
|
1120
|
+
props: op.props,
|
|
1121
|
+
embedding: [...op.embedding],
|
|
1122
|
+
messageId: op.messageId,
|
|
1123
|
+
turnId: op.turnId,
|
|
1124
|
+
writeSeq: op.writeSeq
|
|
1125
|
+
});
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
await this.runDuringRollback(`MERGE (e:${ENTITY_TABLE} {id: $id}) ` +
|
|
1129
|
+
"SET e.kind = $kind, e.props = $props, e.embedding = NULL, " +
|
|
1130
|
+
"e.messageId = $messageId, e.turnId = $turnId, e.writeSeq = $writeSeq;", {
|
|
1131
|
+
id: op.id,
|
|
1132
|
+
kind: op.entityKind,
|
|
1133
|
+
props: op.props,
|
|
1134
|
+
messageId: op.messageId,
|
|
1135
|
+
turnId: op.turnId,
|
|
1136
|
+
writeSeq: op.writeSeq
|
|
1137
|
+
});
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
if (op.kind === "relation-delete") {
|
|
1141
|
+
await this.runDuringRollback(`MATCH (s:${ENTITY_TABLE} {id: $src})-[r:${RELATION_TABLE} {kind: $kind}]->` +
|
|
1142
|
+
`(d:${ENTITY_TABLE} {id: $dst}) DELETE r;`, { src: op.src, dst: op.dst, kind: op.relKind });
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
// relation-restore
|
|
1146
|
+
await this.runDuringRollback(`MATCH (s:${ENTITY_TABLE} {id: $src}), (d:${ENTITY_TABLE} {id: $dst}) ` +
|
|
1147
|
+
`MERGE (s)-[r:${RELATION_TABLE} {kind: $kind}]->(d) ` +
|
|
1148
|
+
"SET r.props = $props, r.messageId = $messageId, " +
|
|
1149
|
+
"r.turnId = $turnId, r.writeSeq = $writeSeq;", {
|
|
1150
|
+
src: op.src,
|
|
1151
|
+
dst: op.dst,
|
|
1152
|
+
kind: op.relKind,
|
|
1153
|
+
props: op.props,
|
|
1154
|
+
messageId: op.messageId,
|
|
1155
|
+
turnId: op.turnId,
|
|
1156
|
+
writeSeq: op.writeSeq
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
async restoreAttrHistoryRowsDuringRollback(rows) {
|
|
1160
|
+
if (rows.length === 0)
|
|
1161
|
+
return;
|
|
1162
|
+
const payload = rows.map((row) => ({
|
|
1163
|
+
attrHistoryId: row.attrHistoryId,
|
|
1164
|
+
entityId: row.entityId,
|
|
1165
|
+
attrPath: row.attrPath,
|
|
1166
|
+
oldValueText: row.oldValueText,
|
|
1167
|
+
newValueText: row.newValueText,
|
|
1168
|
+
replacedAtMessageId: row.replacedAtMessageId,
|
|
1169
|
+
replacedAtTurnId: row.replacedAtTurnId,
|
|
1170
|
+
replacedAtWriteSeq: row.replacedAtWriteSeq,
|
|
1171
|
+
superseded: row.superseded
|
|
1172
|
+
}));
|
|
1173
|
+
await this.runDuringRollback(`UNWIND $rows AS row MERGE (a:${ATTR_HISTORY_TABLE} {attrHistoryId: row.attrHistoryId}) ` +
|
|
1174
|
+
"SET a.entityId = row.entityId, a.attrPath = row.attrPath, " +
|
|
1175
|
+
"a.oldValueText = row.oldValueText, a.newValueText = row.newValueText, " +
|
|
1176
|
+
"a.replacedAtMessageId = row.replacedAtMessageId, a.replacedAtTurnId = row.replacedAtTurnId, " +
|
|
1177
|
+
"a.replacedAtWriteSeq = row.replacedAtWriteSeq, a.superseded = row.superseded;", { rows: payload });
|
|
1178
|
+
}
|
|
1179
|
+
async restoreLatchRowsDuringRollback(scenarioId, rows) {
|
|
1180
|
+
if (rows.length === 0)
|
|
1181
|
+
return;
|
|
1182
|
+
const payload = rows.map((row) => ({
|
|
1183
|
+
pk: this.latchPk(scenarioId, row.leaf_id),
|
|
1184
|
+
scenarioId,
|
|
1185
|
+
leafId: row.leaf_id,
|
|
1186
|
+
state: row.state,
|
|
1187
|
+
firstFiredAtMs: row.first_fired_at_ms,
|
|
1188
|
+
lastEvaluatedAtMs: row.last_evaluated_at_ms,
|
|
1189
|
+
fireCount: row.fire_count,
|
|
1190
|
+
source: row.source,
|
|
1191
|
+
confidence: row.confidence
|
|
1192
|
+
}));
|
|
1193
|
+
await this.runDuringRollback(`UNWIND $rows AS row MERGE (l:${LATCH_TABLE} {pk: row.pk}) ` +
|
|
1194
|
+
"SET l.scenarioId = row.scenarioId, l.leafId = row.leafId, l.state = row.state, " +
|
|
1195
|
+
"l.firstFiredAtMs = row.firstFiredAtMs, l.lastEvaluatedAtMs = row.lastEvaluatedAtMs, " +
|
|
1196
|
+
"l.fireCount = row.fireCount, l.source = row.source, l.confidence = row.confidence;", { rows: payload });
|
|
1197
|
+
}
|
|
1198
|
+
async snapshotEntity(id) {
|
|
1199
|
+
const rows = await this.runInternal(`MATCH (e:${ENTITY_TABLE} {id: $id}) ` +
|
|
1200
|
+
"RETURN e.kind AS kind, e.props AS props, e.embedding AS embedding, " +
|
|
1201
|
+
"e.messageId AS messageId, e.turnId AS turnId, e.writeSeq AS writeSeq;", { id });
|
|
1202
|
+
if (rows.length === 0)
|
|
1203
|
+
return null;
|
|
1204
|
+
const row = rows[0];
|
|
1205
|
+
const embRaw = row.embedding;
|
|
1206
|
+
let embedding = null;
|
|
1207
|
+
if (Array.isArray(embRaw)) {
|
|
1208
|
+
embedding = embRaw.map((n) => (typeof n === "number" ? n : Number(n)));
|
|
1209
|
+
}
|
|
1210
|
+
return {
|
|
1211
|
+
kind: "entity-restore",
|
|
1212
|
+
id,
|
|
1213
|
+
entityKind: asString(row.kind),
|
|
1214
|
+
props: asString(row.props),
|
|
1215
|
+
embedding,
|
|
1216
|
+
messageId: asString(row.messageId),
|
|
1217
|
+
turnId: asString(row.turnId),
|
|
1218
|
+
writeSeq: asNumber(row.writeSeq)
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
async snapshotEntityAnalyzerProjection(entityId) {
|
|
1222
|
+
const rows = await this.runInternal(`MATCH (p:${ENTITY_LEXICAL_PROJECTION_TABLE} {entityId: $entityId}) ` +
|
|
1223
|
+
"RETURN p.lexicalText AS lexicalText, p.projectionJson AS projectionJson, " +
|
|
1224
|
+
"p.analyzerVersion AS analyzerVersion, p.lexicalStatus AS lexicalStatus, " +
|
|
1225
|
+
"p.relationalStatus AS relationalStatus, p.writeSeq AS writeSeq;", { entityId });
|
|
1226
|
+
if (rows.length === 0)
|
|
1227
|
+
return null;
|
|
1228
|
+
const row = rows[0];
|
|
1229
|
+
return {
|
|
1230
|
+
kind: "entity-lexical-projection-restore",
|
|
1231
|
+
entityId,
|
|
1232
|
+
lexicalText: asString(row.lexicalText),
|
|
1233
|
+
projectionJson: asString(row.projectionJson),
|
|
1234
|
+
analyzerVersion: asString(row.analyzerVersion),
|
|
1235
|
+
lexicalStatus: asString(row.lexicalStatus),
|
|
1236
|
+
relationalStatus: asString(row.relationalStatus),
|
|
1237
|
+
writeSeq: asNumber(row.writeSeq)
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
async snapshotRelation(src, dst, kind) {
|
|
1241
|
+
const rows = await this.runInternal(`MATCH (s:${ENTITY_TABLE} {id: $src})-[r:${RELATION_TABLE} {kind: $kind}]->` +
|
|
1242
|
+
`(d:${ENTITY_TABLE} {id: $dst}) ` +
|
|
1243
|
+
"RETURN r.props AS props, r.messageId AS messageId, " +
|
|
1244
|
+
"r.turnId AS turnId, r.writeSeq AS writeSeq;", { src, dst, kind });
|
|
1245
|
+
if (rows.length === 0)
|
|
1246
|
+
return null;
|
|
1247
|
+
const row = rows[0];
|
|
1248
|
+
return {
|
|
1249
|
+
kind: "relation-restore",
|
|
1250
|
+
src,
|
|
1251
|
+
dst,
|
|
1252
|
+
relKind: kind,
|
|
1253
|
+
props: asString(row.props),
|
|
1254
|
+
messageId: asString(row.messageId),
|
|
1255
|
+
turnId: asString(row.turnId),
|
|
1256
|
+
writeSeq: asNumber(row.writeSeq)
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
async recordEntityUndoBeforeWrite(id) {
|
|
1260
|
+
if (this.tx === null)
|
|
1261
|
+
return;
|
|
1262
|
+
const priorProjection = await this.snapshotEntityAnalyzerProjection(id);
|
|
1263
|
+
this.tx.ops.push(priorProjection ?? { kind: "entity-lexical-projection-delete", entityId: id });
|
|
1264
|
+
const prior = await this.snapshotEntity(id);
|
|
1265
|
+
if (prior) {
|
|
1266
|
+
this.tx.ops.push(prior);
|
|
1267
|
+
}
|
|
1268
|
+
else {
|
|
1269
|
+
this.tx.ops.push({ kind: "entity-delete", id });
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
async recordRelationUndoBeforeWrite(src, dst, kind) {
|
|
1273
|
+
if (this.tx === null)
|
|
1274
|
+
return;
|
|
1275
|
+
const prior = await this.snapshotRelation(src, dst, kind);
|
|
1276
|
+
if (prior) {
|
|
1277
|
+
this.tx.ops.push(prior);
|
|
1278
|
+
}
|
|
1279
|
+
else {
|
|
1280
|
+
this.tx.ops.push({ kind: "relation-delete", src, dst, relKind: kind });
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
async recordEntityUndoBeforeDelete(id) {
|
|
1284
|
+
if (this.tx === null)
|
|
1285
|
+
return;
|
|
1286
|
+
// Snapshot the entity itself + every adjacent relation so DETACH DELETE can
|
|
1287
|
+
// be reversed. Relations are snapshotted first in the ops array so that on
|
|
1288
|
+
// reverse-replay the entity is restored first then the relations (DAG order).
|
|
1289
|
+
const prior = await this.snapshotEntity(id);
|
|
1290
|
+
if (!prior)
|
|
1291
|
+
return;
|
|
1292
|
+
const priorProjection = await this.snapshotEntityAnalyzerProjection(id);
|
|
1293
|
+
if (priorProjection)
|
|
1294
|
+
this.tx.ops.push(priorProjection);
|
|
1295
|
+
const rels = await this.runInternal(`MATCH (s:${ENTITY_TABLE})-[r:${RELATION_TABLE}]->(d:${ENTITY_TABLE}) ` +
|
|
1296
|
+
"WHERE s.id = $id OR d.id = $id " +
|
|
1297
|
+
"RETURN s.id AS src, d.id AS dst, r.kind AS kind, r.props AS props, " +
|
|
1298
|
+
"r.messageId AS messageId, r.turnId AS turnId, r.writeSeq AS writeSeq;", { id });
|
|
1299
|
+
// Push relations FIRST (so reverse-replay pops them LAST, after entity restored)
|
|
1300
|
+
for (const row of rels) {
|
|
1301
|
+
this.tx.ops.push({
|
|
1302
|
+
kind: "relation-restore",
|
|
1303
|
+
src: asString(row.src),
|
|
1304
|
+
dst: asString(row.dst),
|
|
1305
|
+
relKind: asString(row.kind),
|
|
1306
|
+
props: asString(row.props),
|
|
1307
|
+
messageId: asString(row.messageId),
|
|
1308
|
+
turnId: asString(row.turnId),
|
|
1309
|
+
writeSeq: asNumber(row.writeSeq)
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
this.tx.ops.push(prior);
|
|
1313
|
+
}
|
|
1314
|
+
async recordRelationUndoBeforeDelete(src, dst) {
|
|
1315
|
+
if (this.tx === null)
|
|
1316
|
+
return;
|
|
1317
|
+
const rows = await this.runInternal(`MATCH (s:${ENTITY_TABLE} {id: $src})-[r:${RELATION_TABLE}]->` +
|
|
1318
|
+
`(d:${ENTITY_TABLE} {id: $dst}) ` +
|
|
1319
|
+
"RETURN r.kind AS kind, r.props AS props, " +
|
|
1320
|
+
"r.messageId AS messageId, r.turnId AS turnId, r.writeSeq AS writeSeq;", { src, dst });
|
|
1321
|
+
for (const row of rows) {
|
|
1322
|
+
this.tx.ops.push({
|
|
1323
|
+
kind: "relation-restore",
|
|
1324
|
+
src,
|
|
1325
|
+
dst,
|
|
1326
|
+
relKind: asString(row.kind),
|
|
1327
|
+
props: asString(row.props),
|
|
1328
|
+
messageId: asString(row.messageId),
|
|
1329
|
+
turnId: asString(row.turnId),
|
|
1330
|
+
writeSeq: asNumber(row.writeSeq)
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
async recordAttrHistoryUndo(entityId, attrPath) {
|
|
1335
|
+
if (this.tx === null)
|
|
1336
|
+
return;
|
|
1337
|
+
this.tx.ops.push({
|
|
1338
|
+
kind: "attr-history-restore",
|
|
1339
|
+
entityId,
|
|
1340
|
+
attrPath,
|
|
1341
|
+
rows: await this.queryAttrHistory(entityId, attrPath)
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
async recordAllAttrHistoryUndo() {
|
|
1345
|
+
if (this.tx === null)
|
|
1346
|
+
return;
|
|
1347
|
+
const rows = await this.runInternal(`MATCH (a:${ATTR_HISTORY_TABLE}) ` +
|
|
1348
|
+
"RETURN a.attrHistoryId AS attrHistoryId, a.entityId AS entityId, " +
|
|
1349
|
+
"a.attrPath AS attrPath, a.oldValueText AS oldValueText, a.newValueText AS newValueText, " +
|
|
1350
|
+
"a.replacedAtMessageId AS replacedAtMessageId, a.replacedAtTurnId AS replacedAtTurnId, " +
|
|
1351
|
+
"a.replacedAtWriteSeq AS replacedAtWriteSeq, a.superseded AS superseded " +
|
|
1352
|
+
"ORDER BY a.replacedAtWriteSeq;", {});
|
|
1353
|
+
this.tx.ops.push({
|
|
1354
|
+
kind: "attr-history-restore-all",
|
|
1355
|
+
rows: rows.map((row) => this.toAttrHistory(row))
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
async recordLatchScenarioUndo(scenarioId) {
|
|
1359
|
+
if (this.tx === null)
|
|
1360
|
+
return;
|
|
1361
|
+
this.tx.ops.push({
|
|
1362
|
+
kind: "latch-restore-scenario",
|
|
1363
|
+
scenarioId,
|
|
1364
|
+
rows: await this.latchLoad(scenarioId)
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
async recordRollbackAfterMessageUndo(pivot) {
|
|
1368
|
+
if (this.tx === null)
|
|
1369
|
+
return;
|
|
1370
|
+
const projections = await this.runInternal(`MATCH (p:${ENTITY_LEXICAL_PROJECTION_TABLE}) WHERE p.writeSeq > $pivot ` +
|
|
1371
|
+
"RETURN p.entityId AS entityId, p.lexicalText AS lexicalText, " +
|
|
1372
|
+
"p.projectionJson AS projectionJson, p.analyzerVersion AS analyzerVersion, " +
|
|
1373
|
+
"p.lexicalStatus AS lexicalStatus, p.relationalStatus AS relationalStatus, " +
|
|
1374
|
+
"p.writeSeq AS writeSeq;", { pivot });
|
|
1375
|
+
for (const row of projections) {
|
|
1376
|
+
this.tx.ops.push({
|
|
1377
|
+
kind: "entity-lexical-projection-restore",
|
|
1378
|
+
entityId: asString(row.entityId),
|
|
1379
|
+
lexicalText: asString(row.lexicalText),
|
|
1380
|
+
projectionJson: asString(row.projectionJson),
|
|
1381
|
+
analyzerVersion: asString(row.analyzerVersion),
|
|
1382
|
+
lexicalStatus: asString(row.lexicalStatus),
|
|
1383
|
+
relationalStatus: asString(row.relationalStatus),
|
|
1384
|
+
writeSeq: asNumber(row.writeSeq)
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
const relations = await this.runInternal(`MATCH (s:${ENTITY_TABLE})-[r:${RELATION_TABLE}]->(d:${ENTITY_TABLE}) ` +
|
|
1388
|
+
"WHERE r.writeSeq > $pivot OR s.writeSeq > $pivot OR d.writeSeq > $pivot " +
|
|
1389
|
+
"RETURN s.id AS src, d.id AS dst, r.kind AS kind, r.props AS props, " +
|
|
1390
|
+
"r.messageId AS messageId, r.turnId AS turnId, r.writeSeq AS writeSeq;", { pivot });
|
|
1391
|
+
for (const row of relations) {
|
|
1392
|
+
this.tx.ops.push({
|
|
1393
|
+
kind: "relation-restore",
|
|
1394
|
+
src: asString(row.src),
|
|
1395
|
+
dst: asString(row.dst),
|
|
1396
|
+
relKind: asString(row.kind),
|
|
1397
|
+
props: asString(row.props),
|
|
1398
|
+
messageId: asString(row.messageId),
|
|
1399
|
+
turnId: asString(row.turnId),
|
|
1400
|
+
writeSeq: asNumber(row.writeSeq)
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
const entities = await this.runInternal(`MATCH (e:${ENTITY_TABLE}) WHERE e.writeSeq > $pivot ` +
|
|
1404
|
+
"RETURN e.id AS id, e.kind AS kind, e.props AS props, e.embedding AS embedding, " +
|
|
1405
|
+
"e.messageId AS messageId, e.turnId AS turnId, e.writeSeq AS writeSeq;", { pivot });
|
|
1406
|
+
for (const row of entities) {
|
|
1407
|
+
const embedding = Array.isArray(row.embedding)
|
|
1408
|
+
? row.embedding.map((value) => (typeof value === "number" ? value : Number(value)))
|
|
1409
|
+
: null;
|
|
1410
|
+
this.tx.ops.push({
|
|
1411
|
+
kind: "entity-restore",
|
|
1412
|
+
id: asString(row.id),
|
|
1413
|
+
entityKind: asString(row.kind),
|
|
1414
|
+
props: asString(row.props),
|
|
1415
|
+
embedding,
|
|
1416
|
+
messageId: asString(row.messageId),
|
|
1417
|
+
turnId: asString(row.turnId),
|
|
1418
|
+
writeSeq: asNumber(row.writeSeq)
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
constructor(options, variant = lbug) {
|
|
1423
|
+
if (!Number.isInteger(options.dim) || options.dim <= 0) {
|
|
1424
|
+
throw new Error(`dim must be a positive integer got ${options.dim}`);
|
|
1425
|
+
}
|
|
1426
|
+
this.dim = options.dim;
|
|
1427
|
+
this.variant = variant;
|
|
1428
|
+
this.transcriptReachabilityResolver = options.resolveTranscriptReachability ?? null;
|
|
1429
|
+
if (options.worldMemoryHostAuthority !== undefined
|
|
1430
|
+
&& !worldMemoryHostAuthorities.has(options.worldMemoryHostAuthority)) {
|
|
1431
|
+
throw new Error("world memory host authority was not created by this bridge module");
|
|
1432
|
+
}
|
|
1433
|
+
this.worldMemoryHostAuthority = options.worldMemoryHostAuthority ?? null;
|
|
1434
|
+
if (options.workerPath && variant.setWorkerPath) {
|
|
1435
|
+
variant.setWorkerPath(options.workerPath);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
async init() {
|
|
1439
|
+
if (this.closing) {
|
|
1440
|
+
throw new Error("ladybug bridge cannot initialize while closing");
|
|
1441
|
+
}
|
|
1442
|
+
if (this.initializing) {
|
|
1443
|
+
throw new Error("ladybug bridge initialization is already in progress");
|
|
1444
|
+
}
|
|
1445
|
+
if (this.graphRollbackInProgress) {
|
|
1446
|
+
throw new Error("ladybug bridge cannot initialize during rollback replay");
|
|
1447
|
+
}
|
|
1448
|
+
if (this.ready)
|
|
1449
|
+
return;
|
|
1450
|
+
this.initializing = true;
|
|
1451
|
+
try {
|
|
1452
|
+
this.db = new this.variant.Database(IN_MEMORY_PATH);
|
|
1453
|
+
await this.db.init();
|
|
1454
|
+
this.conn = new this.variant.Connection(this.db);
|
|
1455
|
+
await this.conn.init();
|
|
1456
|
+
await this.createSchema();
|
|
1457
|
+
this.ready = true;
|
|
1458
|
+
}
|
|
1459
|
+
finally {
|
|
1460
|
+
this.initializing = false;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
requireConnUnchecked() {
|
|
1464
|
+
if (!this.conn)
|
|
1465
|
+
throw new Error("ladybug bridge not initialized");
|
|
1466
|
+
return this.conn;
|
|
1467
|
+
}
|
|
1468
|
+
requireConn() {
|
|
1469
|
+
if (this.closing)
|
|
1470
|
+
throw new Error("ladybug bridge is closing");
|
|
1471
|
+
if (this.graphRollbackInProgress)
|
|
1472
|
+
throw new Error("ladybug bridge graph rollback replay is in progress");
|
|
1473
|
+
return this.requireConnUnchecked();
|
|
1474
|
+
}
|
|
1475
|
+
async queryDuringRollback(statement) {
|
|
1476
|
+
if (!this.graphRollbackInProgress) {
|
|
1477
|
+
throw new Error("ladybug rollback query requires active rollback replay");
|
|
1478
|
+
}
|
|
1479
|
+
return collectRows(await this.requireConnUnchecked().query(statement));
|
|
1480
|
+
}
|
|
1481
|
+
async runDuringRollback(statement, params) {
|
|
1482
|
+
if (!this.graphRollbackInProgress) {
|
|
1483
|
+
throw new Error("ladybug rollback statement requires active rollback replay");
|
|
1484
|
+
}
|
|
1485
|
+
const conn = this.requireConnUnchecked();
|
|
1486
|
+
const prepared = await conn.prepare(statement);
|
|
1487
|
+
if (!prepared.isSuccess()) {
|
|
1488
|
+
const message = await prepared.getErrorMessage();
|
|
1489
|
+
await prepared.close();
|
|
1490
|
+
throw new Error(message);
|
|
1491
|
+
}
|
|
1492
|
+
const result = await conn.execute(prepared, params);
|
|
1493
|
+
await prepared.close();
|
|
1494
|
+
return collectRows(result);
|
|
1495
|
+
}
|
|
1496
|
+
assertStatementCannotAddressWorldMemory(statement) {
|
|
1497
|
+
if (WORLD_MEMORY_TABLE_PATTERN.test(statement)) {
|
|
1498
|
+
throw new Error("world memory schema is only available through the trusted host adapter");
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
assertBusinessStatementCannotAddressWorldMemory(statement) {
|
|
1502
|
+
this.assertStatementCannotAddressWorldMemory(statement);
|
|
1503
|
+
if (this.worldMemoryHostAuthority !== null) {
|
|
1504
|
+
throw new Error("trusted WorldMemory bridge does not expose raw query or run");
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
async queryForWasmRuntime(statement) {
|
|
1508
|
+
this.assertStatementCannotAddressWorldMemory(statement);
|
|
1509
|
+
return this.queryInternal(statement);
|
|
1510
|
+
}
|
|
1511
|
+
async queryInternal(statement) {
|
|
1512
|
+
return collectRows(await this.requireConn().query(statement));
|
|
1513
|
+
}
|
|
1514
|
+
async runInternal(statement, params) {
|
|
1515
|
+
const conn = this.requireConn();
|
|
1516
|
+
const prepared = await conn.prepare(statement);
|
|
1517
|
+
if (!prepared.isSuccess()) {
|
|
1518
|
+
const message = await prepared.getErrorMessage();
|
|
1519
|
+
await prepared.close();
|
|
1520
|
+
throw new Error(message);
|
|
1521
|
+
}
|
|
1522
|
+
const result = await conn.execute(prepared, params);
|
|
1523
|
+
await prepared.close();
|
|
1524
|
+
return collectRows(result);
|
|
1525
|
+
}
|
|
1526
|
+
async query(statement) {
|
|
1527
|
+
this.assertBusinessStatementCannotAddressWorldMemory(statement);
|
|
1528
|
+
return this.queryInternal(statement);
|
|
1529
|
+
}
|
|
1530
|
+
async run(statement, params) {
|
|
1531
|
+
this.assertBusinessStatementCannotAddressWorldMemory(statement);
|
|
1532
|
+
return this.runInternal(statement, params);
|
|
1533
|
+
}
|
|
1534
|
+
async queryWorldMemory(statement) {
|
|
1535
|
+
if (!WORLD_MEMORY_TABLE_PATTERN.test(statement)) {
|
|
1536
|
+
throw new Error("world memory internal query must address a dedicated Wm table");
|
|
1537
|
+
}
|
|
1538
|
+
return this.queryInternal(statement);
|
|
1539
|
+
}
|
|
1540
|
+
async runWorldMemory(statement, params) {
|
|
1541
|
+
if (!WORLD_MEMORY_TABLE_PATTERN.test(statement)) {
|
|
1542
|
+
throw new Error("world memory internal statement must address a dedicated Wm table");
|
|
1543
|
+
}
|
|
1544
|
+
return this.runInternal(statement, params);
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Redacted full-state CAS used by the Runtime 2 transaction barrier. Raw
|
|
1548
|
+
* rows never cross the bridge; only a domain-separated digest is returned.
|
|
1549
|
+
*/
|
|
1550
|
+
async stateDigest() {
|
|
1551
|
+
const entities = await this.runInternal(`MATCH (e:${ENTITY_TABLE}) ` +
|
|
1552
|
+
"RETURN e.id AS id, e.kind AS kind, e.props AS props, e.embedding AS embedding, " +
|
|
1553
|
+
"e.messageId AS messageId, e.turnId AS turnId, e.writeSeq AS writeSeq ORDER BY e.id;", {});
|
|
1554
|
+
const entityLexicalProjections = await this.runInternal(`MATCH (p:${ENTITY_LEXICAL_PROJECTION_TABLE}) ` +
|
|
1555
|
+
"RETURN p.entityId AS entityId, p.lexicalText AS lexicalText, " +
|
|
1556
|
+
"p.projectionJson AS projectionJson, p.analyzerVersion AS analyzerVersion, " +
|
|
1557
|
+
"p.lexicalStatus AS lexicalStatus, p.relationalStatus AS relationalStatus, " +
|
|
1558
|
+
"p.writeSeq AS writeSeq ORDER BY entityId;", {});
|
|
1559
|
+
const relations = await this.runInternal(`MATCH (s:${ENTITY_TABLE})-[r:${RELATION_TABLE}]->(d:${ENTITY_TABLE}) ` +
|
|
1560
|
+
"RETURN s.id AS src, d.id AS dst, r.kind AS kind, r.props AS props, " +
|
|
1561
|
+
"r.messageId AS messageId, r.turnId AS turnId, r.writeSeq AS writeSeq " +
|
|
1562
|
+
"ORDER BY src, dst, kind;", {});
|
|
1563
|
+
const attrHistory = await this.runInternal(`MATCH (a:${ATTR_HISTORY_TABLE}) ` +
|
|
1564
|
+
"RETURN a.attrHistoryId AS attrHistoryId, a.entityId AS entityId, " +
|
|
1565
|
+
"a.attrPath AS attrPath, a.oldValueText AS oldValueText, a.newValueText AS newValueText, " +
|
|
1566
|
+
"a.replacedAtMessageId AS replacedAtMessageId, a.replacedAtTurnId AS replacedAtTurnId, " +
|
|
1567
|
+
"a.replacedAtWriteSeq AS replacedAtWriteSeq, a.superseded AS superseded " +
|
|
1568
|
+
"ORDER BY attrHistoryId;", {});
|
|
1569
|
+
const latches = await this.runInternal(`MATCH (l:${LATCH_TABLE}) ` +
|
|
1570
|
+
"RETURN l.pk AS pk, l.scenarioId AS scenarioId, l.leafId AS leafId, l.state AS state, " +
|
|
1571
|
+
"l.firstFiredAtMs AS firstFiredAtMs, l.lastEvaluatedAtMs AS lastEvaluatedAtMs, " +
|
|
1572
|
+
"l.fireCount AS fireCount, l.source AS source, l.confidence AS confidence ORDER BY pk;", {});
|
|
1573
|
+
const transcriptMessages = await this.runInternal(`MATCH (m:${TRANSCRIPT_MESSAGE_TABLE}) ` +
|
|
1574
|
+
"RETURN m.scenarioId AS scenarioId, m.saveId AS saveId, m.worldlineId AS worldlineId, " +
|
|
1575
|
+
"m.messageId AS messageId, m.turnId AS turnId, m.role AS role, m.content AS content, " +
|
|
1576
|
+
"m.status AS status, m.createdAtMs AS createdAtMs, m.updatedAtMs AS updatedAtMs, " +
|
|
1577
|
+
"m.sourceLedgerSeq AS sourceLedgerSeq ORDER BY scenarioId, saveId, worldlineId, sourceLedgerSeq, messageId;", {});
|
|
1578
|
+
const canonical = JSON.stringify({
|
|
1579
|
+
schemaVersion: 3,
|
|
1580
|
+
dim: this.dim,
|
|
1581
|
+
entities,
|
|
1582
|
+
entityLexicalProjections,
|
|
1583
|
+
relations,
|
|
1584
|
+
attrHistory,
|
|
1585
|
+
latches,
|
|
1586
|
+
transcriptMessages
|
|
1587
|
+
});
|
|
1588
|
+
return sha256HexUtf8(`ladybug-runtime2-state-v3\u0000${canonical}`);
|
|
1589
|
+
}
|
|
1590
|
+
async recallCandidateRecord(id) {
|
|
1591
|
+
const rows = await this.runInternal(`MATCH (e:${ENTITY_TABLE} {id: $id}) RETURN ` +
|
|
1592
|
+
"e.id AS id, e.kind AS kind, e.props AS props, e.embedding AS embedding, e.messageId AS messageId, " +
|
|
1593
|
+
"e.turnId AS turnId, e.writeSeq AS recordedRevision;", { id });
|
|
1594
|
+
if (rows.length === 0)
|
|
1595
|
+
return null;
|
|
1596
|
+
if (rows.length !== 1)
|
|
1597
|
+
throw new Error(`recall candidate identity is not unique: ${id}`);
|
|
1598
|
+
const row = rows[0];
|
|
1599
|
+
const recordedRevision = asNumber(row.recordedRevision);
|
|
1600
|
+
const writeRevision = this._writeSeq;
|
|
1601
|
+
if (!Number.isSafeInteger(recordedRevision) || recordedRevision < 1
|
|
1602
|
+
|| !Number.isSafeInteger(writeRevision) || writeRevision < recordedRevision) {
|
|
1603
|
+
throw new Error(`recall candidate revision invalid: ${id}`);
|
|
1604
|
+
}
|
|
1605
|
+
const messageId = asString(row.messageId);
|
|
1606
|
+
const turnId = asString(row.turnId);
|
|
1607
|
+
if (messageId.length === 0 || turnId.length === 0 || /[\0\r\n]/.test(messageId) || /[\0\r\n]/.test(turnId)) {
|
|
1608
|
+
throw new Error(`recall candidate write identity invalid: ${id}`);
|
|
1609
|
+
}
|
|
1610
|
+
return Object.freeze({
|
|
1611
|
+
entity: Object.freeze(this.toEntity(row)),
|
|
1612
|
+
embedding: Object.freeze(asNumberArray(row.embedding)),
|
|
1613
|
+
messageId,
|
|
1614
|
+
turnId,
|
|
1615
|
+
recordedRevision,
|
|
1616
|
+
writeRevision
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
async createSchema() {
|
|
1620
|
+
await this.queryInternal(`CREATE NODE TABLE IF NOT EXISTS ${ENTITY_TABLE}(` +
|
|
1621
|
+
`id STRING, kind STRING, props STRING, embedding FLOAT[${this.dim}], ` +
|
|
1622
|
+
`messageId STRING, turnId STRING, writeSeq INT64, PRIMARY KEY (id));`);
|
|
1623
|
+
await this.queryInternal(`CREATE NODE TABLE IF NOT EXISTS ${ENTITY_LEXICAL_PROJECTION_TABLE}(` +
|
|
1624
|
+
"entityId STRING, lexicalText STRING, projectionJson STRING, analyzerVersion STRING, " +
|
|
1625
|
+
"lexicalStatus STRING, relationalStatus STRING, writeSeq INT64, PRIMARY KEY (entityId));");
|
|
1626
|
+
await this.queryInternal(`CREATE REL TABLE IF NOT EXISTS ${RELATION_TABLE}(` +
|
|
1627
|
+
`FROM ${ENTITY_TABLE} TO ${ENTITY_TABLE}, kind STRING, props STRING, ` +
|
|
1628
|
+
`messageId STRING, turnId STRING, writeSeq INT64);`);
|
|
1629
|
+
await this.queryInternal(`CREATE NODE TABLE IF NOT EXISTS ${LATCH_TABLE}(` +
|
|
1630
|
+
"pk STRING, scenarioId STRING, leafId STRING, state STRING, " +
|
|
1631
|
+
"firstFiredAtMs INT64, lastEvaluatedAtMs INT64, fireCount INT64, " +
|
|
1632
|
+
"source STRING, confidence DOUBLE, " +
|
|
1633
|
+
"PRIMARY KEY (pk));");
|
|
1634
|
+
await this.queryInternal(`CREATE NODE TABLE IF NOT EXISTS ${ATTR_HISTORY_TABLE}(` +
|
|
1635
|
+
"attrHistoryId STRING, entityId STRING, attrPath STRING, " +
|
|
1636
|
+
"oldValueText STRING, newValueText STRING, " +
|
|
1637
|
+
"replacedAtMessageId STRING, replacedAtTurnId STRING, " +
|
|
1638
|
+
"replacedAtWriteSeq INT64, superseded BOOL, " +
|
|
1639
|
+
"PRIMARY KEY (attrHistoryId));");
|
|
1640
|
+
await this.queryInternal(`CREATE NODE TABLE IF NOT EXISTS ${TRANSCRIPT_MESSAGE_TABLE}(` +
|
|
1641
|
+
"pk STRING, scenarioId STRING, saveId STRING, worldlineId STRING, " +
|
|
1642
|
+
"messageId STRING, turnId STRING, role STRING, content STRING, searchText STRING, status STRING, " +
|
|
1643
|
+
"createdAtMs INT64, updatedAtMs INT64, sourceLedgerSeq INT64, " +
|
|
1644
|
+
"PRIMARY KEY (pk));");
|
|
1645
|
+
await this.createWorldMemorySchema();
|
|
1646
|
+
}
|
|
1647
|
+
async createWorldMemorySchema() {
|
|
1648
|
+
await this.queryWorldMemory(`CREATE NODE TABLE IF NOT EXISTS ${WM_EPISODE_TABLE}(` +
|
|
1649
|
+
`id STRING, sourceMessageIds STRING, turnBundleId STRING, synopsis STRING, ` +
|
|
1650
|
+
`searchText STRING, embedding FLOAT[${WORLD_MEMORY_EMBEDDING_DIM}], sourceDigest STRING, ` +
|
|
1651
|
+
`worldTimeFrom INT64, worldTimeTo INT64, recordedWriteSeq INT64, reachability STRING, ` +
|
|
1652
|
+
`model STRING, provider STRING, attemptId STRING, schemaVersion INT32, PRIMARY KEY (id));`);
|
|
1653
|
+
await this.queryWorldMemory(`CREATE NODE TABLE IF NOT EXISTS ${WM_CLAIM_ROOT_TABLE}(` +
|
|
1654
|
+
`id STRING, subjectKind STRING, entityId STRING, attrPath STRING, relationKey STRING, ` +
|
|
1655
|
+
`currentClaimId STRING, latestRevision INT64, schemaVersion INT32, PRIMARY KEY (id));`);
|
|
1656
|
+
await this.queryWorldMemory(`CREATE NODE TABLE IF NOT EXISTS ${WM_CLAIM_TABLE}(` +
|
|
1657
|
+
`id STRING, rootId STRING, authorityClass STRING, status STRING, canonicalValue STRING, ` +
|
|
1658
|
+
`canonicalValueSha256 STRING, confidence DOUBLE, recordedFromWriteSeq INT64, ` +
|
|
1659
|
+
`recordedToWriteSeq INT64, validFromWorldSeconds INT64, validToWorldSeconds INT64, ` +
|
|
1660
|
+
`sourceDigest STRING, sourceEpisodeIds STRING, sourceMessageIds STRING, revision INT64, ` +
|
|
1661
|
+
`schemaVersion INT32, PRIMARY KEY (id));`);
|
|
1662
|
+
await this.queryWorldMemory(`CREATE NODE TABLE IF NOT EXISTS ${WM_DERIVED_TABLE}(` +
|
|
1663
|
+
`id STRING, derivedKind STRING, text STRING, searchText STRING, ` +
|
|
1664
|
+
`embedding FLOAT[${WORLD_MEMORY_EMBEDDING_DIM}], ` +
|
|
1665
|
+
`authorityClass STRING, status STRING, sourceDigest STRING, coverageIds STRING, ` +
|
|
1666
|
+
`summarizedThroughOpId STRING, validFromWorldSeconds INT64, validToWorldSeconds INT64, ` +
|
|
1667
|
+
`recordedWriteSeq INT64, model STRING, provider STRING, promptVersion STRING, ` +
|
|
1668
|
+
`tokenizerVersion STRING, schemaVersion INT32, PRIMARY KEY (id));`);
|
|
1669
|
+
for (const [relationTable, definition] of Object.entries(MEMORY_RELATION_DEFINITIONS)) {
|
|
1670
|
+
await this.queryWorldMemory(`CREATE REL TABLE IF NOT EXISTS ${relationTable}(` +
|
|
1671
|
+
`FROM ${definition.sourceTable} TO ${definition.targetTable});`);
|
|
1672
|
+
}
|
|
1673
|
+
await this.ensureWorldMemoryIndexes();
|
|
1674
|
+
}
|
|
1675
|
+
async executeWorldMemoryQuery(statement, duringRollback = false) {
|
|
1676
|
+
return duringRollback
|
|
1677
|
+
? this.queryDuringRollback(statement)
|
|
1678
|
+
: this.queryWorldMemory(statement);
|
|
1679
|
+
}
|
|
1680
|
+
async ensureWorldMemoryIndexes(duringRollback = false) {
|
|
1681
|
+
if (!this.memoryEpisodeVectorIndexBuilt) {
|
|
1682
|
+
await this.executeWorldMemoryQuery(`CALL CREATE_VECTOR_INDEX('${WM_EPISODE_TABLE}', '${WM_EPISODE_VECTOR_INDEX}', ` +
|
|
1683
|
+
"'embedding', metric := 'cosine');", duringRollback);
|
|
1684
|
+
this.memoryEpisodeVectorIndexBuilt = true;
|
|
1685
|
+
}
|
|
1686
|
+
if (!this.memoryDerivedVectorIndexBuilt) {
|
|
1687
|
+
await this.executeWorldMemoryQuery(`CALL CREATE_VECTOR_INDEX('${WM_DERIVED_TABLE}', '${WM_DERIVED_VECTOR_INDEX}', ` +
|
|
1688
|
+
"'embedding', metric := 'cosine');", duringRollback);
|
|
1689
|
+
this.memoryDerivedVectorIndexBuilt = true;
|
|
1690
|
+
}
|
|
1691
|
+
if (!this.memoryEpisodeFtsIndexBuilt) {
|
|
1692
|
+
await this.executeWorldMemoryQuery(`CALL CREATE_FTS_INDEX('${WM_EPISODE_TABLE}', '${WM_EPISODE_FTS_INDEX}', ['searchText'], ` +
|
|
1693
|
+
"stemmer := 'none', tokenizer := 'simple');", duringRollback);
|
|
1694
|
+
this.memoryEpisodeFtsIndexBuilt = true;
|
|
1695
|
+
}
|
|
1696
|
+
if (!this.memoryDerivedFtsIndexBuilt) {
|
|
1697
|
+
await this.executeWorldMemoryQuery(`CALL CREATE_FTS_INDEX('${WM_DERIVED_TABLE}', '${WM_DERIVED_FTS_INDEX}', ['searchText'], ` +
|
|
1698
|
+
"stemmer := 'none', tokenizer := 'simple');", duringRollback);
|
|
1699
|
+
this.memoryDerivedFtsIndexBuilt = true;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
async dropMemoryEpisodeIndexes(duringRollback = false) {
|
|
1703
|
+
if (this.memoryEpisodeVectorIndexBuilt) {
|
|
1704
|
+
await this.executeWorldMemoryQuery(`CALL DROP_VECTOR_INDEX('${WM_EPISODE_TABLE}', '${WM_EPISODE_VECTOR_INDEX}');`, duringRollback);
|
|
1705
|
+
this.memoryEpisodeVectorIndexBuilt = false;
|
|
1706
|
+
}
|
|
1707
|
+
if (this.memoryEpisodeFtsIndexBuilt) {
|
|
1708
|
+
await this.executeWorldMemoryQuery(`CALL DROP_FTS_INDEX('${WM_EPISODE_TABLE}', '${WM_EPISODE_FTS_INDEX}');`, duringRollback);
|
|
1709
|
+
this.memoryEpisodeFtsIndexBuilt = false;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
async dropMemoryDerivedIndexes(duringRollback = false) {
|
|
1713
|
+
if (this.memoryDerivedVectorIndexBuilt) {
|
|
1714
|
+
await this.executeWorldMemoryQuery(`CALL DROP_VECTOR_INDEX('${WM_DERIVED_TABLE}', '${WM_DERIVED_VECTOR_INDEX}');`, duringRollback);
|
|
1715
|
+
this.memoryDerivedVectorIndexBuilt = false;
|
|
1716
|
+
}
|
|
1717
|
+
if (this.memoryDerivedFtsIndexBuilt) {
|
|
1718
|
+
await this.executeWorldMemoryQuery(`CALL DROP_FTS_INDEX('${WM_DERIVED_TABLE}', '${WM_DERIVED_FTS_INDEX}');`, duringRollback);
|
|
1719
|
+
this.memoryDerivedFtsIndexBuilt = false;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
async rebuildMemoryEpisodeIndexes(duringRollback = false) {
|
|
1723
|
+
if (!this.memoryEpisodeVectorIndexBuilt) {
|
|
1724
|
+
await this.executeWorldMemoryQuery(`CALL CREATE_VECTOR_INDEX('${WM_EPISODE_TABLE}', '${WM_EPISODE_VECTOR_INDEX}', ` +
|
|
1725
|
+
"'embedding', metric := 'cosine');", duringRollback);
|
|
1726
|
+
this.memoryEpisodeVectorIndexBuilt = true;
|
|
1727
|
+
}
|
|
1728
|
+
if (!this.memoryEpisodeFtsIndexBuilt) {
|
|
1729
|
+
await this.executeWorldMemoryQuery(`CALL CREATE_FTS_INDEX('${WM_EPISODE_TABLE}', '${WM_EPISODE_FTS_INDEX}', ['searchText'], ` +
|
|
1730
|
+
"stemmer := 'none', tokenizer := 'simple');", duringRollback);
|
|
1731
|
+
this.memoryEpisodeFtsIndexBuilt = true;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
async rebuildMemoryDerivedIndexes(duringRollback = false) {
|
|
1735
|
+
if (!this.memoryDerivedVectorIndexBuilt) {
|
|
1736
|
+
await this.executeWorldMemoryQuery(`CALL CREATE_VECTOR_INDEX('${WM_DERIVED_TABLE}', '${WM_DERIVED_VECTOR_INDEX}', ` +
|
|
1737
|
+
"'embedding', metric := 'cosine');", duringRollback);
|
|
1738
|
+
this.memoryDerivedVectorIndexBuilt = true;
|
|
1739
|
+
}
|
|
1740
|
+
if (!this.memoryDerivedFtsIndexBuilt) {
|
|
1741
|
+
await this.executeWorldMemoryQuery(`CALL CREATE_FTS_INDEX('${WM_DERIVED_TABLE}', '${WM_DERIVED_FTS_INDEX}', ['searchText'], ` +
|
|
1742
|
+
"stemmer := 'none', tokenizer := 'simple');", duringRollback);
|
|
1743
|
+
this.memoryDerivedFtsIndexBuilt = true;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
toMemoryEpisode(row) {
|
|
1747
|
+
const embedding = Array.isArray(row.embedding)
|
|
1748
|
+
? row.embedding.map((value) => (typeof value === "number" ? value : Number(value)))
|
|
1749
|
+
: [];
|
|
1750
|
+
return Object.freeze({
|
|
1751
|
+
id: assertMemoryIdentifier("episode.id", asString(row.id)),
|
|
1752
|
+
sourceMessageIds: decodeCanonicalStringIds("episode.source_message_ids", row.sourceMessageIds, false),
|
|
1753
|
+
turnBundleId: assertMemoryIdentifier("episode.turn_bundle_id", asString(row.turnBundleId)),
|
|
1754
|
+
synopsis: assertMemorySynopsis(asString(row.synopsis)),
|
|
1755
|
+
embedding: snapshotMemoryEmbedding(embedding),
|
|
1756
|
+
sourceDigest: assertMemoryIdentifier("episode.source_digest", asString(row.sourceDigest)),
|
|
1757
|
+
worldTimeFrom: assertMemoryInteger("episode.world_time_from", asNumber(row.worldTimeFrom), true),
|
|
1758
|
+
worldTimeTo: row.worldTimeTo == null
|
|
1759
|
+
? null
|
|
1760
|
+
: assertMemoryNullableInteger("episode.world_time_to", asNumber(row.worldTimeTo)),
|
|
1761
|
+
recordedWriteSeq: assertMemoryInteger("episode.recorded_write_seq", asNumber(row.recordedWriteSeq)),
|
|
1762
|
+
reachability: assertMemoryIdentifier("episode.reachability", asString(row.reachability)),
|
|
1763
|
+
model: assertMemoryIdentifier("episode.model", asString(row.model)),
|
|
1764
|
+
provider: assertMemoryIdentifier("episode.provider", asString(row.provider)),
|
|
1765
|
+
attemptId: assertMemoryIdentifier("episode.attempt_id", asString(row.attemptId)),
|
|
1766
|
+
schemaVersion: assertMemorySchemaVersion(asNumber(row.schemaVersion))
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
toMemoryClaimRoot(row) {
|
|
1770
|
+
const entityId = asNullableString(row.entityId);
|
|
1771
|
+
const attrPath = asNullableString(row.attrPath);
|
|
1772
|
+
const relationKey = asNullableString(row.relationKey);
|
|
1773
|
+
const currentClaimId = asNullableString(row.currentClaimId);
|
|
1774
|
+
return Object.freeze({
|
|
1775
|
+
id: assertMemoryIdentifier("claim_root.id", asString(row.id)),
|
|
1776
|
+
subjectKind: assertMemoryIdentifier("claim_root.subject_kind", asString(row.subjectKind)),
|
|
1777
|
+
entityId: entityId === null ? null : assertMemoryIdentifier("claim_root.entity_id", entityId),
|
|
1778
|
+
attrPath: attrPath === null ? null : assertMemoryIdentifier("claim_root.attr_path", attrPath),
|
|
1779
|
+
relationKey: relationKey === null
|
|
1780
|
+
? null
|
|
1781
|
+
: assertMemoryIdentifier("claim_root.relation_key", relationKey),
|
|
1782
|
+
currentClaimId: currentClaimId === null
|
|
1783
|
+
? null
|
|
1784
|
+
: assertMemoryIdentifier("claim_root.current_claim_id", currentClaimId),
|
|
1785
|
+
latestRevision: assertMemoryInteger("claim_root.latest_revision", asNumber(row.latestRevision)),
|
|
1786
|
+
schemaVersion: assertMemorySchemaVersion(asNumber(row.schemaVersion))
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
toMemoryClaim(row) {
|
|
1790
|
+
const authorityClass = asString(row.authorityClass);
|
|
1791
|
+
if (authorityClass !== "world_fact") {
|
|
1792
|
+
throw new Error("world memory claim authority_class must be world_fact");
|
|
1793
|
+
}
|
|
1794
|
+
return Object.freeze({
|
|
1795
|
+
id: assertMemoryIdentifier("claim.id", asString(row.id)),
|
|
1796
|
+
rootId: assertMemoryIdentifier("claim.root_id", asString(row.rootId)),
|
|
1797
|
+
authorityClass,
|
|
1798
|
+
status: assertMemoryIdentifier("claim.status", asString(row.status)),
|
|
1799
|
+
canonicalValue: assertCanonicalJson(asString(row.canonicalValue)),
|
|
1800
|
+
canonicalValueSha256: assertMemoryIdentifier("claim.canonical_value_sha256", asString(row.canonicalValueSha256)),
|
|
1801
|
+
confidence: assertMemoryConfidence(asNumber(row.confidence)),
|
|
1802
|
+
recordedFromWriteSeq: assertMemoryInteger("claim.recorded_from_write_seq", asNumber(row.recordedFromWriteSeq)),
|
|
1803
|
+
recordedToWriteSeq: row.recordedToWriteSeq == null
|
|
1804
|
+
? null
|
|
1805
|
+
: assertMemoryNullableInteger("claim.recorded_to_write_seq", asNumber(row.recordedToWriteSeq)),
|
|
1806
|
+
validFromWorldSeconds: assertMemoryInteger("claim.valid_from_world_seconds", asNumber(row.validFromWorldSeconds), true),
|
|
1807
|
+
validToWorldSeconds: row.validToWorldSeconds == null
|
|
1808
|
+
? null
|
|
1809
|
+
: assertMemoryNullableInteger("claim.valid_to_world_seconds", asNumber(row.validToWorldSeconds)),
|
|
1810
|
+
sourceDigest: assertMemoryIdentifier("claim.source_digest", asString(row.sourceDigest)),
|
|
1811
|
+
sourceEpisodeIds: decodeCanonicalStringIds("claim.source_episode_ids", row.sourceEpisodeIds, true),
|
|
1812
|
+
sourceMessageIds: decodeCanonicalStringIds("claim.source_message_ids", row.sourceMessageIds, true),
|
|
1813
|
+
revision: assertMemoryInteger("claim.revision", asNumber(row.revision)),
|
|
1814
|
+
schemaVersion: assertMemorySchemaVersion(asNumber(row.schemaVersion))
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
toMemoryDerived(row) {
|
|
1818
|
+
const derivedKind = asString(row.derivedKind);
|
|
1819
|
+
if (!["observation", "experience", "summary"].includes(derivedKind)) {
|
|
1820
|
+
throw new Error(`unknown world memory derived kind ${derivedKind}`);
|
|
1821
|
+
}
|
|
1822
|
+
const authorityClass = asString(row.authorityClass);
|
|
1823
|
+
if (authorityClass !== derivedKind) {
|
|
1824
|
+
throw new Error("world memory derived authority_class must equal derived_kind");
|
|
1825
|
+
}
|
|
1826
|
+
const embedding = Array.isArray(row.embedding)
|
|
1827
|
+
? row.embedding.map((value) => (typeof value === "number" ? value : Number(value)))
|
|
1828
|
+
: [];
|
|
1829
|
+
return Object.freeze({
|
|
1830
|
+
id: assertMemoryIdentifier("derived.id", asString(row.id)),
|
|
1831
|
+
derivedKind,
|
|
1832
|
+
text: assertMemoryIdentifier("derived.text", asString(row.text)),
|
|
1833
|
+
embedding: snapshotMemoryEmbedding(embedding),
|
|
1834
|
+
authorityClass,
|
|
1835
|
+
status: assertMemoryIdentifier("derived.status", asString(row.status)),
|
|
1836
|
+
sourceDigest: assertMemoryIdentifier("derived.source_digest", asString(row.sourceDigest)),
|
|
1837
|
+
coverageIds: decodeCanonicalStringIds("derived.coverage_ids", row.coverageIds, false),
|
|
1838
|
+
summarizedThroughOpId: asNullableString(row.summarizedThroughOpId),
|
|
1839
|
+
validFromWorldSeconds: assertMemoryInteger("derived.valid_from_world_seconds", asNumber(row.validFromWorldSeconds), true),
|
|
1840
|
+
validToWorldSeconds: row.validToWorldSeconds == null
|
|
1841
|
+
? null
|
|
1842
|
+
: assertMemoryNullableInteger("derived.valid_to_world_seconds", asNumber(row.validToWorldSeconds)),
|
|
1843
|
+
recordedWriteSeq: assertMemoryInteger("derived.recorded_write_seq", asNumber(row.recordedWriteSeq)),
|
|
1844
|
+
model: assertMemoryIdentifier("derived.model", asString(row.model)),
|
|
1845
|
+
provider: assertMemoryIdentifier("derived.provider", asString(row.provider)),
|
|
1846
|
+
promptVersion: assertMemoryIdentifier("derived.prompt_version", asString(row.promptVersion)),
|
|
1847
|
+
tokenizerVersion: assertMemoryIdentifier("derived.tokenizer_version", asString(row.tokenizerVersion)),
|
|
1848
|
+
schemaVersion: assertMemorySchemaVersion(asNumber(row.schemaVersion))
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
async listMemoryEpisodes(duringRollback = false) {
|
|
1852
|
+
const statement = `MATCH (e:${WM_EPISODE_TABLE}) RETURN ` +
|
|
1853
|
+
"e.id AS id, e.sourceMessageIds AS sourceMessageIds, e.turnBundleId AS turnBundleId, " +
|
|
1854
|
+
"e.synopsis AS synopsis, e.embedding AS embedding, e.sourceDigest AS sourceDigest, " +
|
|
1855
|
+
"e.worldTimeFrom AS worldTimeFrom, e.worldTimeTo AS worldTimeTo, " +
|
|
1856
|
+
"e.recordedWriteSeq AS recordedWriteSeq, e.reachability AS reachability, " +
|
|
1857
|
+
"e.model AS model, e.provider AS provider, e.attemptId AS attemptId, " +
|
|
1858
|
+
"e.schemaVersion AS schemaVersion ORDER BY id;";
|
|
1859
|
+
const rows = duringRollback
|
|
1860
|
+
? await this.queryDuringRollback(statement)
|
|
1861
|
+
: await this.runWorldMemory(statement, {});
|
|
1862
|
+
return rows.map((row) => this.toMemoryEpisode(row));
|
|
1863
|
+
}
|
|
1864
|
+
async listMemoryClaimRoots(duringRollback = false) {
|
|
1865
|
+
const statement = `MATCH (r:${WM_CLAIM_ROOT_TABLE}) RETURN ` +
|
|
1866
|
+
"r.id AS id, r.subjectKind AS subjectKind, r.entityId AS entityId, r.attrPath AS attrPath, " +
|
|
1867
|
+
"r.relationKey AS relationKey, r.currentClaimId AS currentClaimId, " +
|
|
1868
|
+
"r.latestRevision AS latestRevision, r.schemaVersion AS schemaVersion ORDER BY id;";
|
|
1869
|
+
const rows = duringRollback
|
|
1870
|
+
? await this.queryDuringRollback(statement)
|
|
1871
|
+
: await this.runWorldMemory(statement, {});
|
|
1872
|
+
return rows.map((row) => this.toMemoryClaimRoot(row));
|
|
1873
|
+
}
|
|
1874
|
+
memoryClaimProjection(alias) {
|
|
1875
|
+
return `${alias}.id AS id, ${alias}.rootId AS rootId, ${alias}.authorityClass AS authorityClass, ` +
|
|
1876
|
+
`${alias}.status AS status, ${alias}.canonicalValue AS canonicalValue, ` +
|
|
1877
|
+
`${alias}.canonicalValueSha256 AS canonicalValueSha256, ${alias}.confidence AS confidence, ` +
|
|
1878
|
+
`${alias}.recordedFromWriteSeq AS recordedFromWriteSeq, ` +
|
|
1879
|
+
`${alias}.recordedToWriteSeq AS recordedToWriteSeq, ` +
|
|
1880
|
+
`${alias}.validFromWorldSeconds AS validFromWorldSeconds, ` +
|
|
1881
|
+
`${alias}.validToWorldSeconds AS validToWorldSeconds, ${alias}.sourceDigest AS sourceDigest, ` +
|
|
1882
|
+
`${alias}.sourceEpisodeIds AS sourceEpisodeIds, ${alias}.sourceMessageIds AS sourceMessageIds, ` +
|
|
1883
|
+
`${alias}.revision AS revision, ${alias}.schemaVersion AS schemaVersion`;
|
|
1884
|
+
}
|
|
1885
|
+
async listMemoryClaims(duringRollback = false) {
|
|
1886
|
+
const statement = `MATCH (c:${WM_CLAIM_TABLE}) RETURN ${this.memoryClaimProjection("c")} ` +
|
|
1887
|
+
"ORDER BY rootId, revision, id;";
|
|
1888
|
+
const rows = duringRollback
|
|
1889
|
+
? await this.queryDuringRollback(statement)
|
|
1890
|
+
: await this.runWorldMemory(statement, {});
|
|
1891
|
+
return rows.map((row) => this.toMemoryClaim(row));
|
|
1892
|
+
}
|
|
1893
|
+
async listMemoryDerived(duringRollback = false) {
|
|
1894
|
+
const statement = `MATCH (d:${WM_DERIVED_TABLE}) RETURN ` +
|
|
1895
|
+
"d.id AS id, d.derivedKind AS derivedKind, d.text AS text, d.embedding AS embedding, " +
|
|
1896
|
+
"d.authorityClass AS authorityClass, d.status AS status, d.sourceDigest AS sourceDigest, " +
|
|
1897
|
+
"d.coverageIds AS coverageIds, d.summarizedThroughOpId AS summarizedThroughOpId, " +
|
|
1898
|
+
"d.validFromWorldSeconds AS validFromWorldSeconds, d.validToWorldSeconds AS validToWorldSeconds, " +
|
|
1899
|
+
"d.recordedWriteSeq AS recordedWriteSeq, d.model AS model, d.provider AS provider, " +
|
|
1900
|
+
"d.promptVersion AS promptVersion, d.tokenizerVersion AS tokenizerVersion, " +
|
|
1901
|
+
"d.schemaVersion AS schemaVersion ORDER BY id;";
|
|
1902
|
+
const rows = duringRollback
|
|
1903
|
+
? await this.queryDuringRollback(statement)
|
|
1904
|
+
: await this.runWorldMemory(statement, {});
|
|
1905
|
+
return rows.map((row) => this.toMemoryDerived(row));
|
|
1906
|
+
}
|
|
1907
|
+
async listMemoryRelations(duringRollback = false) {
|
|
1908
|
+
const relations = [];
|
|
1909
|
+
for (const [kind, definition] of Object.entries(MEMORY_RELATION_DEFINITIONS)) {
|
|
1910
|
+
const statement = `MATCH (s:${definition.sourceTable})-[r:${kind}]->` +
|
|
1911
|
+
`(d:${definition.targetTable}) RETURN s.id AS sourceId, d.id AS targetId ` +
|
|
1912
|
+
"ORDER BY sourceId, targetId;";
|
|
1913
|
+
const rows = duringRollback
|
|
1914
|
+
? await this.queryDuringRollback(statement)
|
|
1915
|
+
: await this.runWorldMemory(statement, {});
|
|
1916
|
+
for (const row of rows) {
|
|
1917
|
+
relations.push(Object.freeze({
|
|
1918
|
+
kind: assertMemoryRelationKind(kind),
|
|
1919
|
+
sourceId: assertMemoryIdentifier("relation.source_id", asString(row.sourceId)),
|
|
1920
|
+
targetId: assertMemoryIdentifier("relation.target_id", asString(row.targetId))
|
|
1921
|
+
}));
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
return relations;
|
|
1925
|
+
}
|
|
1926
|
+
async snapshotWorldMemory(requireComplete = true) {
|
|
1927
|
+
const snapshot = this.normalizeWorldMemorySnapshot({
|
|
1928
|
+
episodes: Object.freeze(await this.listMemoryEpisodes()),
|
|
1929
|
+
claimRoots: Object.freeze(await this.listMemoryClaimRoots()),
|
|
1930
|
+
claims: Object.freeze(await this.listMemoryClaims()),
|
|
1931
|
+
derived: Object.freeze(await this.listMemoryDerived()),
|
|
1932
|
+
relations: Object.freeze(await this.listMemoryRelations()),
|
|
1933
|
+
episodeVectorIndexBuilt: this.memoryEpisodeVectorIndexBuilt,
|
|
1934
|
+
episodeFtsIndexBuilt: this.memoryEpisodeFtsIndexBuilt,
|
|
1935
|
+
derivedVectorIndexBuilt: this.memoryDerivedVectorIndexBuilt,
|
|
1936
|
+
derivedFtsIndexBuilt: this.memoryDerivedFtsIndexBuilt
|
|
1937
|
+
}, requireComplete);
|
|
1938
|
+
await this.assertWorldMemoryExternalEntityReferences(snapshot);
|
|
1939
|
+
return snapshot;
|
|
1940
|
+
}
|
|
1941
|
+
async recordWorldMemoryUndo() {
|
|
1942
|
+
if (this.tx === null) {
|
|
1943
|
+
throw new Error("world memory mutation requires an active Ladybug graph transaction");
|
|
1944
|
+
}
|
|
1945
|
+
await this.recordWorldMemoryUndoIfTransaction();
|
|
1946
|
+
}
|
|
1947
|
+
async recordWorldMemoryUndoIfTransaction() {
|
|
1948
|
+
if (this.tx === null)
|
|
1949
|
+
return;
|
|
1950
|
+
this.tx.ops.push({
|
|
1951
|
+
kind: "world-memory-restore",
|
|
1952
|
+
snapshot: await this.snapshotWorldMemory(false)
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
normalizeMemoryEpisode(row) {
|
|
1956
|
+
return this.toMemoryEpisode({
|
|
1957
|
+
...row,
|
|
1958
|
+
sourceMessageIds: encodeCanonicalStringIds("episode.source_message_ids", row.sourceMessageIds, false),
|
|
1959
|
+
embedding: [...row.embedding]
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
normalizeMemoryClaimRoot(row) {
|
|
1963
|
+
const normalized = this.toMemoryClaimRoot({ ...row });
|
|
1964
|
+
const attributeSubject = normalized.entityId !== null
|
|
1965
|
+
&& normalized.attrPath !== null
|
|
1966
|
+
&& normalized.relationKey === null;
|
|
1967
|
+
const relationSubject = normalized.attrPath === null
|
|
1968
|
+
&& normalized.relationKey !== null;
|
|
1969
|
+
if (!attributeSubject && !relationSubject) {
|
|
1970
|
+
throw new Error("world memory claim root must identify exactly one attribute or relation subject");
|
|
1971
|
+
}
|
|
1972
|
+
if ((attributeSubject && normalized.subjectKind !== "attribute")
|
|
1973
|
+
|| (relationSubject && normalized.subjectKind !== "relation")) {
|
|
1974
|
+
throw new Error("world memory claim root subject_kind does not match its typed subject");
|
|
1975
|
+
}
|
|
1976
|
+
return normalized;
|
|
1977
|
+
}
|
|
1978
|
+
normalizeMemoryClaim(row) {
|
|
1979
|
+
const normalized = this.toMemoryClaim({
|
|
1980
|
+
...row,
|
|
1981
|
+
sourceEpisodeIds: encodeCanonicalStringIds("claim.source_episode_ids", row.sourceEpisodeIds, true),
|
|
1982
|
+
sourceMessageIds: encodeCanonicalStringIds("claim.source_message_ids", row.sourceMessageIds, true)
|
|
1983
|
+
});
|
|
1984
|
+
if (normalized.sourceEpisodeIds.length === 0 && normalized.sourceMessageIds.length === 0) {
|
|
1985
|
+
throw new Error("world memory claim must have at least one source episode or message");
|
|
1986
|
+
}
|
|
1987
|
+
if (normalized.recordedToWriteSeq !== null
|
|
1988
|
+
&& normalized.recordedToWriteSeq < normalized.recordedFromWriteSeq) {
|
|
1989
|
+
throw new Error("world memory claim recorded interval is inverted");
|
|
1990
|
+
}
|
|
1991
|
+
if (normalized.validToWorldSeconds !== null
|
|
1992
|
+
&& normalized.validToWorldSeconds < normalized.validFromWorldSeconds) {
|
|
1993
|
+
throw new Error("world memory claim valid interval is inverted");
|
|
1994
|
+
}
|
|
1995
|
+
return normalized;
|
|
1996
|
+
}
|
|
1997
|
+
normalizeMemoryDerived(row) {
|
|
1998
|
+
const normalized = this.toMemoryDerived({
|
|
1999
|
+
...row,
|
|
2000
|
+
coverageIds: encodeCanonicalStringIds("derived.coverage_ids", row.coverageIds, false),
|
|
2001
|
+
embedding: [...row.embedding]
|
|
2002
|
+
});
|
|
2003
|
+
if (normalized.authorityClass !== normalized.derivedKind) {
|
|
2004
|
+
throw new Error("world memory derived authority_class must equal derived_kind");
|
|
2005
|
+
}
|
|
2006
|
+
if (normalized.validToWorldSeconds !== null
|
|
2007
|
+
&& normalized.validToWorldSeconds < normalized.validFromWorldSeconds) {
|
|
2008
|
+
throw new Error("world memory derived valid interval is inverted");
|
|
2009
|
+
}
|
|
2010
|
+
return normalized;
|
|
2011
|
+
}
|
|
2012
|
+
normalizeMemoryRelation(row) {
|
|
2013
|
+
const kind = assertMemoryRelationKind(row.kind);
|
|
2014
|
+
const sourceId = assertMemoryIdentifier("relation.source_id", row.sourceId);
|
|
2015
|
+
const targetId = assertMemoryIdentifier("relation.target_id", row.targetId);
|
|
2016
|
+
if ((kind === "WmClaimSupersedesClaim" || kind === "WmClaimContradictsClaim")
|
|
2017
|
+
&& sourceId === targetId) {
|
|
2018
|
+
throw new Error("world memory claim relation must not be self-referential");
|
|
2019
|
+
}
|
|
2020
|
+
if (kind === "WmClaimContradictsClaim" && sourceId >= targetId) {
|
|
2021
|
+
throw new Error("world memory contradiction relation must use smaller claim id as source");
|
|
2022
|
+
}
|
|
2023
|
+
return Object.freeze({ kind, sourceId, targetId });
|
|
2024
|
+
}
|
|
2025
|
+
normalizeWorldMemorySnapshot(snapshot, requireComplete = true) {
|
|
2026
|
+
if (typeof snapshot !== "object" || snapshot === null) {
|
|
2027
|
+
throw new Error("world memory snapshot must be an object");
|
|
2028
|
+
}
|
|
2029
|
+
for (const field of ["episodes", "claimRoots", "claims", "derived", "relations"]) {
|
|
2030
|
+
if (!Array.isArray(snapshot[field])) {
|
|
2031
|
+
throw new Error(`world memory snapshot ${field} must be an array`);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
for (const field of [
|
|
2035
|
+
"episodeVectorIndexBuilt",
|
|
2036
|
+
"episodeFtsIndexBuilt",
|
|
2037
|
+
"derivedVectorIndexBuilt",
|
|
2038
|
+
"derivedFtsIndexBuilt"
|
|
2039
|
+
]) {
|
|
2040
|
+
if (typeof snapshot[field] !== "boolean") {
|
|
2041
|
+
throw new Error(`world memory snapshot ${field} must be boolean`);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
const episodes = snapshot.episodes.map((row) => this.normalizeMemoryEpisode(row));
|
|
2045
|
+
const claimRoots = snapshot.claimRoots.map((row) => this.normalizeMemoryClaimRoot(row));
|
|
2046
|
+
const claims = snapshot.claims.map((row) => this.normalizeMemoryClaim(row));
|
|
2047
|
+
const derived = snapshot.derived.map((row) => this.normalizeMemoryDerived(row));
|
|
2048
|
+
const relations = snapshot.relations.map((row) => this.normalizeMemoryRelation(row));
|
|
2049
|
+
const unique = (label, rows) => {
|
|
2050
|
+
const ids = new Set();
|
|
2051
|
+
for (const row of rows) {
|
|
2052
|
+
if (ids.has(row.id))
|
|
2053
|
+
throw new Error(`world memory snapshot duplicate ${label} ${row.id}`);
|
|
2054
|
+
ids.add(row.id);
|
|
2055
|
+
}
|
|
2056
|
+
return ids;
|
|
2057
|
+
};
|
|
2058
|
+
const episodeIds = unique("episode", episodes);
|
|
2059
|
+
const claimRootIds = unique("claim root", claimRoots);
|
|
2060
|
+
const claimIds = unique("claim", claims);
|
|
2061
|
+
const derivedIds = unique("derived", derived);
|
|
2062
|
+
const episodeTurnBundles = new Set();
|
|
2063
|
+
for (const episode of episodes) {
|
|
2064
|
+
if (episode.recordedWriteSeq === 0) {
|
|
2065
|
+
throw new Error(`world memory snapshot episode write sequence is zero for ${episode.id}`);
|
|
2066
|
+
}
|
|
2067
|
+
if (episode.worldTimeTo !== null && episode.worldTimeTo < episode.worldTimeFrom) {
|
|
2068
|
+
throw new Error(`world memory snapshot episode interval inverted for ${episode.id}`);
|
|
2069
|
+
}
|
|
2070
|
+
if (episodeTurnBundles.has(episode.turnBundleId)) {
|
|
2071
|
+
throw new Error(`world memory snapshot duplicate turn bundle ${episode.turnBundleId}`);
|
|
2072
|
+
}
|
|
2073
|
+
episodeTurnBundles.add(episode.turnBundleId);
|
|
2074
|
+
}
|
|
2075
|
+
const claimsByRoot = new Map();
|
|
2076
|
+
const claimRootRevisions = new Set();
|
|
2077
|
+
for (const claim of claims) {
|
|
2078
|
+
if (requireComplete && !claimRootIds.has(claim.rootId)) {
|
|
2079
|
+
throw new Error(`world memory snapshot claim root missing for ${claim.id}`);
|
|
2080
|
+
}
|
|
2081
|
+
if (claim.status !== "active" && claim.status !== "retracted") {
|
|
2082
|
+
throw new Error(`world memory snapshot claim status invalid for ${claim.id}`);
|
|
2083
|
+
}
|
|
2084
|
+
if (claim.recordedFromWriteSeq === 0 || claim.revision === 0) {
|
|
2085
|
+
throw new Error(`world memory snapshot claim sequence or revision is zero for ${claim.id}`);
|
|
2086
|
+
}
|
|
2087
|
+
for (const episodeId of claim.sourceEpisodeIds) {
|
|
2088
|
+
if (requireComplete && !episodeIds.has(episodeId)) {
|
|
2089
|
+
throw new Error(`world memory snapshot claim source episode missing for ${claim.id}`);
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
if (claim.sourceEpisodeIds.length === 0 && claim.sourceMessageIds.length === 0) {
|
|
2093
|
+
throw new Error(`world memory snapshot claim sources empty for ${claim.id}`);
|
|
2094
|
+
}
|
|
2095
|
+
const revisionKey = `${claim.rootId}\u0000${claim.revision}`;
|
|
2096
|
+
if (claimRootRevisions.has(revisionKey)) {
|
|
2097
|
+
throw new Error(`world memory snapshot duplicate claim revision ${claim.rootId}:${claim.revision}`);
|
|
2098
|
+
}
|
|
2099
|
+
claimRootRevisions.add(revisionKey);
|
|
2100
|
+
const rootClaims = claimsByRoot.get(claim.rootId) ?? [];
|
|
2101
|
+
rootClaims.push(claim);
|
|
2102
|
+
claimsByRoot.set(claim.rootId, rootClaims);
|
|
2103
|
+
}
|
|
2104
|
+
if (requireComplete) {
|
|
2105
|
+
for (const root of claimRoots) {
|
|
2106
|
+
const rootClaims = claimsByRoot.get(root.id) ?? [];
|
|
2107
|
+
const latestRevision = rootClaims.reduce((latest, claim) => Math.max(latest, claim.revision), 0);
|
|
2108
|
+
if (root.latestRevision !== latestRevision) {
|
|
2109
|
+
throw new Error(`world memory snapshot claim root latest revision mismatch for ${root.id}`);
|
|
2110
|
+
}
|
|
2111
|
+
if (root.currentClaimId !== null) {
|
|
2112
|
+
const current = rootClaims.find((claim) => claim.id === root.currentClaimId);
|
|
2113
|
+
if (current === undefined
|
|
2114
|
+
|| current.status !== "active"
|
|
2115
|
+
|| current.revision !== latestRevision) {
|
|
2116
|
+
throw new Error(`world memory snapshot claim root current claim invalid for ${root.id}`);
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
for (const row of derived) {
|
|
2122
|
+
if (row.status !== "active" && row.status !== "retracted") {
|
|
2123
|
+
throw new Error(`world memory snapshot derived status invalid for ${row.id}`);
|
|
2124
|
+
}
|
|
2125
|
+
if (row.coverageIds.length === 0) {
|
|
2126
|
+
throw new Error(`world memory snapshot derived coverage empty for ${row.id}`);
|
|
2127
|
+
}
|
|
2128
|
+
if (row.recordedWriteSeq === 0) {
|
|
2129
|
+
throw new Error(`world memory snapshot derived write sequence is zero for ${row.id}`);
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
const endpointExists = (kind, source, id) => {
|
|
2133
|
+
switch (kind) {
|
|
2134
|
+
case "WmEpisodeSupportsClaim": return source ? episodeIds.has(id) : claimIds.has(id);
|
|
2135
|
+
case "WmClaimSupersedesClaim":
|
|
2136
|
+
case "WmClaimContradictsClaim": return claimIds.has(id);
|
|
2137
|
+
case "WmDerivedFromEpisode": return source ? derivedIds.has(id) : episodeIds.has(id);
|
|
2138
|
+
case "WmDerivedFromClaim": return source ? derivedIds.has(id) : claimIds.has(id);
|
|
2139
|
+
case "WmEpisodeMentionsEntity": return source ? episodeIds.has(id) : true;
|
|
2140
|
+
case "WmDerivedMentionsEntity": return source ? derivedIds.has(id) : true;
|
|
2141
|
+
case "WmClaimAboutEntity": return source ? claimIds.has(id) : true;
|
|
2142
|
+
}
|
|
2143
|
+
};
|
|
2144
|
+
const relationKeys = new Set();
|
|
2145
|
+
for (const relation of relations) {
|
|
2146
|
+
if (requireComplete
|
|
2147
|
+
&& (!endpointExists(relation.kind, true, relation.sourceId)
|
|
2148
|
+
|| !endpointExists(relation.kind, false, relation.targetId))) {
|
|
2149
|
+
throw new Error(`world memory snapshot relation endpoint missing ${relation.kind}:${relation.sourceId}:${relation.targetId}`);
|
|
2150
|
+
}
|
|
2151
|
+
const key = `${relation.kind}\u0000${relation.sourceId}\u0000${relation.targetId}`;
|
|
2152
|
+
if (relationKeys.has(key))
|
|
2153
|
+
throw new Error(`world memory snapshot duplicate relation ${key}`);
|
|
2154
|
+
relationKeys.add(key);
|
|
2155
|
+
}
|
|
2156
|
+
const compareId = (left, right) => left.id.localeCompare(right.id, "und");
|
|
2157
|
+
const compareClaim = (left, right) => left.rootId.localeCompare(right.rootId, "und")
|
|
2158
|
+
|| left.revision - right.revision
|
|
2159
|
+
|| left.id.localeCompare(right.id, "und");
|
|
2160
|
+
const compareRelation = (left, right) => left.kind.localeCompare(right.kind, "und")
|
|
2161
|
+
|| left.sourceId.localeCompare(right.sourceId, "und")
|
|
2162
|
+
|| left.targetId.localeCompare(right.targetId, "und");
|
|
2163
|
+
return Object.freeze({
|
|
2164
|
+
episodes: Object.freeze([...episodes].sort(compareId)),
|
|
2165
|
+
claimRoots: Object.freeze([...claimRoots].sort(compareId)),
|
|
2166
|
+
claims: Object.freeze([...claims].sort(compareClaim)),
|
|
2167
|
+
derived: Object.freeze([...derived].sort(compareId)),
|
|
2168
|
+
relations: Object.freeze([...relations].sort(compareRelation)),
|
|
2169
|
+
episodeVectorIndexBuilt: snapshot.episodeVectorIndexBuilt,
|
|
2170
|
+
episodeFtsIndexBuilt: snapshot.episodeFtsIndexBuilt,
|
|
2171
|
+
derivedVectorIndexBuilt: snapshot.derivedVectorIndexBuilt,
|
|
2172
|
+
derivedFtsIndexBuilt: snapshot.derivedFtsIndexBuilt
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
async executeWorldMemoryRun(statement, params, duringRollback = false) {
|
|
2176
|
+
return duringRollback
|
|
2177
|
+
? this.runDuringRollback(statement, params)
|
|
2178
|
+
: this.runWorldMemory(statement, params);
|
|
2179
|
+
}
|
|
2180
|
+
async writeMemoryEpisode(row, duringRollback = false) {
|
|
2181
|
+
const normalized = this.normalizeMemoryEpisode(row);
|
|
2182
|
+
if (normalized.worldTimeTo !== null
|
|
2183
|
+
&& normalized.worldTimeTo < normalized.worldTimeFrom) {
|
|
2184
|
+
throw new Error("world memory episode world-time interval is inverted");
|
|
2185
|
+
}
|
|
2186
|
+
const conflicts = await this.executeWorldMemoryRun(`MATCH (e:${WM_EPISODE_TABLE} {turnBundleId: $turnBundleId}) ` +
|
|
2187
|
+
"WHERE e.id <> $id RETURN e.id AS id LIMIT 1;", { turnBundleId: normalized.turnBundleId, id: normalized.id }, duringRollback);
|
|
2188
|
+
if (conflicts.length > 0)
|
|
2189
|
+
throw new Error("world memory turn already has a different episode");
|
|
2190
|
+
await this.executeWorldMemoryRun(`MERGE (e:${WM_EPISODE_TABLE} {id: $id}) SET ` +
|
|
2191
|
+
"e.sourceMessageIds = $sourceMessageIds, e.turnBundleId = $turnBundleId, " +
|
|
2192
|
+
"e.synopsis = $synopsis, e.searchText = $searchText, " +
|
|
2193
|
+
"e.embedding = $embedding, e.sourceDigest = $sourceDigest, " +
|
|
2194
|
+
"e.worldTimeFrom = $worldTimeFrom, e.worldTimeTo = $worldTimeTo, " +
|
|
2195
|
+
"e.recordedWriteSeq = $recordedWriteSeq, e.reachability = $reachability, " +
|
|
2196
|
+
"e.model = $model, e.provider = $provider, e.attemptId = $attemptId, " +
|
|
2197
|
+
"e.schemaVersion = $schemaVersion;", {
|
|
2198
|
+
...normalized,
|
|
2199
|
+
sourceMessageIds: JSON.stringify(normalized.sourceMessageIds),
|
|
2200
|
+
searchText: buildIndexedSearchText(normalized.synopsis),
|
|
2201
|
+
embedding: [...normalized.embedding]
|
|
2202
|
+
}, duringRollback);
|
|
2203
|
+
}
|
|
2204
|
+
async writeMemoryClaimRoot(row, duringRollback = false) {
|
|
2205
|
+
const normalized = this.normalizeMemoryClaimRoot(row);
|
|
2206
|
+
if (normalized.currentClaimId !== null) {
|
|
2207
|
+
const linked = await this.executeWorldMemoryRun(`MATCH (c:${WM_CLAIM_TABLE} {id: $claimId, rootId: $rootId}) RETURN c.id AS id;`, { claimId: normalized.currentClaimId, rootId: normalized.id }, duringRollback);
|
|
2208
|
+
if (linked.length !== 1) {
|
|
2209
|
+
throw new Error("world memory claim root current_claim_id is not a claim in this root");
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
await this.executeWorldMemoryRun(`MERGE (r:${WM_CLAIM_ROOT_TABLE} {id: $id}) SET ` +
|
|
2213
|
+
"r.subjectKind = $subjectKind, r.entityId = $entityId, r.attrPath = $attrPath, " +
|
|
2214
|
+
"r.relationKey = $relationKey, r.currentClaimId = $currentClaimId, " +
|
|
2215
|
+
"r.latestRevision = $latestRevision, r.schemaVersion = $schemaVersion;", { ...normalized }, duringRollback);
|
|
2216
|
+
}
|
|
2217
|
+
async writeMemoryClaim(row, duringRollback = false) {
|
|
2218
|
+
const normalized = this.normalizeMemoryClaim(row);
|
|
2219
|
+
const digest = await sha256HexUtf8(normalized.canonicalValue);
|
|
2220
|
+
if (digest !== normalized.canonicalValueSha256) {
|
|
2221
|
+
throw new Error("world memory canonical_value_sha256 mismatch");
|
|
2222
|
+
}
|
|
2223
|
+
const roots = await this.executeWorldMemoryRun(`MATCH (r:${WM_CLAIM_ROOT_TABLE} {id: $rootId}) RETURN r.id AS id;`, { rootId: normalized.rootId }, duringRollback);
|
|
2224
|
+
if (roots.length !== 1)
|
|
2225
|
+
throw new Error("world memory claim root does not exist");
|
|
2226
|
+
const revisionConflicts = await this.executeWorldMemoryRun(`MATCH (c:${WM_CLAIM_TABLE} {rootId: $rootId, revision: $revision}) ` +
|
|
2227
|
+
"WHERE c.id <> $id RETURN c.id AS id LIMIT 1;", { rootId: normalized.rootId, revision: normalized.revision, id: normalized.id }, duringRollback);
|
|
2228
|
+
if (revisionConflicts.length > 0) {
|
|
2229
|
+
throw new Error("world memory claim root already has a different claim at this revision");
|
|
2230
|
+
}
|
|
2231
|
+
await this.executeWorldMemoryRun(`MERGE (c:${WM_CLAIM_TABLE} {id: $id}) SET ` +
|
|
2232
|
+
"c.rootId = $rootId, c.authorityClass = $authorityClass, c.status = $status, " +
|
|
2233
|
+
"c.canonicalValue = $canonicalValue, c.canonicalValueSha256 = $canonicalValueSha256, " +
|
|
2234
|
+
"c.confidence = $confidence, c.recordedFromWriteSeq = $recordedFromWriteSeq, " +
|
|
2235
|
+
"c.recordedToWriteSeq = $recordedToWriteSeq, " +
|
|
2236
|
+
"c.validFromWorldSeconds = $validFromWorldSeconds, c.validToWorldSeconds = $validToWorldSeconds, " +
|
|
2237
|
+
"c.sourceDigest = $sourceDigest, c.sourceEpisodeIds = $sourceEpisodeIds, " +
|
|
2238
|
+
"c.sourceMessageIds = $sourceMessageIds, c.revision = $revision, " +
|
|
2239
|
+
"c.schemaVersion = $schemaVersion;", {
|
|
2240
|
+
...normalized,
|
|
2241
|
+
sourceEpisodeIds: JSON.stringify(normalized.sourceEpisodeIds),
|
|
2242
|
+
sourceMessageIds: JSON.stringify(normalized.sourceMessageIds)
|
|
2243
|
+
}, duringRollback);
|
|
2244
|
+
}
|
|
2245
|
+
async writeMemoryDerived(row, duringRollback = false) {
|
|
2246
|
+
const normalized = this.normalizeMemoryDerived(row);
|
|
2247
|
+
await this.executeWorldMemoryRun(`MERGE (d:${WM_DERIVED_TABLE} {id: $id}) SET ` +
|
|
2248
|
+
"d.derivedKind = $derivedKind, d.text = $text, d.searchText = $searchText, " +
|
|
2249
|
+
"d.embedding = $embedding, " +
|
|
2250
|
+
"d.authorityClass = $authorityClass, d.status = $status, d.sourceDigest = $sourceDigest, " +
|
|
2251
|
+
"d.coverageIds = $coverageIds, d.summarizedThroughOpId = $summarizedThroughOpId, " +
|
|
2252
|
+
"d.validFromWorldSeconds = $validFromWorldSeconds, d.validToWorldSeconds = $validToWorldSeconds, " +
|
|
2253
|
+
"d.recordedWriteSeq = $recordedWriteSeq, d.model = $model, d.provider = $provider, " +
|
|
2254
|
+
"d.promptVersion = $promptVersion, d.tokenizerVersion = $tokenizerVersion, " +
|
|
2255
|
+
"d.schemaVersion = $schemaVersion;", {
|
|
2256
|
+
...normalized,
|
|
2257
|
+
coverageIds: JSON.stringify(normalized.coverageIds),
|
|
2258
|
+
searchText: buildIndexedSearchText(normalized.text),
|
|
2259
|
+
embedding: [...normalized.embedding]
|
|
2260
|
+
}, duringRollback);
|
|
2261
|
+
}
|
|
2262
|
+
async writeMemoryRelation(row, duringRollback = false) {
|
|
2263
|
+
const { kind, sourceId, targetId } = this.normalizeMemoryRelation(row);
|
|
2264
|
+
const definition = MEMORY_RELATION_DEFINITIONS[kind];
|
|
2265
|
+
const rows = await this.executeWorldMemoryRun(`MATCH (s:${definition.sourceTable} {id: $sourceId}), ` +
|
|
2266
|
+
`(d:${definition.targetTable} {id: $targetId}) ` +
|
|
2267
|
+
`MERGE (s)-[r:${kind}]->(d) RETURN s.id AS sourceId, d.id AS targetId;`, { sourceId, targetId }, duringRollback);
|
|
2268
|
+
if (rows.length !== 1)
|
|
2269
|
+
throw new Error(`world memory ${kind} endpoint is missing`);
|
|
2270
|
+
}
|
|
2271
|
+
async assertWorldMemoryExternalEntityReferences(snapshot, duringRollback = false) {
|
|
2272
|
+
const requiredIds = new Set();
|
|
2273
|
+
for (const root of snapshot.claimRoots) {
|
|
2274
|
+
if (root.entityId !== null)
|
|
2275
|
+
requiredIds.add(root.entityId);
|
|
2276
|
+
}
|
|
2277
|
+
for (const relation of snapshot.relations) {
|
|
2278
|
+
if (relation.kind === "WmEpisodeMentionsEntity"
|
|
2279
|
+
|| relation.kind === "WmDerivedMentionsEntity"
|
|
2280
|
+
|| relation.kind === "WmClaimAboutEntity") {
|
|
2281
|
+
requiredIds.add(relation.targetId);
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
if (requiredIds.size === 0)
|
|
2285
|
+
return;
|
|
2286
|
+
const ids = [...requiredIds].sort((left, right) => left.localeCompare(right, "und"));
|
|
2287
|
+
const statement = `UNWIND $ids AS id MATCH (e:${ENTITY_TABLE} {id: id}) RETURN e.id AS id ORDER BY id;`;
|
|
2288
|
+
const rows = duringRollback
|
|
2289
|
+
? await this.runDuringRollback(statement, { ids })
|
|
2290
|
+
: await this.runInternal(statement, { ids });
|
|
2291
|
+
const found = new Set(rows.map((row) => asString(row.id)));
|
|
2292
|
+
const missing = ids.filter((id) => !found.has(id));
|
|
2293
|
+
if (missing.length > 0) {
|
|
2294
|
+
throw new Error(`world memory snapshot entity references missing: ${missing.join(",")}`);
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
async replaceWorldMemorySnapshot(input, duringRollback = false, requireComplete = true) {
|
|
2298
|
+
const snapshot = this.normalizeWorldMemorySnapshot(input, requireComplete);
|
|
2299
|
+
await Promise.all(snapshot.claims.map(async (claim) => {
|
|
2300
|
+
const digest = await sha256HexUtf8(claim.canonicalValue);
|
|
2301
|
+
if (digest !== claim.canonicalValueSha256) {
|
|
2302
|
+
throw new Error(`world memory canonical_value_sha256 mismatch for ${claim.id}`);
|
|
2303
|
+
}
|
|
2304
|
+
}));
|
|
2305
|
+
await this.assertWorldMemoryExternalEntityReferences(snapshot, duringRollback);
|
|
2306
|
+
await this.dropMemoryEpisodeIndexes(duringRollback);
|
|
2307
|
+
await this.dropMemoryDerivedIndexes(duringRollback);
|
|
2308
|
+
const query = async (statement) => {
|
|
2309
|
+
if (duringRollback)
|
|
2310
|
+
await this.queryDuringRollback(statement);
|
|
2311
|
+
else
|
|
2312
|
+
await this.queryWorldMemory(statement);
|
|
2313
|
+
};
|
|
2314
|
+
const run = async (statement, params) => {
|
|
2315
|
+
if (duringRollback)
|
|
2316
|
+
await this.runDuringRollback(statement, params);
|
|
2317
|
+
else
|
|
2318
|
+
await this.runWorldMemory(statement, params);
|
|
2319
|
+
};
|
|
2320
|
+
await query(`MATCH (n:${WM_CLAIM_ROOT_TABLE}) DETACH DELETE n;`);
|
|
2321
|
+
await query(`MATCH (n:${WM_CLAIM_TABLE}) DETACH DELETE n;`);
|
|
2322
|
+
await query(`MATCH (n:${WM_EPISODE_TABLE}) DETACH DELETE n;`);
|
|
2323
|
+
await query(`MATCH (n:${WM_DERIVED_TABLE}) DETACH DELETE n;`);
|
|
2324
|
+
if (snapshot.episodes.length > 0) {
|
|
2325
|
+
await run(`UNWIND $rows AS row CREATE (e:${WM_EPISODE_TABLE} {` +
|
|
2326
|
+
"id: row.id, sourceMessageIds: row.sourceMessageIds, turnBundleId: row.turnBundleId, " +
|
|
2327
|
+
"synopsis: row.synopsis, searchText: row.searchText, " +
|
|
2328
|
+
"embedding: row.embedding, sourceDigest: row.sourceDigest, " +
|
|
2329
|
+
"worldTimeFrom: row.worldTimeFrom, worldTimeTo: row.worldTimeTo, " +
|
|
2330
|
+
"recordedWriteSeq: row.recordedWriteSeq, reachability: row.reachability, " +
|
|
2331
|
+
"model: row.model, provider: row.provider, attemptId: row.attemptId, " +
|
|
2332
|
+
"schemaVersion: row.schemaVersion});", {
|
|
2333
|
+
rows: snapshot.episodes.map((row) => ({
|
|
2334
|
+
...row,
|
|
2335
|
+
sourceMessageIds: JSON.stringify(row.sourceMessageIds),
|
|
2336
|
+
searchText: buildIndexedSearchText(row.synopsis),
|
|
2337
|
+
embedding: [...row.embedding]
|
|
2338
|
+
}))
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
if (snapshot.claimRoots.length > 0) {
|
|
2342
|
+
await run(`UNWIND $rows AS row CREATE (r:${WM_CLAIM_ROOT_TABLE} {` +
|
|
2343
|
+
"id: row.id, subjectKind: row.subjectKind, entityId: row.entityId, " +
|
|
2344
|
+
"attrPath: row.attrPath, relationKey: row.relationKey, currentClaimId: NULL, " +
|
|
2345
|
+
"latestRevision: row.latestRevision, schemaVersion: row.schemaVersion});", { rows: snapshot.claimRoots.map((row) => ({ ...row })) });
|
|
2346
|
+
}
|
|
2347
|
+
if (snapshot.claims.length > 0) {
|
|
2348
|
+
await run(`UNWIND $rows AS row CREATE (c:${WM_CLAIM_TABLE} {` +
|
|
2349
|
+
"id: row.id, rootId: row.rootId, authorityClass: row.authorityClass, status: row.status, " +
|
|
2350
|
+
"canonicalValue: row.canonicalValue, canonicalValueSha256: row.canonicalValueSha256, " +
|
|
2351
|
+
"confidence: row.confidence, recordedFromWriteSeq: row.recordedFromWriteSeq, " +
|
|
2352
|
+
"recordedToWriteSeq: row.recordedToWriteSeq, validFromWorldSeconds: row.validFromWorldSeconds, " +
|
|
2353
|
+
"validToWorldSeconds: row.validToWorldSeconds, sourceDigest: row.sourceDigest, " +
|
|
2354
|
+
"sourceEpisodeIds: row.sourceEpisodeIds, sourceMessageIds: row.sourceMessageIds, " +
|
|
2355
|
+
"revision: row.revision, schemaVersion: row.schemaVersion});", {
|
|
2356
|
+
rows: snapshot.claims.map((row) => ({
|
|
2357
|
+
...row,
|
|
2358
|
+
sourceEpisodeIds: JSON.stringify(row.sourceEpisodeIds),
|
|
2359
|
+
sourceMessageIds: JSON.stringify(row.sourceMessageIds)
|
|
2360
|
+
}))
|
|
2361
|
+
});
|
|
2362
|
+
}
|
|
2363
|
+
if (snapshot.derived.length > 0) {
|
|
2364
|
+
await run(`UNWIND $rows AS row CREATE (d:${WM_DERIVED_TABLE} {` +
|
|
2365
|
+
"id: row.id, derivedKind: row.derivedKind, text: row.text, searchText: row.searchText, " +
|
|
2366
|
+
"embedding: row.embedding, " +
|
|
2367
|
+
"authorityClass: row.authorityClass, status: row.status, sourceDigest: row.sourceDigest, " +
|
|
2368
|
+
"coverageIds: row.coverageIds, summarizedThroughOpId: row.summarizedThroughOpId, " +
|
|
2369
|
+
"validFromWorldSeconds: row.validFromWorldSeconds, validToWorldSeconds: row.validToWorldSeconds, " +
|
|
2370
|
+
"recordedWriteSeq: row.recordedWriteSeq, model: row.model, provider: row.provider, " +
|
|
2371
|
+
"promptVersion: row.promptVersion, tokenizerVersion: row.tokenizerVersion, " +
|
|
2372
|
+
"schemaVersion: row.schemaVersion});", {
|
|
2373
|
+
rows: snapshot.derived.map((row) => ({
|
|
2374
|
+
...row,
|
|
2375
|
+
searchText: buildIndexedSearchText(row.text),
|
|
2376
|
+
embedding: [...row.embedding],
|
|
2377
|
+
coverageIds: JSON.stringify(row.coverageIds)
|
|
2378
|
+
}))
|
|
2379
|
+
});
|
|
2380
|
+
}
|
|
2381
|
+
for (const [kind, definition] of Object.entries(MEMORY_RELATION_DEFINITIONS)) {
|
|
2382
|
+
const rows = snapshot.relations
|
|
2383
|
+
.filter((row) => row.kind === kind)
|
|
2384
|
+
.map((row) => ({ sourceId: row.sourceId, targetId: row.targetId }));
|
|
2385
|
+
if (rows.length === 0)
|
|
2386
|
+
continue;
|
|
2387
|
+
await run(`UNWIND $rows AS row MATCH (s:${definition.sourceTable} {id: row.sourceId}), ` +
|
|
2388
|
+
`(d:${definition.targetTable} {id: row.targetId}) ` +
|
|
2389
|
+
`CREATE (s)-[:${kind}]->(d);`, { rows });
|
|
2390
|
+
}
|
|
2391
|
+
if (snapshot.claimRoots.length > 0) {
|
|
2392
|
+
await run(`UNWIND $rows AS row MATCH (r:${WM_CLAIM_ROOT_TABLE} {id: row.id}) ` +
|
|
2393
|
+
"SET r.currentClaimId = row.currentClaimId;", { rows: snapshot.claimRoots.map((row) => ({ id: row.id, currentClaimId: row.currentClaimId })) });
|
|
2394
|
+
}
|
|
2395
|
+
if (snapshot.episodeVectorIndexBuilt) {
|
|
2396
|
+
await this.executeWorldMemoryQuery(`CALL CREATE_VECTOR_INDEX('${WM_EPISODE_TABLE}', '${WM_EPISODE_VECTOR_INDEX}', ` +
|
|
2397
|
+
"'embedding', metric := 'cosine');", duringRollback);
|
|
2398
|
+
this.memoryEpisodeVectorIndexBuilt = true;
|
|
2399
|
+
}
|
|
2400
|
+
if (snapshot.episodeFtsIndexBuilt) {
|
|
2401
|
+
await this.executeWorldMemoryQuery(`CALL CREATE_FTS_INDEX('${WM_EPISODE_TABLE}', '${WM_EPISODE_FTS_INDEX}', ['searchText'], ` +
|
|
2402
|
+
"stemmer := 'none', tokenizer := 'simple');", duringRollback);
|
|
2403
|
+
this.memoryEpisodeFtsIndexBuilt = true;
|
|
2404
|
+
}
|
|
2405
|
+
if (snapshot.derivedVectorIndexBuilt) {
|
|
2406
|
+
await this.executeWorldMemoryQuery(`CALL CREATE_VECTOR_INDEX('${WM_DERIVED_TABLE}', '${WM_DERIVED_VECTOR_INDEX}', ` +
|
|
2407
|
+
"'embedding', metric := 'cosine');", duringRollback);
|
|
2408
|
+
this.memoryDerivedVectorIndexBuilt = true;
|
|
2409
|
+
}
|
|
2410
|
+
if (snapshot.derivedFtsIndexBuilt) {
|
|
2411
|
+
await this.executeWorldMemoryQuery(`CALL CREATE_FTS_INDEX('${WM_DERIVED_TABLE}', '${WM_DERIVED_FTS_INDEX}', ['searchText'], ` +
|
|
2412
|
+
"stemmer := 'none', tokenizer := 'simple');", duringRollback);
|
|
2413
|
+
this.memoryDerivedFtsIndexBuilt = true;
|
|
2414
|
+
}
|
|
2415
|
+
return snapshot;
|
|
2416
|
+
}
|
|
2417
|
+
async restoreWorldMemorySnapshot(snapshot) {
|
|
2418
|
+
await this.replaceWorldMemorySnapshot(snapshot, true, false);
|
|
2419
|
+
}
|
|
2420
|
+
async restoreWorldMemorySnapshotInTransaction(snapshot) {
|
|
2421
|
+
if (this.tx === null || this.graphRollbackInProgress) {
|
|
2422
|
+
throw new Error("world memory transaction restore requires active graph transaction");
|
|
2423
|
+
}
|
|
2424
|
+
await this.recordWorldMemoryUndo();
|
|
2425
|
+
const expected = await this.replaceWorldMemorySnapshot(snapshot);
|
|
2426
|
+
const readback = await this.snapshotWorldMemory();
|
|
2427
|
+
if (JSON.stringify(readback) !== JSON.stringify(expected)) {
|
|
2428
|
+
throw new Error("world memory transaction snapshot immutable readback mismatch");
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
async restoreWorldMemorySnapshotFromHost(snapshot) {
|
|
2432
|
+
if (this.tx !== null || this.graphRollbackInProgress) {
|
|
2433
|
+
throw new Error("world memory snapshot restore requires exclusive transaction ownership");
|
|
2434
|
+
}
|
|
2435
|
+
this.beginTransaction();
|
|
2436
|
+
try {
|
|
2437
|
+
await this.recordWorldMemoryUndo();
|
|
2438
|
+
const expected = await this.replaceWorldMemorySnapshot(snapshot);
|
|
2439
|
+
const readback = await this.snapshotWorldMemory();
|
|
2440
|
+
if (JSON.stringify(readback) !== JSON.stringify(expected)) {
|
|
2441
|
+
throw new Error("world memory snapshot immutable readback mismatch");
|
|
2442
|
+
}
|
|
2443
|
+
this.commitTransaction();
|
|
2444
|
+
}
|
|
2445
|
+
catch (cause) {
|
|
2446
|
+
try {
|
|
2447
|
+
await this.rollbackTransaction();
|
|
2448
|
+
}
|
|
2449
|
+
catch (rollbackCause) {
|
|
2450
|
+
throw new AggregateError([cause, rollbackCause], "world memory snapshot restore and rollback both failed");
|
|
2451
|
+
}
|
|
2452
|
+
throw cause;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
async upsertMemoryEpisode(row) {
|
|
2456
|
+
await this.recordWorldMemoryUndo();
|
|
2457
|
+
await this.dropMemoryEpisodeIndexes();
|
|
2458
|
+
await this.writeMemoryEpisode(row);
|
|
2459
|
+
await this.rebuildMemoryEpisodeIndexes();
|
|
2460
|
+
}
|
|
2461
|
+
async upsertMemoryClaimRoot(row) {
|
|
2462
|
+
await this.recordWorldMemoryUndo();
|
|
2463
|
+
await this.writeMemoryClaimRoot(row);
|
|
2464
|
+
}
|
|
2465
|
+
async upsertMemoryClaim(row) {
|
|
2466
|
+
await this.recordWorldMemoryUndo();
|
|
2467
|
+
await this.writeMemoryClaim(row);
|
|
2468
|
+
}
|
|
2469
|
+
async upsertMemoryDerived(row) {
|
|
2470
|
+
await this.recordWorldMemoryUndo();
|
|
2471
|
+
await this.dropMemoryDerivedIndexes();
|
|
2472
|
+
await this.writeMemoryDerived(row);
|
|
2473
|
+
await this.rebuildMemoryDerivedIndexes();
|
|
2474
|
+
}
|
|
2475
|
+
async upsertMemoryRelation(row) {
|
|
2476
|
+
await this.recordWorldMemoryUndo();
|
|
2477
|
+
await this.writeMemoryRelation(row);
|
|
2478
|
+
}
|
|
2479
|
+
async getClaimRoot(id) {
|
|
2480
|
+
const claimRootId = assertMemoryIdentifier("claim_root.id", id);
|
|
2481
|
+
const rows = await this.runWorldMemory(`MATCH (r:${WM_CLAIM_ROOT_TABLE} {id: $id}) RETURN ` +
|
|
2482
|
+
"r.id AS id, r.subjectKind AS subjectKind, r.entityId AS entityId, r.attrPath AS attrPath, " +
|
|
2483
|
+
"r.relationKey AS relationKey, r.currentClaimId AS currentClaimId, " +
|
|
2484
|
+
"r.latestRevision AS latestRevision, r.schemaVersion AS schemaVersion;", { id: claimRootId });
|
|
2485
|
+
if (rows.length > 1)
|
|
2486
|
+
throw new Error("world memory claim root primary key returned multiple rows");
|
|
2487
|
+
return rows.length === 0 ? null : this.toMemoryClaimRoot(rows[0]);
|
|
2488
|
+
}
|
|
2489
|
+
async getClaimsByRoot(rootId) {
|
|
2490
|
+
const id = assertMemoryIdentifier("claim.root_id", rootId);
|
|
2491
|
+
const rows = await this.runWorldMemory(`MATCH (c:${WM_CLAIM_TABLE} {rootId: $rootId}) ` +
|
|
2492
|
+
`RETURN ${this.memoryClaimProjection("c")} ORDER BY revision, id;`, { rootId: id });
|
|
2493
|
+
return rows.map((row) => this.toMemoryClaim(row));
|
|
2494
|
+
}
|
|
2495
|
+
async resolveClaimsForEntities(entityIds) {
|
|
2496
|
+
const ids = snapshotCanonicalStringIds("entity_ids", entityIds, true);
|
|
2497
|
+
if (ids.length === 0)
|
|
2498
|
+
return [];
|
|
2499
|
+
const rows = await this.runWorldMemory(`UNWIND $entityIds AS entityId MATCH (c:${WM_CLAIM_TABLE})-` +
|
|
2500
|
+
`[:WmClaimAboutEntity]->(e:${ENTITY_TABLE} {id: entityId}) ` +
|
|
2501
|
+
`RETURN ${this.memoryClaimProjection("c")} ORDER BY rootId, revision, id;`, { entityIds: [...ids] });
|
|
2502
|
+
const claims = new Map();
|
|
2503
|
+
for (const row of rows) {
|
|
2504
|
+
const claim = this.toMemoryClaim(row);
|
|
2505
|
+
claims.set(claim.id, claim);
|
|
2506
|
+
}
|
|
2507
|
+
return [...claims.values()];
|
|
2508
|
+
}
|
|
2509
|
+
async vectorSearchEpisodes(query, topK, config) {
|
|
2510
|
+
assertTopK(topK);
|
|
2511
|
+
const embedding = snapshotMemoryEmbedding(query);
|
|
2512
|
+
if (!this.memoryEpisodeVectorIndexBuilt) {
|
|
2513
|
+
throw new Error("world memory episode vector index is unavailable");
|
|
2514
|
+
}
|
|
2515
|
+
const efs = clampInt(config?.efs, HNSW_EFS_MIN, HNSW_EFS_MAX);
|
|
2516
|
+
const efsArg = efs !== undefined ? `, efs := ${efs}` : "";
|
|
2517
|
+
const rows = await this.runWorldMemory(`CALL QUERY_VECTOR_INDEX('${WM_EPISODE_TABLE}', '${WM_EPISODE_VECTOR_INDEX}', ` +
|
|
2518
|
+
`$query, $topK${efsArg}) YIELD node AS memory, distance ` +
|
|
2519
|
+
"RETURN memory.id AS id, distance ORDER BY distance;", { query: [...embedding], topK });
|
|
2520
|
+
return rows.map((row) => Object.freeze({
|
|
2521
|
+
id: asString(row.id),
|
|
2522
|
+
distance: asNumber(row.distance)
|
|
2523
|
+
}));
|
|
2524
|
+
}
|
|
2525
|
+
async fullTextSearchEpisodes(query, topK, config) {
|
|
2526
|
+
assertTopK(topK);
|
|
2527
|
+
assertMemoryIdentifier("episode.full_text_query", query);
|
|
2528
|
+
if (!this.memoryEpisodeFtsIndexBuilt) {
|
|
2529
|
+
throw new Error("world memory episode FTS index is unavailable");
|
|
2530
|
+
}
|
|
2531
|
+
const rows = await this.runWorldMemory(`CALL QUERY_FTS_INDEX('${WM_EPISODE_TABLE}', '${WM_EPISODE_FTS_INDEX}', ` +
|
|
2532
|
+
`$query${this.buildFtsQueryArgs(config)}) ` +
|
|
2533
|
+
"RETURN node.id AS id, score ORDER BY score DESC LIMIT $topK;", { query: buildIndexedSearchText(query), topK });
|
|
2534
|
+
return rows.map((row) => Object.freeze({ id: asString(row.id), score: asNumber(row.score) }));
|
|
2535
|
+
}
|
|
2536
|
+
async vectorSearchDerived(query, topK, config) {
|
|
2537
|
+
assertTopK(topK);
|
|
2538
|
+
const embedding = snapshotMemoryEmbedding(query);
|
|
2539
|
+
if (!this.memoryDerivedVectorIndexBuilt) {
|
|
2540
|
+
throw new Error("world memory derived vector index is unavailable");
|
|
2541
|
+
}
|
|
2542
|
+
const efs = clampInt(config?.efs, HNSW_EFS_MIN, HNSW_EFS_MAX);
|
|
2543
|
+
const efsArg = efs !== undefined ? `, efs := ${efs}` : "";
|
|
2544
|
+
const rows = await this.runWorldMemory(`CALL QUERY_VECTOR_INDEX('${WM_DERIVED_TABLE}', '${WM_DERIVED_VECTOR_INDEX}', ` +
|
|
2545
|
+
`$query, $topK${efsArg}) YIELD node AS memory, distance ` +
|
|
2546
|
+
"RETURN memory.id AS id, distance ORDER BY distance;", { query: [...embedding], topK });
|
|
2547
|
+
return rows.map((row) => Object.freeze({
|
|
2548
|
+
id: asString(row.id),
|
|
2549
|
+
distance: asNumber(row.distance)
|
|
2550
|
+
}));
|
|
2551
|
+
}
|
|
2552
|
+
async fullTextSearchDerived(query, topK, config) {
|
|
2553
|
+
assertTopK(topK);
|
|
2554
|
+
assertMemoryIdentifier("derived.full_text_query", query);
|
|
2555
|
+
if (!this.memoryDerivedFtsIndexBuilt) {
|
|
2556
|
+
throw new Error("world memory derived FTS index is unavailable");
|
|
2557
|
+
}
|
|
2558
|
+
const rows = await this.runWorldMemory(`CALL QUERY_FTS_INDEX('${WM_DERIVED_TABLE}', '${WM_DERIVED_FTS_INDEX}', ` +
|
|
2559
|
+
`$query${this.buildFtsQueryArgs(config)}) ` +
|
|
2560
|
+
"RETURN node.id AS id, score ORDER BY score DESC LIMIT $topK;", { query: buildIndexedSearchText(query), topK });
|
|
2561
|
+
return rows.map((row) => Object.freeze({ id: asString(row.id), score: asNumber(row.score) }));
|
|
2562
|
+
}
|
|
2563
|
+
async deleteMemoryClosure(closure) {
|
|
2564
|
+
const episodeIds = snapshotMemoryClosureIds("closure.episode_ids", closure.episodeIds);
|
|
2565
|
+
const claimRootIds = snapshotMemoryClosureIds("closure.claim_root_ids", closure.claimRootIds);
|
|
2566
|
+
const claimIds = snapshotMemoryClosureIds("closure.claim_ids", closure.claimIds);
|
|
2567
|
+
const derivedIds = snapshotMemoryClosureIds("closure.derived_ids", closure.derivedIds);
|
|
2568
|
+
if (episodeIds.length + claimRootIds.length + claimIds.length + derivedIds.length === 0) {
|
|
2569
|
+
return { deletedEpisodes: 0, deletedClaimRoots: 0, deletedClaims: 0, deletedDerived: 0 };
|
|
2570
|
+
}
|
|
2571
|
+
await this.recordWorldMemoryUndo();
|
|
2572
|
+
if (episodeIds.length > 0)
|
|
2573
|
+
await this.dropMemoryEpisodeIndexes();
|
|
2574
|
+
if (derivedIds.length > 0)
|
|
2575
|
+
await this.dropMemoryDerivedIndexes();
|
|
2576
|
+
const count = async (table, ids) => {
|
|
2577
|
+
if (ids.length === 0)
|
|
2578
|
+
return 0;
|
|
2579
|
+
const rows = await this.runWorldMemory(`UNWIND $ids AS id MATCH (n:${table} {id: id}) RETURN count(n) AS n;`, { ids: [...ids] });
|
|
2580
|
+
return rows.length === 0 ? 0 : asNumber(rows[0].n);
|
|
2581
|
+
};
|
|
2582
|
+
const deletedEpisodes = await count(WM_EPISODE_TABLE, episodeIds);
|
|
2583
|
+
const deletedClaimRoots = await count(WM_CLAIM_ROOT_TABLE, claimRootIds);
|
|
2584
|
+
const deletedClaims = await count(WM_CLAIM_TABLE, claimIds);
|
|
2585
|
+
const deletedDerived = await count(WM_DERIVED_TABLE, derivedIds);
|
|
2586
|
+
if (claimIds.length > 0) {
|
|
2587
|
+
await this.runWorldMemory(`UNWIND $ids AS id MATCH (r:${WM_CLAIM_ROOT_TABLE}) ` +
|
|
2588
|
+
"WHERE r.currentClaimId = id SET r.currentClaimId = NULL;", { ids: [...claimIds] });
|
|
2589
|
+
}
|
|
2590
|
+
for (const [table, ids] of [
|
|
2591
|
+
[WM_DERIVED_TABLE, derivedIds],
|
|
2592
|
+
[WM_CLAIM_TABLE, claimIds],
|
|
2593
|
+
[WM_CLAIM_ROOT_TABLE, claimRootIds],
|
|
2594
|
+
[WM_EPISODE_TABLE, episodeIds]
|
|
2595
|
+
]) {
|
|
2596
|
+
if (ids.length === 0)
|
|
2597
|
+
continue;
|
|
2598
|
+
await this.runWorldMemory(`UNWIND $ids AS id MATCH (n:${table} {id: id}) DETACH DELETE n;`, { ids: [...ids] });
|
|
2599
|
+
}
|
|
2600
|
+
if (episodeIds.length > 0)
|
|
2601
|
+
await this.rebuildMemoryEpisodeIndexes();
|
|
2602
|
+
if (derivedIds.length > 0)
|
|
2603
|
+
await this.rebuildMemoryDerivedIndexes();
|
|
2604
|
+
return { deletedEpisodes, deletedClaimRoots, deletedClaims, deletedDerived };
|
|
2605
|
+
}
|
|
2606
|
+
async transcriptPk(scope, messageId) {
|
|
2607
|
+
return sha256HexUtf8(`ladybug-transcript-message-v1\u0000${JSON.stringify([
|
|
2608
|
+
scope.scenarioId,
|
|
2609
|
+
scope.saveId,
|
|
2610
|
+
scope.worldlineId,
|
|
2611
|
+
messageId
|
|
2612
|
+
])}`);
|
|
2613
|
+
}
|
|
2614
|
+
toTranscriptMessage(row) {
|
|
2615
|
+
return validateTranscriptRecord({
|
|
2616
|
+
scenarioId: asString(row.scenarioId),
|
|
2617
|
+
saveId: asString(row.saveId),
|
|
2618
|
+
worldlineId: asString(row.worldlineId),
|
|
2619
|
+
messageId: asString(row.messageId),
|
|
2620
|
+
turnId: asString(row.turnId),
|
|
2621
|
+
role: assertTranscriptRole(asString(row.role)),
|
|
2622
|
+
content: asString(row.content),
|
|
2623
|
+
status: assertTranscriptStatus(asString(row.status)),
|
|
2624
|
+
createdAtMs: asNumber(row.createdAtMs),
|
|
2625
|
+
updatedAtMs: asNumber(row.updatedAtMs),
|
|
2626
|
+
sourceLedgerSeq: asNumber(row.sourceLedgerSeq)
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2629
|
+
async writeTranscriptRecord(record, duringRollback = false) {
|
|
2630
|
+
const validated = validateTranscriptRecord(record);
|
|
2631
|
+
const pk = await this.transcriptPk(validated, validated.messageId);
|
|
2632
|
+
const statement = `MERGE (m:${TRANSCRIPT_MESSAGE_TABLE} {pk: $pk}) ` +
|
|
2633
|
+
"SET m.scenarioId = $scenarioId, m.saveId = $saveId, m.worldlineId = $worldlineId, " +
|
|
2634
|
+
"m.messageId = $messageId, m.turnId = $turnId, m.role = $role, m.content = $content, " +
|
|
2635
|
+
"m.searchText = $searchText, m.status = $status, m.createdAtMs = $createdAtMs, m.updatedAtMs = $updatedAtMs, " +
|
|
2636
|
+
"m.sourceLedgerSeq = $sourceLedgerSeq;";
|
|
2637
|
+
const params = { pk, ...validated, searchText: buildIndexedSearchText(validated.content) };
|
|
2638
|
+
if (duringRollback)
|
|
2639
|
+
await this.runDuringRollback(statement, params);
|
|
2640
|
+
else
|
|
2641
|
+
await this.runInternal(statement, params);
|
|
2642
|
+
}
|
|
2643
|
+
async readTranscriptByPk(pk) {
|
|
2644
|
+
const rows = await this.runInternal(`MATCH (m:${TRANSCRIPT_MESSAGE_TABLE} {pk: $pk}) ` +
|
|
2645
|
+
"RETURN m.scenarioId AS scenarioId, m.saveId AS saveId, m.worldlineId AS worldlineId, " +
|
|
2646
|
+
"m.messageId AS messageId, m.turnId AS turnId, m.role AS role, m.content AS content, " +
|
|
2647
|
+
"m.status AS status, m.createdAtMs AS createdAtMs, m.updatedAtMs AS updatedAtMs, " +
|
|
2648
|
+
"m.sourceLedgerSeq AS sourceLedgerSeq;", { pk });
|
|
2649
|
+
if (rows.length > 1)
|
|
2650
|
+
throw new Error("transcript primary key returned multiple rows");
|
|
2651
|
+
return rows.length === 0 ? null : this.toTranscriptMessage(rows[0]);
|
|
2652
|
+
}
|
|
2653
|
+
async listTranscriptScope(scope, duringRollback = false) {
|
|
2654
|
+
assertTranscriptScope(scope);
|
|
2655
|
+
const statement = `MATCH (m:${TRANSCRIPT_MESSAGE_TABLE} {scenarioId: $scenarioId, saveId: $saveId, worldlineId: $worldlineId}) ` +
|
|
2656
|
+
"RETURN m.scenarioId AS scenarioId, m.saveId AS saveId, m.worldlineId AS worldlineId, " +
|
|
2657
|
+
"m.messageId AS messageId, m.turnId AS turnId, m.role AS role, m.content AS content, " +
|
|
2658
|
+
"m.status AS status, m.createdAtMs AS createdAtMs, m.updatedAtMs AS updatedAtMs, " +
|
|
2659
|
+
"m.sourceLedgerSeq AS sourceLedgerSeq ORDER BY sourceLedgerSeq, messageId;";
|
|
2660
|
+
const rows = duringRollback
|
|
2661
|
+
? await this.runDuringRollback(statement, { ...scope })
|
|
2662
|
+
: await this.runInternal(statement, { ...scope });
|
|
2663
|
+
const records = rows.map((row) => this.toTranscriptMessage(row));
|
|
2664
|
+
assertTranscriptRecordSetIdentities(records);
|
|
2665
|
+
return records;
|
|
2666
|
+
}
|
|
2667
|
+
async restoreTranscriptScope(scope, records, duringRollback = false) {
|
|
2668
|
+
const authorizedScope = snapshotTranscriptScope(scope);
|
|
2669
|
+
for (const record of records) {
|
|
2670
|
+
if (record.scenarioId !== authorizedScope.scenarioId
|
|
2671
|
+
|| record.saveId !== authorizedScope.saveId
|
|
2672
|
+
|| record.worldlineId !== authorizedScope.worldlineId) {
|
|
2673
|
+
throw new Error("transcript scope restore record mismatch");
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
await this.invalidateTranscriptFtsIndex(duringRollback);
|
|
2677
|
+
const deleteStatement = `MATCH (m:${TRANSCRIPT_MESSAGE_TABLE} ` +
|
|
2678
|
+
"{scenarioId: $scenarioId, saveId: $saveId, worldlineId: $worldlineId}) DETACH DELETE m;";
|
|
2679
|
+
if (duringRollback)
|
|
2680
|
+
await this.runDuringRollback(deleteStatement, { ...authorizedScope });
|
|
2681
|
+
else
|
|
2682
|
+
await this.runInternal(deleteStatement, { ...authorizedScope });
|
|
2683
|
+
for (const record of records)
|
|
2684
|
+
await this.writeTranscriptRecord(record, duringRollback);
|
|
2685
|
+
const readback = await this.listTranscriptScope(authorizedScope, duringRollback);
|
|
2686
|
+
if (readback.length !== records.length
|
|
2687
|
+
|| records.some((record) => !readback.some((candidate) => transcriptRecordMatches(candidate, record)))) {
|
|
2688
|
+
throw new Error("transcript scope restore readback mismatch");
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
async invalidateTranscriptFtsIndex(duringRollback = false) {
|
|
2692
|
+
if (!this.transcriptFtsBuilt)
|
|
2693
|
+
return;
|
|
2694
|
+
const statement = `CALL DROP_FTS_INDEX('${TRANSCRIPT_MESSAGE_TABLE}', '${TRANSCRIPT_FTS_INDEX}');`;
|
|
2695
|
+
if (duringRollback)
|
|
2696
|
+
await this.queryDuringRollback(statement);
|
|
2697
|
+
else
|
|
2698
|
+
await this.queryInternal(statement);
|
|
2699
|
+
this.transcriptFtsBuilt = false;
|
|
2700
|
+
}
|
|
2701
|
+
async ensureTranscriptFtsIndex() {
|
|
2702
|
+
if (this.transcriptFtsBuilt)
|
|
2703
|
+
return;
|
|
2704
|
+
await this.queryInternal(`CALL CREATE_FTS_INDEX('${TRANSCRIPT_MESSAGE_TABLE}', '${TRANSCRIPT_FTS_INDEX}', ['searchText'], ` +
|
|
2705
|
+
"stemmer := 'none', tokenizer := 'simple');");
|
|
2706
|
+
this.transcriptFtsBuilt = true;
|
|
2707
|
+
}
|
|
2708
|
+
async upsertTranscriptMessage(request) {
|
|
2709
|
+
const result = await this.upsertTranscriptMessages({
|
|
2710
|
+
scope: request.scope,
|
|
2711
|
+
journal: request.journal,
|
|
2712
|
+
messages: [request.message]
|
|
2713
|
+
});
|
|
2714
|
+
const record = result.messages[0];
|
|
2715
|
+
if (!record)
|
|
2716
|
+
throw new Error("transcript single upsert returned no record");
|
|
2717
|
+
return record;
|
|
2718
|
+
}
|
|
2719
|
+
async upsertTranscriptMessages(request) {
|
|
2720
|
+
return this.mutateTranscriptMessagesBatch({
|
|
2721
|
+
scope: request.scope,
|
|
2722
|
+
journal: request.journal,
|
|
2723
|
+
messages: request.messages
|
|
2724
|
+
});
|
|
2725
|
+
}
|
|
2726
|
+
/**
|
|
2727
|
+
* Applies the pending journal's suffix operation and replacement/upsert cohort
|
|
2728
|
+
* under one bridge lock. A failure restores the exact pre-operation scope;
|
|
2729
|
+
* process loss restores the last SAV-backed in-memory database checkpoint.
|
|
2730
|
+
*/
|
|
2731
|
+
async mutateTranscriptMessagesBatch(request) {
|
|
2732
|
+
const scope = snapshotTranscriptScope(request.scope);
|
|
2733
|
+
const journal = snapshotTranscriptJournalIdentity(request.journal);
|
|
2734
|
+
const records = snapshotTranscriptMutationRecords(scope, request.messages);
|
|
2735
|
+
if (records.length === 0 && request.suffix === undefined) {
|
|
2736
|
+
throw new Error("transcript mutation batch must not be empty");
|
|
2737
|
+
}
|
|
2738
|
+
const batchMessageIds = new Set();
|
|
2739
|
+
const batchLedgerSeqs = new Set();
|
|
2740
|
+
for (const record of records) {
|
|
2741
|
+
if (batchMessageIds.has(record.messageId)) {
|
|
2742
|
+
throw new Error("transcript mutation batch contains duplicate message_id");
|
|
2743
|
+
}
|
|
2744
|
+
if (batchLedgerSeqs.has(record.sourceLedgerSeq)) {
|
|
2745
|
+
throw new Error("transcript mutation batch contains duplicate source_ledger_seq");
|
|
2746
|
+
}
|
|
2747
|
+
batchMessageIds.add(record.messageId);
|
|
2748
|
+
batchLedgerSeqs.add(record.sourceLedgerSeq);
|
|
2749
|
+
}
|
|
2750
|
+
const suffix = request.suffix === undefined
|
|
2751
|
+
? null
|
|
2752
|
+
: snapshotTranscriptSuffixSpec(request.suffix);
|
|
2753
|
+
return this.withTranscriptLock(async () => this.withTranscriptOperationIsolation(async () => {
|
|
2754
|
+
const mutationPayloadSha256 = await transcriptMutationPayloadSha256FromRecords(scope, records, suffix);
|
|
2755
|
+
const authorityBefore = await this.resolveTranscriptAuthority(scope, "mutation", journal, records.map((record) => record.messageId));
|
|
2756
|
+
if (authorityBefore.mutationPayloadSha256 !== mutationPayloadSha256) {
|
|
2757
|
+
throw new Error("transcript mutation payload does not match journal authority");
|
|
2758
|
+
}
|
|
2759
|
+
if (authorityBefore.requiredUpserts.length !== records.length
|
|
2760
|
+
|| records.some((record, index) => {
|
|
2761
|
+
const required = authorityBefore.requiredUpserts[index];
|
|
2762
|
+
return required === undefined
|
|
2763
|
+
|| required.messageId !== record.messageId
|
|
2764
|
+
|| required.sourceLedgerSeq !== record.sourceLedgerSeq;
|
|
2765
|
+
})) {
|
|
2766
|
+
throw new Error("transcript mutation batch does not match journal-sealed upserts");
|
|
2767
|
+
}
|
|
2768
|
+
if (!sameTranscriptSuffixSpec(authorityBefore.suffix, suffix)) {
|
|
2769
|
+
throw new Error("transcript mutation suffix does not match journal authority");
|
|
2770
|
+
}
|
|
2771
|
+
const before = await this.listTranscriptScope(scope);
|
|
2772
|
+
await this.assertTranscriptReachabilityCoverage(scope, authorityBefore.activeReachableMessageIds, batchMessageIds, before);
|
|
2773
|
+
const authorizedMutationSet = new Set(authorityBefore.authorizedMutationMessageIds);
|
|
2774
|
+
const beforeByMessageId = new Map(before.map((record) => [record.messageId, record]));
|
|
2775
|
+
const beforeLedgerOwner = new Map(before.map((record) => [record.sourceLedgerSeq, record.messageId]));
|
|
2776
|
+
for (const record of records) {
|
|
2777
|
+
const original = beforeByMessageId.get(record.messageId);
|
|
2778
|
+
if (original !== undefined && !transcriptIdentityMatches(original, record)) {
|
|
2779
|
+
throw new Error("transcript immutable first-ledger identity mismatch");
|
|
2780
|
+
}
|
|
2781
|
+
const ledgerOwner = beforeLedgerOwner.get(record.sourceLedgerSeq);
|
|
2782
|
+
if (ledgerOwner !== undefined && ledgerOwner !== record.messageId) {
|
|
2783
|
+
throw new Error("transcript source_ledger_seq identity collision");
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
let affected = [];
|
|
2787
|
+
if (suffix !== null) {
|
|
2788
|
+
if (suffix.afterSourceLedgerSeq > 0) {
|
|
2789
|
+
const pivotMatches = before.filter((record) => authorizedMutationSet.has(record.messageId)
|
|
2790
|
+
&& record.sourceLedgerSeq === suffix.afterSourceLedgerSeq);
|
|
2791
|
+
if (pivotMatches.length !== 1) {
|
|
2792
|
+
throw new Error("transcript suffix pivot is not uniquely journal-authorized");
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
affected = before.filter((record) => authorizedMutationSet.has(record.messageId)
|
|
2796
|
+
&& record.sourceLedgerSeq > suffix.afterSourceLedgerSeq
|
|
2797
|
+
&& (suffix.mode === "delete" || record.status !== "tombstoned"));
|
|
2798
|
+
if (affected.some((record) => suffix.updatedAtMs < record.updatedAtMs)) {
|
|
2799
|
+
throw new Error("transcript suffix updated_at_ms regression");
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
const simulated = new Map(before.map((record) => [record.messageId, record]));
|
|
2803
|
+
if (suffix !== null) {
|
|
2804
|
+
for (const record of affected) {
|
|
2805
|
+
if (suffix.mode === "delete")
|
|
2806
|
+
simulated.delete(record.messageId);
|
|
2807
|
+
else
|
|
2808
|
+
simulated.set(record.messageId, Object.freeze({
|
|
2809
|
+
...record,
|
|
2810
|
+
status: "tombstoned",
|
|
2811
|
+
updatedAtMs: suffix.updatedAtMs
|
|
2812
|
+
}));
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
for (const record of records) {
|
|
2816
|
+
const existing = simulated.get(record.messageId);
|
|
2817
|
+
if (existing !== undefined) {
|
|
2818
|
+
if (!transcriptIdentityMatches(existing, record)) {
|
|
2819
|
+
throw new Error("transcript immutable first-ledger identity mismatch");
|
|
2820
|
+
}
|
|
2821
|
+
if (existing.status === "tombstoned" && record.status !== "tombstoned") {
|
|
2822
|
+
throw new Error("transcript tombstone resurrection forbidden");
|
|
2823
|
+
}
|
|
2824
|
+
if (record.updatedAtMs < existing.updatedAtMs) {
|
|
2825
|
+
throw new Error("transcript updated_at_ms regression");
|
|
2826
|
+
}
|
|
2827
|
+
if (record.updatedAtMs === existing.updatedAtMs && !transcriptRecordMatches(existing, record)) {
|
|
2828
|
+
throw new Error("transcript idempotency collision");
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
simulated.set(record.messageId, record);
|
|
2832
|
+
}
|
|
2833
|
+
let mutationStarted = false;
|
|
2834
|
+
const scopeUndo = { kind: "transcript-restore-scope", scope, records: before };
|
|
2835
|
+
try {
|
|
2836
|
+
if (affected.length > 0 && suffix !== null) {
|
|
2837
|
+
mutationStarted = true;
|
|
2838
|
+
await this.invalidateTranscriptFtsIndex();
|
|
2839
|
+
if (suffix.mode === "tombstone") {
|
|
2840
|
+
await this.runInternal(`MATCH (m:${TRANSCRIPT_MESSAGE_TABLE} ` +
|
|
2841
|
+
"{scenarioId: $scenarioId, saveId: $saveId, worldlineId: $worldlineId}) " +
|
|
2842
|
+
"WHERE m.messageId IN $authorized AND m.sourceLedgerSeq > $pivot AND m.status <> 'tombstoned' " +
|
|
2843
|
+
"SET m.status = 'tombstoned', m.updatedAtMs = $updatedAtMs;", {
|
|
2844
|
+
...scope,
|
|
2845
|
+
authorized: authorityBefore.authorizedMutationMessageIds,
|
|
2846
|
+
pivot: suffix.afterSourceLedgerSeq,
|
|
2847
|
+
updatedAtMs: suffix.updatedAtMs
|
|
2848
|
+
});
|
|
2849
|
+
}
|
|
2850
|
+
else {
|
|
2851
|
+
await this.runInternal(`MATCH (m:${TRANSCRIPT_MESSAGE_TABLE} ` +
|
|
2852
|
+
"{scenarioId: $scenarioId, saveId: $saveId, worldlineId: $worldlineId}) " +
|
|
2853
|
+
"WHERE m.messageId IN $authorized AND m.sourceLedgerSeq > $pivot DETACH DELETE m;", {
|
|
2854
|
+
...scope,
|
|
2855
|
+
authorized: authorityBefore.authorizedMutationMessageIds,
|
|
2856
|
+
pivot: suffix.afterSourceLedgerSeq
|
|
2857
|
+
});
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
if (records.length > 0) {
|
|
2861
|
+
if (!mutationStarted)
|
|
2862
|
+
await this.invalidateTranscriptFtsIndex();
|
|
2863
|
+
mutationStarted = true;
|
|
2864
|
+
for (const record of records) {
|
|
2865
|
+
const existing = await this.readTranscriptByPk(await this.transcriptPk(scope, record.messageId));
|
|
2866
|
+
if (existing === null || !transcriptRecordMatches(existing, record)) {
|
|
2867
|
+
await this.writeTranscriptRecord(record);
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
const readback = await this.listTranscriptScope(scope);
|
|
2872
|
+
await this.assertTranscriptReachabilityCoverage(scope, authorityBefore.activeReachableMessageIds, new Set(), readback);
|
|
2873
|
+
const resultMessages = records.map((record) => {
|
|
2874
|
+
const current = readback.find((candidate) => candidate.messageId === record.messageId);
|
|
2875
|
+
if (current === undefined || !transcriptRecordMatches(current, record)) {
|
|
2876
|
+
throw new Error("transcript batch upsert readback mismatch");
|
|
2877
|
+
}
|
|
2878
|
+
return current;
|
|
2879
|
+
});
|
|
2880
|
+
const affectedIds = affected.map((record) => record.messageId);
|
|
2881
|
+
const readbackById = new Map(readback.map((record) => [record.messageId, record]));
|
|
2882
|
+
const invalidSuffix = affectedIds.some((messageId) => {
|
|
2883
|
+
if (batchMessageIds.has(messageId))
|
|
2884
|
+
return false;
|
|
2885
|
+
const current = readbackById.get(messageId);
|
|
2886
|
+
return suffix?.mode === "delete" ? current !== undefined : current?.status !== "tombstoned";
|
|
2887
|
+
});
|
|
2888
|
+
if (invalidSuffix)
|
|
2889
|
+
throw new Error("transcript suffix mutation readback mismatch");
|
|
2890
|
+
const authorityAfter = await this.resolveTranscriptAuthority(scope, "mutation", journal, records.map((record) => record.messageId));
|
|
2891
|
+
if (!sameTranscriptAuthority(authorityBefore, authorityAfter)) {
|
|
2892
|
+
throw new Error("transcript authority changed during mutation");
|
|
2893
|
+
}
|
|
2894
|
+
const activeReachableMessageIds = Object.freeze([...authorityAfter.activeReachableMessageIds]);
|
|
2895
|
+
const upsertedMessageIds = Object.freeze(records.map((record) => record.messageId));
|
|
2896
|
+
const upserts = Object.freeze(records.map((record) => Object.freeze({
|
|
2897
|
+
messageId: record.messageId,
|
|
2898
|
+
sourceLedgerSeq: record.sourceLedgerSeq
|
|
2899
|
+
})));
|
|
2900
|
+
const suffixAffectedMessageIds = Object.freeze([...affectedIds]);
|
|
2901
|
+
const receiptImage = JSON.stringify([
|
|
2902
|
+
scope.scenarioId,
|
|
2903
|
+
scope.saveId,
|
|
2904
|
+
scope.worldlineId,
|
|
2905
|
+
journal.idempotencyKey,
|
|
2906
|
+
journal.payloadSha256,
|
|
2907
|
+
journal.phase,
|
|
2908
|
+
journal.phaseRevision,
|
|
2909
|
+
authorityAfter.lineageDigest,
|
|
2910
|
+
mutationPayloadSha256,
|
|
2911
|
+
activeReachableMessageIds,
|
|
2912
|
+
upserts,
|
|
2913
|
+
suffix === null
|
|
2914
|
+
? null
|
|
2915
|
+
: [suffix.afterSourceLedgerSeq, suffix.mode, suffix.updatedAtMs],
|
|
2916
|
+
suffixAffectedMessageIds
|
|
2917
|
+
]);
|
|
2918
|
+
const receipt = Object.freeze({
|
|
2919
|
+
journal,
|
|
2920
|
+
lineageDigest: authorityAfter.lineageDigest,
|
|
2921
|
+
mutationPayloadSha256,
|
|
2922
|
+
activeReachableMessageIds,
|
|
2923
|
+
upsertedMessageIds,
|
|
2924
|
+
upserts,
|
|
2925
|
+
suffix,
|
|
2926
|
+
suffixMode: suffix?.mode ?? null,
|
|
2927
|
+
suffixAffectedMessageIds,
|
|
2928
|
+
receiptSha256: await sha256HexUtf8(`ladybug-transcript-mutation-receipt-v1\u0000${receiptImage}`)
|
|
2929
|
+
});
|
|
2930
|
+
return Object.freeze({ messages: Object.freeze(resultMessages), receipt });
|
|
2931
|
+
}
|
|
2932
|
+
catch (error) {
|
|
2933
|
+
if (!mutationStarted)
|
|
2934
|
+
throw error;
|
|
2935
|
+
try {
|
|
2936
|
+
await this.restoreTranscriptScope(scopeUndo.scope, scopeUndo.records);
|
|
2937
|
+
}
|
|
2938
|
+
catch (recoveryError) {
|
|
2939
|
+
throw new AggregateError([error, recoveryError], "transcript batch mutation recovery failed");
|
|
2940
|
+
}
|
|
2941
|
+
throw error;
|
|
2942
|
+
}
|
|
2943
|
+
}));
|
|
2944
|
+
}
|
|
2945
|
+
async recallTranscriptMessages(request) {
|
|
2946
|
+
const scope = snapshotTranscriptScope(request.scope);
|
|
2947
|
+
const query = request.query.trim();
|
|
2948
|
+
if (query.length === 0)
|
|
2949
|
+
throw new Error("transcript recall query must not be empty");
|
|
2950
|
+
const searchQuery = buildIndexedSearchText(query);
|
|
2951
|
+
if (searchQuery.length === 0) {
|
|
2952
|
+
throw new Error(`transcript recall query has no searchable text: ${JSON.stringify(query)}`);
|
|
2953
|
+
}
|
|
2954
|
+
const topK = assertTopK(request.topK);
|
|
2955
|
+
return this.withTranscriptLock(async () => this.withTranscriptOperationIsolation(async () => {
|
|
2956
|
+
const authorityBefore = await this.resolveTranscriptAuthority(scope, "recall", null);
|
|
2957
|
+
const reachable = authorityBefore.activeReachableMessageIds;
|
|
2958
|
+
await this.assertTranscriptReachabilityCoverage(scope, reachable);
|
|
2959
|
+
if (reachable.length === 0) {
|
|
2960
|
+
const authorityAfter = await this.resolveTranscriptAuthority(scope, "recall", null);
|
|
2961
|
+
if (!sameTranscriptAuthority(authorityBefore, authorityAfter)) {
|
|
2962
|
+
throw new Error("transcript authority changed during recall");
|
|
2963
|
+
}
|
|
2964
|
+
return [];
|
|
2965
|
+
}
|
|
2966
|
+
await this.ensureTranscriptFtsIndex();
|
|
2967
|
+
const rows = await this.runInternal(`CALL QUERY_FTS_INDEX('${TRANSCRIPT_MESSAGE_TABLE}', '${TRANSCRIPT_FTS_INDEX}', $query) ` +
|
|
2968
|
+
"WHERE node.scenarioId = $scenarioId AND node.saveId = $saveId " +
|
|
2969
|
+
"AND node.worldlineId = $worldlineId AND node.messageId IN $reachable " +
|
|
2970
|
+
"AND node.status = 'done' " +
|
|
2971
|
+
"RETURN node.scenarioId AS scenarioId, node.saveId AS saveId, " +
|
|
2972
|
+
"node.worldlineId AS worldlineId, node.messageId AS messageId, node.turnId AS turnId, " +
|
|
2973
|
+
"node.role AS role, node.content AS content, node.status AS status, " +
|
|
2974
|
+
"node.createdAtMs AS createdAtMs, node.updatedAtMs AS updatedAtMs, " +
|
|
2975
|
+
"node.sourceLedgerSeq AS sourceLedgerSeq, score ORDER BY score DESC LIMIT $topK;", { query: searchQuery, topK, reachable, ...scope });
|
|
2976
|
+
const reachableSet = new Set(reachable);
|
|
2977
|
+
const hits = rows.map((row) => {
|
|
2978
|
+
const record = this.toTranscriptMessage(row);
|
|
2979
|
+
if (record.scenarioId !== scope.scenarioId
|
|
2980
|
+
|| record.saveId !== scope.saveId
|
|
2981
|
+
|| record.worldlineId !== scope.worldlineId
|
|
2982
|
+
|| !reachableSet.has(record.messageId)
|
|
2983
|
+
|| record.status !== "done") {
|
|
2984
|
+
throw new Error("transcript recall scope violation");
|
|
2985
|
+
}
|
|
2986
|
+
return { ...record, score: asNumber(row.score) };
|
|
2987
|
+
});
|
|
2988
|
+
const authorityAfter = await this.resolveTranscriptAuthority(scope, "recall", null);
|
|
2989
|
+
if (!sameTranscriptAuthority(authorityBefore, authorityAfter)) {
|
|
2990
|
+
throw new Error("transcript authority changed during recall");
|
|
2991
|
+
}
|
|
2992
|
+
return hits;
|
|
2993
|
+
}));
|
|
2994
|
+
}
|
|
2995
|
+
async mutateTranscriptSuffix(input) {
|
|
2996
|
+
const result = await this.mutateTranscriptMessagesBatch({
|
|
2997
|
+
scope: input.scope,
|
|
2998
|
+
journal: input.journal,
|
|
2999
|
+
messages: [],
|
|
3000
|
+
suffix: {
|
|
3001
|
+
afterSourceLedgerSeq: input.afterSourceLedgerSeq,
|
|
3002
|
+
mode: input.mode,
|
|
3003
|
+
updatedAtMs: input.updatedAtMs
|
|
3004
|
+
}
|
|
3005
|
+
});
|
|
3006
|
+
return Object.freeze({
|
|
3007
|
+
mode: input.mode,
|
|
3008
|
+
affectedMessageIds: result.receipt.suffixAffectedMessageIds,
|
|
3009
|
+
receipt: result.receipt
|
|
3010
|
+
});
|
|
3011
|
+
}
|
|
3012
|
+
latchPk(scenarioId, leafId) {
|
|
3013
|
+
return `${scenarioId}${leafId}`;
|
|
3014
|
+
}
|
|
3015
|
+
async latchLoad(scenarioId) {
|
|
3016
|
+
const rows = await this.runInternal(`MATCH (l:${LATCH_TABLE} {scenarioId: $sid}) ` +
|
|
3017
|
+
"RETURN l.scenarioId AS scenario_id, l.leafId AS leaf_id, l.state AS state, " +
|
|
3018
|
+
"l.firstFiredAtMs AS first_fired_at_ms, l.lastEvaluatedAtMs AS last_evaluated_at_ms, " +
|
|
3019
|
+
"l.fireCount AS fire_count, l.source AS source, l.confidence AS confidence;", { sid: scenarioId });
|
|
3020
|
+
return rows.map((row) => ({
|
|
3021
|
+
scenario_id: asString(row.scenario_id),
|
|
3022
|
+
leaf_id: asString(row.leaf_id),
|
|
3023
|
+
state: asLatchState(row.state),
|
|
3024
|
+
first_fired_at_ms: asNumber(row.first_fired_at_ms),
|
|
3025
|
+
last_evaluated_at_ms: asNumber(row.last_evaluated_at_ms),
|
|
3026
|
+
fire_count: asNumber(row.fire_count),
|
|
3027
|
+
source: asLatchSource(row.source),
|
|
3028
|
+
confidence: asNumber(row.confidence)
|
|
3029
|
+
}));
|
|
3030
|
+
}
|
|
3031
|
+
async latchCommitBatch(scenarioId, rows) {
|
|
3032
|
+
if (rows.length === 0)
|
|
3033
|
+
return 0;
|
|
3034
|
+
await this.recordLatchScenarioUndo(scenarioId);
|
|
3035
|
+
const payload = rows.map((row) => ({
|
|
3036
|
+
pk: this.latchPk(scenarioId, row.leaf_id),
|
|
3037
|
+
scenarioId,
|
|
3038
|
+
leafId: row.leaf_id,
|
|
3039
|
+
state: row.state,
|
|
3040
|
+
firstFiredAtMs: row.first_fired_at_ms,
|
|
3041
|
+
lastEvaluatedAtMs: row.last_evaluated_at_ms,
|
|
3042
|
+
fireCount: row.fire_count,
|
|
3043
|
+
source: row.source,
|
|
3044
|
+
confidence: row.confidence
|
|
3045
|
+
}));
|
|
3046
|
+
await this.runInternal(`UNWIND $rows AS row MERGE (l:${LATCH_TABLE} {pk: row.pk}) ` +
|
|
3047
|
+
"SET l.scenarioId = row.scenarioId, l.leafId = row.leafId, l.state = row.state, " +
|
|
3048
|
+
"l.firstFiredAtMs = row.firstFiredAtMs, l.lastEvaluatedAtMs = row.lastEvaluatedAtMs, " +
|
|
3049
|
+
"l.fireCount = row.fireCount, l.source = row.source, l.confidence = row.confidence;", { rows: payload });
|
|
3050
|
+
return rows.length;
|
|
3051
|
+
}
|
|
3052
|
+
async latchClear(scenarioId) {
|
|
3053
|
+
await this.recordLatchScenarioUndo(scenarioId);
|
|
3054
|
+
await this.runInternal(`MATCH (l:${LATCH_TABLE} {scenarioId: $sid}) DETACH DELETE l;`, { sid: scenarioId });
|
|
3055
|
+
}
|
|
3056
|
+
async latchRollback(scenarioId, leafIds) {
|
|
3057
|
+
if (leafIds.length === 0)
|
|
3058
|
+
return;
|
|
3059
|
+
await this.recordLatchScenarioUndo(scenarioId);
|
|
3060
|
+
const pks = leafIds.map((lid) => this.latchPk(scenarioId, lid));
|
|
3061
|
+
await this.runInternal(`UNWIND $pks AS p MATCH (l:${LATCH_TABLE} {pk: p}) DETACH DELETE l;`, { pks });
|
|
3062
|
+
}
|
|
3063
|
+
async upsertAttrHistory(rows) {
|
|
3064
|
+
if (rows.length === 0)
|
|
3065
|
+
return;
|
|
3066
|
+
const touched = new Set(rows.map((row) => `${row.entityId}\u0000${row.attrPath}`));
|
|
3067
|
+
for (const identity of touched) {
|
|
3068
|
+
const separator = identity.indexOf("\u0000");
|
|
3069
|
+
await this.recordAttrHistoryUndo(identity.slice(0, separator), identity.slice(separator + 1));
|
|
3070
|
+
}
|
|
3071
|
+
const payload = rows.map((row) => ({
|
|
3072
|
+
attrHistoryId: row.attrHistoryId,
|
|
3073
|
+
entityId: row.entityId,
|
|
3074
|
+
attrPath: row.attrPath,
|
|
3075
|
+
oldValueText: row.oldValueText,
|
|
3076
|
+
newValueText: row.newValueText,
|
|
3077
|
+
replacedAtMessageId: row.replacedAtMessageId,
|
|
3078
|
+
replacedAtTurnId: row.replacedAtTurnId,
|
|
3079
|
+
replacedAtWriteSeq: row.replacedAtWriteSeq,
|
|
3080
|
+
superseded: row.superseded
|
|
3081
|
+
}));
|
|
3082
|
+
await this.runInternal(`UNWIND $rows AS row MERGE (a:${ATTR_HISTORY_TABLE} {attrHistoryId: row.attrHistoryId}) ` +
|
|
3083
|
+
"SET a.entityId = row.entityId, a.attrPath = row.attrPath, " +
|
|
3084
|
+
"a.oldValueText = row.oldValueText, a.newValueText = row.newValueText, " +
|
|
3085
|
+
"a.replacedAtMessageId = row.replacedAtMessageId, a.replacedAtTurnId = row.replacedAtTurnId, " +
|
|
3086
|
+
"a.replacedAtWriteSeq = row.replacedAtWriteSeq, a.superseded = row.superseded;", { rows: payload });
|
|
3087
|
+
}
|
|
3088
|
+
async queryAttrHistory(entityId, attrPath) {
|
|
3089
|
+
const rows = await this.runInternal(`MATCH (a:${ATTR_HISTORY_TABLE} {entityId: $entityId, attrPath: $attrPath}) ` +
|
|
3090
|
+
"RETURN a.attrHistoryId AS attrHistoryId, a.entityId AS entityId, a.attrPath AS attrPath, " +
|
|
3091
|
+
"a.oldValueText AS oldValueText, a.newValueText AS newValueText, " +
|
|
3092
|
+
"a.replacedAtMessageId AS replacedAtMessageId, a.replacedAtTurnId AS replacedAtTurnId, " +
|
|
3093
|
+
"a.replacedAtWriteSeq AS replacedAtWriteSeq, a.superseded AS superseded " +
|
|
3094
|
+
"ORDER BY a.replacedAtWriteSeq;", { entityId, attrPath });
|
|
3095
|
+
return rows.map((row) => this.toAttrHistory(row));
|
|
3096
|
+
}
|
|
3097
|
+
async attrHistoryCheck(entityId, attrPath, valueText) {
|
|
3098
|
+
const rows = await this.runInternal(`MATCH (a:${ATTR_HISTORY_TABLE} {entityId: $entityId, attrPath: $attrPath}) ` +
|
|
3099
|
+
"WHERE a.oldValueText = $valueText OR (a.newValueText = $valueText AND a.superseded = true) " +
|
|
3100
|
+
"RETURN a.replacedAtMessageId AS supersededByMessageId, a.replacedAtWriteSeq AS writeSeq " +
|
|
3101
|
+
"ORDER BY a.replacedAtWriteSeq LIMIT 1;", { entityId, attrPath, valueText });
|
|
3102
|
+
if (rows.length === 0)
|
|
3103
|
+
return { superseded: false };
|
|
3104
|
+
return { superseded: true, supersededByMessageId: asString(rows[0].supersededByMessageId) };
|
|
3105
|
+
}
|
|
3106
|
+
async pruneAttrHistory(entityId, attrPath, maxRetain = ATTR_HISTORY_MAX_RETAIN) {
|
|
3107
|
+
const retain = this.assertMaxRetain(maxRetain);
|
|
3108
|
+
const stale = await this.runInternal(`MATCH (a:${ATTR_HISTORY_TABLE} {entityId: $entityId, attrPath: $attrPath}) ` +
|
|
3109
|
+
"RETURN a.attrHistoryId AS attrHistoryId " +
|
|
3110
|
+
`ORDER BY a.replacedAtWriteSeq DESC SKIP ${retain};`, { entityId, attrPath });
|
|
3111
|
+
if (stale.length === 0)
|
|
3112
|
+
return 0;
|
|
3113
|
+
await this.recordAttrHistoryUndo(entityId, attrPath);
|
|
3114
|
+
const ids = stale.map((row) => asString(row.attrHistoryId));
|
|
3115
|
+
await this.runInternal(`UNWIND $ids AS hid MATCH (a:${ATTR_HISTORY_TABLE} {attrHistoryId: hid}) DETACH DELETE a;`, { ids });
|
|
3116
|
+
return ids.length;
|
|
3117
|
+
}
|
|
3118
|
+
async rollbackAttrHistoryAfterMessage(messageId) {
|
|
3119
|
+
const pivotRows = await this.runInternal(`MATCH (a:${ATTR_HISTORY_TABLE}) WHERE a.replacedAtMessageId = $m ` +
|
|
3120
|
+
"RETURN max(a.replacedAtWriteSeq) AS pivot;", { m: messageId });
|
|
3121
|
+
const pivot = asPivot(pivotRows[0]?.pivot);
|
|
3122
|
+
if (pivot === null)
|
|
3123
|
+
return 0;
|
|
3124
|
+
await this.recordAllAttrHistoryUndo();
|
|
3125
|
+
const before = await this.attrHistoryCount();
|
|
3126
|
+
await this.runInternal(`MATCH (a:${ATTR_HISTORY_TABLE}) WHERE a.replacedAtWriteSeq > $p DETACH DELETE a;`, { p: pivot });
|
|
3127
|
+
return before - (await this.attrHistoryCount());
|
|
3128
|
+
}
|
|
3129
|
+
async attrHistoryCount() {
|
|
3130
|
+
const rows = await this.queryInternal(`MATCH (a:${ATTR_HISTORY_TABLE}) RETURN count(a) AS n;`);
|
|
3131
|
+
return rows.length === 0 ? 0 : asNumber(rows[0].n);
|
|
3132
|
+
}
|
|
3133
|
+
assertMaxRetain(maxRetain) {
|
|
3134
|
+
if (!Number.isInteger(maxRetain) || maxRetain < 0) {
|
|
3135
|
+
throw new Error(`maxRetain must be a non-negative integer got ${maxRetain}`);
|
|
3136
|
+
}
|
|
3137
|
+
return Math.min(maxRetain, ATTR_HISTORY_MAX_RETAIN);
|
|
3138
|
+
}
|
|
3139
|
+
toAttrHistory(row) {
|
|
3140
|
+
return {
|
|
3141
|
+
attrHistoryId: asString(row.attrHistoryId),
|
|
3142
|
+
entityId: asString(row.entityId),
|
|
3143
|
+
attrPath: asString(row.attrPath),
|
|
3144
|
+
oldValueText: asNullableString(row.oldValueText),
|
|
3145
|
+
newValueText: asString(row.newValueText),
|
|
3146
|
+
replacedAtMessageId: asString(row.replacedAtMessageId),
|
|
3147
|
+
replacedAtTurnId: asString(row.replacedAtTurnId),
|
|
3148
|
+
replacedAtWriteSeq: asNumber(row.replacedAtWriteSeq),
|
|
3149
|
+
superseded: row.superseded === true
|
|
3150
|
+
};
|
|
3151
|
+
}
|
|
3152
|
+
// The vector index must be (re)built after embeddings settle: ladybug rejects
|
|
3153
|
+
// SET on an indexed column ("Cannot set property ... used in one or more
|
|
3154
|
+
// indexes"). New CREATE auto-indexes, so the load flow is bulk upsert then
|
|
3155
|
+
// createVectorIndex once; an embedding change on an existing entity requires
|
|
3156
|
+
// dropVectorIndex first (editor flow).
|
|
3157
|
+
async createVectorIndex(config) {
|
|
3158
|
+
const embeddedRows = await this.queryInternal(`MATCH (e:${ENTITY_TABLE}) WHERE e.embedding IS NOT NULL RETURN count(e) AS n;`);
|
|
3159
|
+
const indexedCount = embeddedRows.length === 0 ? 0 : asNumber(embeddedRows[0].n);
|
|
3160
|
+
const args = this.buildVectorIndexArgs(config);
|
|
3161
|
+
await this.queryInternal(`CALL CREATE_VECTOR_INDEX('${ENTITY_TABLE}', '${VECTOR_INDEX}', 'embedding'${args});`);
|
|
3162
|
+
this.vectorIndexBuilt = true;
|
|
3163
|
+
this.vectorIndexedEntityCount = indexedCount;
|
|
3164
|
+
}
|
|
3165
|
+
async dropVectorIndex() {
|
|
3166
|
+
if (!this.vectorIndexBuilt)
|
|
3167
|
+
return;
|
|
3168
|
+
await this.queryInternal(`CALL DROP_VECTOR_INDEX('${ENTITY_TABLE}', '${VECTOR_INDEX}');`);
|
|
3169
|
+
this.vectorIndexBuilt = false;
|
|
3170
|
+
this.vectorIndexedEntityCount = 0;
|
|
3171
|
+
}
|
|
3172
|
+
async createFtsIndex(config) {
|
|
3173
|
+
const indexedRows = await this.queryInternal(`MATCH (p:${ENTITY_LEXICAL_PROJECTION_TABLE}) ` +
|
|
3174
|
+
"WHERE p.lexicalStatus = 'ready' RETURN count(p) AS n;");
|
|
3175
|
+
const indexedCount = indexedRows.length === 0 ? 0 : asNumber(indexedRows[0].n);
|
|
3176
|
+
const args = this.buildFtsIndexArgs(config);
|
|
3177
|
+
await this.queryInternal(`CALL CREATE_FTS_INDEX('${ENTITY_LEXICAL_PROJECTION_TABLE}', '${FTS_INDEX}', ` +
|
|
3178
|
+
`['lexicalText']${args});`);
|
|
3179
|
+
this.ftsIndexBuilt = true;
|
|
3180
|
+
this.ftsIndexedEntityCount = indexedCount;
|
|
3181
|
+
}
|
|
3182
|
+
buildVectorIndexArgs(config) {
|
|
3183
|
+
const parts = [];
|
|
3184
|
+
parts.push(`metric := '${pickEnum(config?.metric, VECTOR_METRIC_WHITELIST, FALLBACK_VECTOR_METRIC)}'`);
|
|
3185
|
+
const mu = clampInt(config?.mu, HNSW_MU_MIN, HNSW_MU_MAX);
|
|
3186
|
+
if (mu !== undefined)
|
|
3187
|
+
parts.push(`mu := ${mu}`);
|
|
3188
|
+
const ml = clampInt(config?.ml, HNSW_ML_MIN, HNSW_ML_MAX);
|
|
3189
|
+
if (ml !== undefined)
|
|
3190
|
+
parts.push(`ml := ${ml}`);
|
|
3191
|
+
const efc = clampInt(config?.efc, HNSW_EFC_MIN, HNSW_EFC_MAX);
|
|
3192
|
+
if (efc !== undefined)
|
|
3193
|
+
parts.push(`efc := ${efc}`);
|
|
3194
|
+
if (config?.cache_embeddings !== undefined) {
|
|
3195
|
+
parts.push(`cache_embeddings := ${config.cache_embeddings ? "true" : "false"}`);
|
|
3196
|
+
}
|
|
3197
|
+
return `, ${parts.join(", ")}`;
|
|
3198
|
+
}
|
|
3199
|
+
buildFtsIndexArgs(config) {
|
|
3200
|
+
if (config?.tokenizer !== undefined && config.tokenizer !== ENTITY_FTS_TOKENIZER) {
|
|
3201
|
+
throw new Error("entity FTS tokenizer is fixed to simple");
|
|
3202
|
+
}
|
|
3203
|
+
if (config?.stemmer !== undefined && config.stemmer !== FALLBACK_FTS_STEMMER) {
|
|
3204
|
+
throw new Error("entity FTS stemmer is fixed to none; stemming belongs to AnalyzerResult");
|
|
3205
|
+
}
|
|
3206
|
+
const parts = [
|
|
3207
|
+
`stemmer := '${FALLBACK_FTS_STEMMER}'`,
|
|
3208
|
+
`tokenizer := '${ENTITY_FTS_TOKENIZER}'`
|
|
3209
|
+
];
|
|
3210
|
+
if (config?.stopwords !== undefined) {
|
|
3211
|
+
const cleaned = escapeStopwordsToken(config.stopwords);
|
|
3212
|
+
if (cleaned.length > 0)
|
|
3213
|
+
parts.push(`stopwords := '${cleaned}'`);
|
|
3214
|
+
}
|
|
3215
|
+
return `, ${parts.join(", ")}`;
|
|
3216
|
+
}
|
|
3217
|
+
async dropFtsIndex() {
|
|
3218
|
+
if (!this.ftsIndexBuilt)
|
|
3219
|
+
return;
|
|
3220
|
+
await this.queryInternal(`CALL DROP_FTS_INDEX('${ENTITY_LEXICAL_PROJECTION_TABLE}', '${FTS_INDEX}');`);
|
|
3221
|
+
this.ftsIndexBuilt = false;
|
|
3222
|
+
this.ftsIndexedEntityCount = 0;
|
|
3223
|
+
}
|
|
3224
|
+
hasVectorIndex() {
|
|
3225
|
+
return this.vectorIndexBuilt;
|
|
3226
|
+
}
|
|
3227
|
+
hasFtsIndex() {
|
|
3228
|
+
return this.ftsIndexBuilt;
|
|
3229
|
+
}
|
|
3230
|
+
/**
|
|
3231
|
+
* 只读运行时诊断。只返回数量与索引状态,不跨 wasm 边界暴露实体 props/embedding 明文。
|
|
3232
|
+
* `vectorIndexedEntityCount` 只在本 bridge 成功完成 CREATE_VECTOR_INDEX 后计数,
|
|
3233
|
+
* 因而可用于提交屏障确认 HNSW 已覆盖当前全部 embedding 行。
|
|
3234
|
+
*/
|
|
3235
|
+
async diagnostics() {
|
|
3236
|
+
const entityRows = await this.queryInternal(`MATCH (e:${ENTITY_TABLE}) RETURN count(e) AS n;`);
|
|
3237
|
+
const relationRows = await this.queryInternal(`MATCH ()-[r:${RELATION_TABLE}]->() RETURN count(r) AS n;`);
|
|
3238
|
+
const embeddingRows = await this.queryInternal(`MATCH (e:${ENTITY_TABLE}) WHERE e.embedding IS NOT NULL RETURN count(e) AS n;`);
|
|
3239
|
+
const indexRows = await this.queryInternal("CALL SHOW_INDEXES() RETURN *;");
|
|
3240
|
+
const hasCatalogIndex = (table, name, type) => indexRows.some((row) => asString(row.table_name) === table &&
|
|
3241
|
+
asString(row.index_name) === name &&
|
|
3242
|
+
asString(row.index_type) === type);
|
|
3243
|
+
const vectorIndexBuilt = hasCatalogIndex(ENTITY_TABLE, VECTOR_INDEX, "HNSW");
|
|
3244
|
+
const ftsIndexBuilt = hasCatalogIndex(ENTITY_LEXICAL_PROJECTION_TABLE, FTS_INDEX, "FTS");
|
|
3245
|
+
if (vectorIndexBuilt !== this.vectorIndexBuilt || ftsIndexBuilt !== this.ftsIndexBuilt) {
|
|
3246
|
+
throw new Error("ladybug index catalog diverged from bridge state: " +
|
|
3247
|
+
`vector(catalog=${vectorIndexBuilt},bridge=${this.vectorIndexBuilt}) ` +
|
|
3248
|
+
`fts(catalog=${ftsIndexBuilt},bridge=${this.ftsIndexBuilt})`);
|
|
3249
|
+
}
|
|
3250
|
+
const entityCount = entityRows.length === 0 ? 0 : asNumber(entityRows[0].n);
|
|
3251
|
+
const relationCount = relationRows.length === 0 ? 0 : asNumber(relationRows[0].n);
|
|
3252
|
+
const embeddedEntityCount = embeddingRows.length === 0 ? 0 : asNumber(embeddingRows[0].n);
|
|
3253
|
+
return {
|
|
3254
|
+
entityCount,
|
|
3255
|
+
relationCount,
|
|
3256
|
+
embeddedEntityCount,
|
|
3257
|
+
vectorIndexedEntityCount: this.vectorIndexedEntityCount,
|
|
3258
|
+
ftsIndexedEntityCount: this.ftsIndexedEntityCount,
|
|
3259
|
+
vectorIndexBuilt,
|
|
3260
|
+
ftsIndexBuilt
|
|
3261
|
+
};
|
|
3262
|
+
}
|
|
3263
|
+
async upsertEntity(id, kind, props, embedding, messageId = "", turnId = "", analyzerProjection) {
|
|
3264
|
+
if (embedding && embedding.length !== this.dim) {
|
|
3265
|
+
throw new Error(`embedding dim mismatch expected ${this.dim} got ${embedding.length}`);
|
|
3266
|
+
}
|
|
3267
|
+
const projection = assertAnalyzerProjection(analyzerProjection);
|
|
3268
|
+
if (this.ftsIndexBuilt) {
|
|
3269
|
+
throw new Error("cannot change an entity analyzer projection while the FTS index is live — dropFtsIndex first");
|
|
3270
|
+
}
|
|
3271
|
+
const exists = (await this.runInternal(`MATCH (e:${ENTITY_TABLE} {id: $id}) RETURN 1 AS hit;`, { id })).length > 0;
|
|
3272
|
+
if (exists && embedding && this.vectorIndexBuilt) {
|
|
3273
|
+
throw new Error("cannot change embedding of an existing entity while the vector index is live — dropVectorIndex first");
|
|
3274
|
+
}
|
|
3275
|
+
await this.recordEntityUndoBeforeWrite(id);
|
|
3276
|
+
const writeSeq = this.nextSeq();
|
|
3277
|
+
if (embedding) {
|
|
3278
|
+
await this.runInternal(`MERGE (e:${ENTITY_TABLE} {id: $id}) ` +
|
|
3279
|
+
"SET e.kind = $kind, e.props = $props, e.embedding = $embedding, " +
|
|
3280
|
+
"e.messageId = $messageId, e.turnId = $turnId, e.writeSeq = $writeSeq;", {
|
|
3281
|
+
id,
|
|
3282
|
+
kind,
|
|
3283
|
+
props: encodeProps(props),
|
|
3284
|
+
embedding: [...embedding],
|
|
3285
|
+
messageId,
|
|
3286
|
+
turnId,
|
|
3287
|
+
writeSeq
|
|
3288
|
+
});
|
|
3289
|
+
await this.replaceEntityAnalyzerProjection(id, projection, writeSeq);
|
|
3290
|
+
return;
|
|
3291
|
+
}
|
|
3292
|
+
await this.runInternal(`MERGE (e:${ENTITY_TABLE} {id: $id}) ` +
|
|
3293
|
+
"SET e.kind = $kind, e.props = $props, " +
|
|
3294
|
+
"e.messageId = $messageId, e.turnId = $turnId, e.writeSeq = $writeSeq;", { id, kind, props: encodeProps(props), messageId, turnId, writeSeq });
|
|
3295
|
+
await this.replaceEntityAnalyzerProjection(id, projection, writeSeq);
|
|
3296
|
+
}
|
|
3297
|
+
async replaceEntityAnalyzerProjection(entityId, projection, writeSeq) {
|
|
3298
|
+
if (projection === null) {
|
|
3299
|
+
await this.runInternal(`MATCH (p:${ENTITY_LEXICAL_PROJECTION_TABLE} {entityId: $entityId}) DETACH DELETE p;`, { entityId });
|
|
3300
|
+
return;
|
|
3301
|
+
}
|
|
3302
|
+
await this.runInternal(`MERGE (p:${ENTITY_LEXICAL_PROJECTION_TABLE} {entityId: $entityId}) ` +
|
|
3303
|
+
"SET p.lexicalText = $lexicalText, p.projectionJson = $projectionJson, " +
|
|
3304
|
+
"p.analyzerVersion = $analyzerVersion, p.lexicalStatus = $lexicalStatus, " +
|
|
3305
|
+
"p.relationalStatus = $relationalStatus, p.writeSeq = $writeSeq;", {
|
|
3306
|
+
entityId,
|
|
3307
|
+
lexicalText: projection.lexicalText,
|
|
3308
|
+
projectionJson: encodeAnalyzerProjection(projection),
|
|
3309
|
+
analyzerVersion: projection.analyzerVersion,
|
|
3310
|
+
lexicalStatus: projection.lexicalStatus,
|
|
3311
|
+
relationalStatus: projection.relationalStatus,
|
|
3312
|
+
writeSeq
|
|
3313
|
+
});
|
|
3314
|
+
}
|
|
3315
|
+
async getEntityAnalyzerProjection(entityId) {
|
|
3316
|
+
const rows = await this.runInternal(`MATCH (p:${ENTITY_LEXICAL_PROJECTION_TABLE} {entityId: $entityId}) ` +
|
|
3317
|
+
"RETURN p.projectionJson AS projectionJson, p.lexicalText AS lexicalText, " +
|
|
3318
|
+
"p.analyzerVersion AS analyzerVersion, p.lexicalStatus AS lexicalStatus, " +
|
|
3319
|
+
"p.relationalStatus AS relationalStatus;", { entityId });
|
|
3320
|
+
if (rows.length === 0) {
|
|
3321
|
+
return {
|
|
3322
|
+
entityId,
|
|
3323
|
+
lexicalStatus: "reindex-needed",
|
|
3324
|
+
relationalStatus: "reindex-needed",
|
|
3325
|
+
lexicalArm: { status: "reindex-needed", analyzerVersion: null },
|
|
3326
|
+
relationalArm: { status: "reindex-needed", analyzerVersion: null },
|
|
3327
|
+
projection: null
|
|
3328
|
+
};
|
|
3329
|
+
}
|
|
3330
|
+
const projection = JSON.parse(asString(rows[0].projectionJson));
|
|
3331
|
+
assertAnalyzerProjection(projection);
|
|
3332
|
+
const { lexicalStatus, relationalStatus } = projection;
|
|
3333
|
+
if (!["ready", "unavailable", "failed"].includes(lexicalStatus) ||
|
|
3334
|
+
!["ready", "unavailable", "failed"].includes(relationalStatus)) {
|
|
3335
|
+
throw new Error("entity analyzer projection has invalid stored arm status");
|
|
3336
|
+
}
|
|
3337
|
+
if (asString(rows[0].lexicalText) !== projection.lexicalText ||
|
|
3338
|
+
asString(rows[0].analyzerVersion) !== projection.analyzerVersion ||
|
|
3339
|
+
asString(rows[0].lexicalStatus) !== lexicalStatus ||
|
|
3340
|
+
asString(rows[0].relationalStatus) !== relationalStatus) {
|
|
3341
|
+
throw new Error("entity analyzer projection sidecar integrity mismatch");
|
|
3342
|
+
}
|
|
3343
|
+
const analyzerVersion = projection.analyzerVersion;
|
|
3344
|
+
return {
|
|
3345
|
+
entityId,
|
|
3346
|
+
lexicalStatus,
|
|
3347
|
+
relationalStatus,
|
|
3348
|
+
lexicalArm: { status: lexicalStatus, analyzerVersion },
|
|
3349
|
+
relationalArm: { status: relationalStatus, analyzerVersion },
|
|
3350
|
+
projection
|
|
3351
|
+
};
|
|
3352
|
+
}
|
|
3353
|
+
// bulk_load 用 UNWIND 单次 prepare+execute 喂多行 ladybug 0.17.1 实测 entity
|
|
3354
|
+
// (with/without embedding) 和 relation (MATCH+MERGE) 均支持 空 rows UNWIND 会
|
|
3355
|
+
// 触发 "Cannot evaluate expression with type VARIABLE" 因此空数组走 early-return
|
|
3356
|
+
// 不再走 prepare 不可与 vector index 同时存在 (ladybug 拒绝 SET 索引列) 由
|
|
3357
|
+
// caller (bulk_load 流程) 保证先 dropVectorIndex
|
|
3358
|
+
async bulkUpsertEntities(entries) {
|
|
3359
|
+
if (entries.length === 0)
|
|
3360
|
+
return;
|
|
3361
|
+
if (this.vectorIndexBuilt) {
|
|
3362
|
+
throw new Error("cannot bulk upsert entities while the vector index is live — dropVectorIndex first");
|
|
3363
|
+
}
|
|
3364
|
+
if (this.ftsIndexBuilt) {
|
|
3365
|
+
throw new Error("cannot bulk upsert entities while the FTS index is live — dropFtsIndex first");
|
|
3366
|
+
}
|
|
3367
|
+
if (this.tx !== null) {
|
|
3368
|
+
for (const entry of entries) {
|
|
3369
|
+
await this.recordEntityUndoBeforeWrite(entry.id);
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
const withEmbedding = [];
|
|
3373
|
+
const withoutEmbedding = [];
|
|
3374
|
+
const projections = [];
|
|
3375
|
+
for (const entry of entries) {
|
|
3376
|
+
const writeSeq = this.nextSeq();
|
|
3377
|
+
projections.push({
|
|
3378
|
+
entityId: entry.id,
|
|
3379
|
+
projection: assertAnalyzerProjection(entry.analyzerProjection),
|
|
3380
|
+
writeSeq
|
|
3381
|
+
});
|
|
3382
|
+
if (entry.embedding) {
|
|
3383
|
+
if (entry.embedding.length !== this.dim) {
|
|
3384
|
+
throw new Error(`embedding dim mismatch expected ${this.dim} got ${entry.embedding.length}`);
|
|
3385
|
+
}
|
|
3386
|
+
withEmbedding.push({
|
|
3387
|
+
id: entry.id,
|
|
3388
|
+
kind: entry.kind,
|
|
3389
|
+
props: encodeProps(entry.props),
|
|
3390
|
+
embedding: [...entry.embedding],
|
|
3391
|
+
messageId: entry.messageId,
|
|
3392
|
+
turnId: entry.turnId,
|
|
3393
|
+
writeSeq
|
|
3394
|
+
});
|
|
3395
|
+
}
|
|
3396
|
+
else {
|
|
3397
|
+
withoutEmbedding.push({
|
|
3398
|
+
id: entry.id,
|
|
3399
|
+
kind: entry.kind,
|
|
3400
|
+
props: encodeProps(entry.props),
|
|
3401
|
+
messageId: entry.messageId,
|
|
3402
|
+
turnId: entry.turnId,
|
|
3403
|
+
writeSeq
|
|
3404
|
+
});
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
if (withEmbedding.length > 0) {
|
|
3408
|
+
await this.runInternal(`UNWIND $rows AS row MERGE (e:${ENTITY_TABLE} {id: row.id}) ` +
|
|
3409
|
+
"SET e.kind = row.kind, e.props = row.props, e.embedding = row.embedding, " +
|
|
3410
|
+
"e.messageId = row.messageId, e.turnId = row.turnId, e.writeSeq = row.writeSeq;", { rows: withEmbedding });
|
|
3411
|
+
}
|
|
3412
|
+
if (withoutEmbedding.length > 0) {
|
|
3413
|
+
await this.runInternal(`UNWIND $rows AS row MERGE (e:${ENTITY_TABLE} {id: row.id}) ` +
|
|
3414
|
+
"SET e.kind = row.kind, e.props = row.props, " +
|
|
3415
|
+
"e.messageId = row.messageId, e.turnId = row.turnId, e.writeSeq = row.writeSeq;", { rows: withoutEmbedding });
|
|
3416
|
+
}
|
|
3417
|
+
for (const row of projections) {
|
|
3418
|
+
await this.replaceEntityAnalyzerProjection(row.entityId, row.projection, row.writeSeq);
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
async bulkUpsertRelations(entries) {
|
|
3422
|
+
if (entries.length === 0)
|
|
3423
|
+
return;
|
|
3424
|
+
if (this.tx !== null) {
|
|
3425
|
+
for (const entry of entries) {
|
|
3426
|
+
await this.recordRelationUndoBeforeWrite(entry.src, entry.dst, entry.kind);
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
const rows = entries.map((entry) => ({
|
|
3430
|
+
src: entry.src,
|
|
3431
|
+
dst: entry.dst,
|
|
3432
|
+
kind: entry.kind,
|
|
3433
|
+
props: encodeProps(entry.props),
|
|
3434
|
+
messageId: entry.messageId,
|
|
3435
|
+
turnId: entry.turnId,
|
|
3436
|
+
writeSeq: this.nextSeq()
|
|
3437
|
+
}));
|
|
3438
|
+
await this.runInternal(`UNWIND $rows AS row MATCH (s:${ENTITY_TABLE} {id: row.src}), (d:${ENTITY_TABLE} {id: row.dst}) ` +
|
|
3439
|
+
`MERGE (s)-[r:${RELATION_TABLE} {kind: row.kind}]->(d) ` +
|
|
3440
|
+
"SET r.props = row.props, r.messageId = row.messageId, " +
|
|
3441
|
+
"r.turnId = row.turnId, r.writeSeq = row.writeSeq;", { rows });
|
|
3442
|
+
}
|
|
3443
|
+
async upsertRelation(src, dst, kind, props, messageId = "", turnId = "") {
|
|
3444
|
+
await this.recordRelationUndoBeforeWrite(src, dst, kind);
|
|
3445
|
+
const writeSeq = this.nextSeq();
|
|
3446
|
+
await this.runInternal(`MATCH (s:${ENTITY_TABLE} {id: $src}), (d:${ENTITY_TABLE} {id: $dst}) ` +
|
|
3447
|
+
`MERGE (s)-[r:${RELATION_TABLE} {kind: $kind}]->(d) ` +
|
|
3448
|
+
"SET r.props = $props, r.messageId = $messageId, " +
|
|
3449
|
+
"r.turnId = $turnId, r.writeSeq = $writeSeq;", { src, dst, kind, props: encodeProps(props), messageId, turnId, writeSeq });
|
|
3450
|
+
}
|
|
3451
|
+
async deleteEntity(id) {
|
|
3452
|
+
if (this.worldMemoryHostAuthority !== null && this.tx === null) {
|
|
3453
|
+
throw new Error("trusted WorldMemory bridge requires a transaction for entity deletion");
|
|
3454
|
+
}
|
|
3455
|
+
await this.recordWorldMemoryUndoIfTransaction();
|
|
3456
|
+
await this.recordEntityUndoBeforeDelete(id);
|
|
3457
|
+
await this.runInternal(`MATCH (p:${ENTITY_LEXICAL_PROJECTION_TABLE} {entityId: $id}) DETACH DELETE p;`, { id });
|
|
3458
|
+
await this.runInternal(`MATCH (e:${ENTITY_TABLE} {id: $id}) DETACH DELETE e;`, { id });
|
|
3459
|
+
}
|
|
3460
|
+
// Rollback 语义 找 message_id M 在 Entity+Relation 中的 max(writeSeq) 作 pivot
|
|
3461
|
+
// 删除 strictly writeSeq>pivot 的所有边和点 pivot 自身的写入保留 没命中 M 视 no-op
|
|
3462
|
+
// 用 DETACH DELETE 简化 此时残余挂在被删 Entity 上的边都是 writeSeq<=pivot
|
|
3463
|
+
// (因 writeSeq 全局单调 而 Entity 的入边只能在它存在后写入) 一并清掉
|
|
3464
|
+
// sentinel 防御: messageId 若命中 SNAPSHOT_PROVENANCE_MESSAGE_ID 直接视为 no-op 不当合法 pivot
|
|
3465
|
+
// 初始快照行不是任何一条对话消息的产物 没有"回滚到快照"这个语义 调用方传入 sentinel
|
|
3466
|
+
// 只可能是上游 bug (把快照当成了可寻址的消息) 拒绝掉避免把快照之后的真实游玩数据错误清空
|
|
3467
|
+
async rollbackAfterMessage(messageId) {
|
|
3468
|
+
if (messageId === SNAPSHOT_PROVENANCE_MESSAGE_ID) {
|
|
3469
|
+
return { deletedEntities: 0, deletedRelations: 0 };
|
|
3470
|
+
}
|
|
3471
|
+
if (this.worldMemoryHostAuthority !== null && this.tx === null) {
|
|
3472
|
+
throw new Error("trusted WorldMemory bridge requires a transaction for message rollback");
|
|
3473
|
+
}
|
|
3474
|
+
const entityPivotRows = await this.runInternal(`MATCH (e:${ENTITY_TABLE}) WHERE e.messageId = $m RETURN max(e.writeSeq) AS pivot;`, { m: messageId });
|
|
3475
|
+
const relationPivotRows = await this.runInternal(`MATCH ()-[r:${RELATION_TABLE}]->() WHERE r.messageId = $m RETURN max(r.writeSeq) AS pivot;`, { m: messageId });
|
|
3476
|
+
const ePivot = asPivot(entityPivotRows[0]?.pivot);
|
|
3477
|
+
const rPivot = asPivot(relationPivotRows[0]?.pivot);
|
|
3478
|
+
if (ePivot === null && rPivot === null) {
|
|
3479
|
+
return { deletedEntities: 0, deletedRelations: 0 };
|
|
3480
|
+
}
|
|
3481
|
+
const pivot = Math.max(ePivot ?? -1, rPivot ?? -1);
|
|
3482
|
+
await this.recordWorldMemoryUndoIfTransaction();
|
|
3483
|
+
await this.recordRollbackAfterMessageUndo(pivot);
|
|
3484
|
+
const beforeE = await this.entityCount();
|
|
3485
|
+
const beforeR = await this.relationCount();
|
|
3486
|
+
await this.runInternal(`MATCH ()-[r:${RELATION_TABLE}]->() WHERE r.writeSeq > $p DELETE r;`, { p: pivot });
|
|
3487
|
+
await this.runInternal(`MATCH (p:${ENTITY_LEXICAL_PROJECTION_TABLE}) WHERE p.writeSeq > $p DETACH DELETE p;`, { p: pivot });
|
|
3488
|
+
await this.runInternal(`MATCH (e:${ENTITY_TABLE}) WHERE e.writeSeq > $p DETACH DELETE e;`, { p: pivot });
|
|
3489
|
+
const afterE = await this.entityCount();
|
|
3490
|
+
const afterR = await this.relationCount();
|
|
3491
|
+
return {
|
|
3492
|
+
deletedEntities: beforeE - afterE,
|
|
3493
|
+
deletedRelations: beforeR - afterR
|
|
3494
|
+
};
|
|
3495
|
+
}
|
|
3496
|
+
async deleteRelation(src, dst) {
|
|
3497
|
+
await this.recordRelationUndoBeforeDelete(src, dst);
|
|
3498
|
+
await this.runInternal(`MATCH (s:${ENTITY_TABLE} {id: $src})-[r:${RELATION_TABLE}]->(d:${ENTITY_TABLE} {id: $dst}) DELETE r;`, { src, dst });
|
|
3499
|
+
}
|
|
3500
|
+
async getById(id) {
|
|
3501
|
+
const rows = await this.runInternal(`MATCH (e:${ENTITY_TABLE} {id: $id}) RETURN e.id AS id, e.kind AS kind, e.props AS props;`, { id });
|
|
3502
|
+
return rows.length === 0 ? null : this.toEntity(rows[0]);
|
|
3503
|
+
}
|
|
3504
|
+
async findByKind(kind) {
|
|
3505
|
+
const rows = await this.runInternal(`MATCH (e:${ENTITY_TABLE} {kind: $kind}) RETURN e.id AS id, e.kind AS kind, e.props AS props;`, { kind });
|
|
3506
|
+
return rows.map((row) => this.toEntity(row));
|
|
3507
|
+
}
|
|
3508
|
+
async allEntities() {
|
|
3509
|
+
const rows = await this.queryInternal(`MATCH (e:${ENTITY_TABLE}) ` +
|
|
3510
|
+
`OPTIONAL MATCH (p:${ENTITY_LEXICAL_PROJECTION_TABLE} {entityId: e.id}) ` +
|
|
3511
|
+
"RETURN e.id AS id, e.kind AS kind, e.props AS props, " +
|
|
3512
|
+
"p.projectionJson AS analyzerProjectionJson;");
|
|
3513
|
+
return rows.map((row) => {
|
|
3514
|
+
const entity = this.toEntity(row);
|
|
3515
|
+
if (row.analyzerProjectionJson === null || row.analyzerProjectionJson === undefined) {
|
|
3516
|
+
return entity;
|
|
3517
|
+
}
|
|
3518
|
+
const projection = JSON.parse(asString(row.analyzerProjectionJson));
|
|
3519
|
+
assertAnalyzerProjection(projection);
|
|
3520
|
+
if (typeof entity.props !== "object" || entity.props === null || Array.isArray(entity.props)) {
|
|
3521
|
+
throw new Error("entity props must be an object when attaching analyzer projection");
|
|
3522
|
+
}
|
|
3523
|
+
return {
|
|
3524
|
+
...entity,
|
|
3525
|
+
props: {
|
|
3526
|
+
...entity.props,
|
|
3527
|
+
__analyzerProjection: projection
|
|
3528
|
+
}
|
|
3529
|
+
};
|
|
3530
|
+
});
|
|
3531
|
+
}
|
|
3532
|
+
async allRelations() {
|
|
3533
|
+
const rows = await this.queryInternal(`MATCH (s:${ENTITY_TABLE})-[r:${RELATION_TABLE}]->(d:${ENTITY_TABLE}) ` +
|
|
3534
|
+
"RETURN s.id AS src, d.id AS dst, r.kind AS kind, r.props AS props;");
|
|
3535
|
+
return rows.map((row) => this.toRelation(row));
|
|
3536
|
+
}
|
|
3537
|
+
async neighbors(id, depth, options) {
|
|
3538
|
+
const safeDepth = assertDepth(depth);
|
|
3539
|
+
if (safeDepth === 0)
|
|
3540
|
+
return [];
|
|
3541
|
+
const entityKinds = (options?.entityKinds ?? []).filter((s) => s.length > 0);
|
|
3542
|
+
const relationKinds = (options?.relationKinds ?? []).filter((s) => s.length > 0);
|
|
3543
|
+
const whereClauses = [];
|
|
3544
|
+
const params = { id };
|
|
3545
|
+
if (entityKinds.length > 0) {
|
|
3546
|
+
// entity kind 支持 Cypher 参数绑定 直接 pushdown 到 kuzu 侧筛
|
|
3547
|
+
whereClauses.push("m.kind IN $entityKinds");
|
|
3548
|
+
params.entityKinds = [...entityKinds];
|
|
3549
|
+
}
|
|
3550
|
+
// relation kind 白名单 kuzu Cypher 不支持 list 参数绑定进入 all()/list_contains 谓词
|
|
3551
|
+
// (parsed_parameter_expression.h UNREACHABLE_CODE) 因此 TS 侧走 rel_kinds RETURN 后置过滤
|
|
3552
|
+
const whereFragment = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")} ` : "";
|
|
3553
|
+
const rows = await this.runInternal(`MATCH p = (s:${ENTITY_TABLE} {id: $id})-[r:${RELATION_TABLE}*1..${safeDepth}]->(m:${ENTITY_TABLE}) ` +
|
|
3554
|
+
whereFragment +
|
|
3555
|
+
"RETURN m.id AS id, m.kind AS kind, length(p) AS hop, " +
|
|
3556
|
+
"list_transform(rels(p), e -> e.kind) AS rel_kinds;", params);
|
|
3557
|
+
const mapped = rows.map((row) => ({
|
|
3558
|
+
row,
|
|
3559
|
+
relKinds: this.toStringList(row.rel_kinds),
|
|
3560
|
+
neighbor: this.toNeighbor(row)
|
|
3561
|
+
}));
|
|
3562
|
+
if (relationKinds.length === 0) {
|
|
3563
|
+
return mapped.map((m) => m.neighbor);
|
|
3564
|
+
}
|
|
3565
|
+
const relSet = new Set(relationKinds);
|
|
3566
|
+
return mapped
|
|
3567
|
+
.filter((m) => m.relKinds.every((k) => relSet.has(k)))
|
|
3568
|
+
.map((m) => m.neighbor);
|
|
3569
|
+
}
|
|
3570
|
+
async vectorSearch(query, topK, config) {
|
|
3571
|
+
assertTopK(topK);
|
|
3572
|
+
const efs = clampInt(config?.efs, HNSW_EFS_MIN, HNSW_EFS_MAX);
|
|
3573
|
+
const efsArg = efs !== undefined ? `, efs := ${efs}` : "";
|
|
3574
|
+
const rows = await this.runInternal(`CALL QUERY_VECTOR_INDEX('${ENTITY_TABLE}', '${VECTOR_INDEX}', $query, $topK${efsArg}) ` +
|
|
3575
|
+
"YIELD node AS seed, distance " +
|
|
3576
|
+
"RETURN seed.id AS id, distance ORDER BY distance;", { query: [...query], topK });
|
|
3577
|
+
return rows.map((row) => ({ id: asString(row.id), distance: asNumber(row.distance) }));
|
|
3578
|
+
}
|
|
3579
|
+
async fullTextSearch(query, topK, config) {
|
|
3580
|
+
assertTopK(topK);
|
|
3581
|
+
const ftsArgs = this.buildFtsQueryArgs(config);
|
|
3582
|
+
const rows = await this.runInternal(`CALL QUERY_FTS_INDEX('${ENTITY_LEXICAL_PROJECTION_TABLE}', '${FTS_INDEX}', ` +
|
|
3583
|
+
`$query${ftsArgs}) ` +
|
|
3584
|
+
"RETURN node.entityId AS id, score ORDER BY score DESC LIMIT $topK;", { query, topK });
|
|
3585
|
+
return rows.map((row) => asString(row.id));
|
|
3586
|
+
}
|
|
3587
|
+
async recallAndExpand(query, topK, depth, config) {
|
|
3588
|
+
assertTopK(topK);
|
|
3589
|
+
const safeDepth = assertDepth(depth);
|
|
3590
|
+
const efs = clampInt(config?.efs, HNSW_EFS_MIN, HNSW_EFS_MAX);
|
|
3591
|
+
const rows = await this.runInternal(this.recallCypher(safeDepth, efs), {
|
|
3592
|
+
query: [...query],
|
|
3593
|
+
topK
|
|
3594
|
+
});
|
|
3595
|
+
return this.rebuildGraphRag(rows);
|
|
3596
|
+
}
|
|
3597
|
+
buildFtsQueryArgs(config) {
|
|
3598
|
+
const parts = [];
|
|
3599
|
+
const k1 = clampDouble(config?.bm25_k1, BM25_K1_MIN, BM25_K1_MAX);
|
|
3600
|
+
if (k1 !== undefined)
|
|
3601
|
+
parts.push(`k1 := ${k1}`);
|
|
3602
|
+
const b = clampDouble(config?.bm25_b, BM25_B_MIN, BM25_B_MAX);
|
|
3603
|
+
if (b !== undefined)
|
|
3604
|
+
parts.push(`b := ${b}`);
|
|
3605
|
+
if (config?.conjunctive !== undefined) {
|
|
3606
|
+
parts.push(`conjunctive := ${config.conjunctive ? "true" : "false"}`);
|
|
3607
|
+
}
|
|
3608
|
+
return parts.length === 0 ? "" : `, ${parts.join(", ")}`;
|
|
3609
|
+
}
|
|
3610
|
+
recallCypher(depth, efs) {
|
|
3611
|
+
const efsArg = efs !== undefined ? `, efs := ${efs}` : "";
|
|
3612
|
+
const projection = "seed.id AS seed_id, seed.kind AS seed_kind, seed.props AS seed_props, distance";
|
|
3613
|
+
if (depth === 0) {
|
|
3614
|
+
return (`CALL QUERY_VECTOR_INDEX('${ENTITY_TABLE}', '${VECTOR_INDEX}', $query, $topK${efsArg}) ` +
|
|
3615
|
+
"YIELD node AS seed, distance " +
|
|
3616
|
+
`RETURN ${projection}, ` +
|
|
3617
|
+
"CAST(NULL AS STRING) AS m_id, CAST(NULL AS STRING) AS m_kind, CAST(NULL AS STRING) AS m_props, " +
|
|
3618
|
+
"CAST(NULL AS INT64) AS hop, [seed.id] AS path_ids, " +
|
|
3619
|
+
"CAST([] AS STRING[]) AS rel_kinds, CAST([] AS STRING[]) AS rel_props " +
|
|
3620
|
+
"ORDER BY distance;");
|
|
3621
|
+
}
|
|
3622
|
+
return (`CALL QUERY_VECTOR_INDEX('${ENTITY_TABLE}', '${VECTOR_INDEX}', $query, $topK${efsArg}) ` +
|
|
3623
|
+
"YIELD node AS seed, distance " +
|
|
3624
|
+
`OPTIONAL MATCH p = (seed)-[r:${RELATION_TABLE}*1..${depth}]->(m:${ENTITY_TABLE}) ` +
|
|
3625
|
+
`RETURN ${projection}, ` +
|
|
3626
|
+
"m.id AS m_id, m.kind AS m_kind, m.props AS m_props, length(p) AS hop, " +
|
|
3627
|
+
"list_transform(nodes(p), x -> x.id) AS path_ids, " +
|
|
3628
|
+
"list_transform(rels(p), e -> e.kind) AS rel_kinds, " +
|
|
3629
|
+
"list_transform(rels(p), e -> e.props) AS rel_props " +
|
|
3630
|
+
"ORDER BY distance, hop;");
|
|
3631
|
+
}
|
|
3632
|
+
rebuildGraphRag(rows) {
|
|
3633
|
+
const seeds = [];
|
|
3634
|
+
const seenSeed = new Set();
|
|
3635
|
+
const nodeOrder = [];
|
|
3636
|
+
const nodeEntities = new Map();
|
|
3637
|
+
const nodeProvenance = new Map();
|
|
3638
|
+
const provSeen = new Set();
|
|
3639
|
+
const edgeOrder = [];
|
|
3640
|
+
const edgeSeen = new Set();
|
|
3641
|
+
const addNode = (entity) => {
|
|
3642
|
+
if (nodeEntities.has(entity.id))
|
|
3643
|
+
return;
|
|
3644
|
+
nodeOrder.push(entity.id);
|
|
3645
|
+
nodeEntities.set(entity.id, entity);
|
|
3646
|
+
nodeProvenance.set(entity.id, []);
|
|
3647
|
+
};
|
|
3648
|
+
const addProvenance = (id, seedId, hop) => {
|
|
3649
|
+
const key = JSON.stringify([id, seedId, hop]);
|
|
3650
|
+
if (provSeen.has(key))
|
|
3651
|
+
return;
|
|
3652
|
+
provSeen.add(key);
|
|
3653
|
+
nodeProvenance.get(id)?.push({ seedId, hop });
|
|
3654
|
+
};
|
|
3655
|
+
const addEdge = (edge) => {
|
|
3656
|
+
const key = JSON.stringify([edge.src, edge.dst, edge.kind]);
|
|
3657
|
+
if (edgeSeen.has(key))
|
|
3658
|
+
return;
|
|
3659
|
+
edgeSeen.add(key);
|
|
3660
|
+
edgeOrder.push(edge);
|
|
3661
|
+
};
|
|
3662
|
+
for (const row of rows) {
|
|
3663
|
+
const seedId = asString(row.seed_id);
|
|
3664
|
+
if (!seenSeed.has(seedId)) {
|
|
3665
|
+
seenSeed.add(seedId);
|
|
3666
|
+
seeds.push({ id: seedId, distance: asNumber(row.distance) });
|
|
3667
|
+
}
|
|
3668
|
+
addNode({ id: seedId, kind: asString(row.seed_kind), props: parseProps(row.seed_props) });
|
|
3669
|
+
addProvenance(seedId, seedId, 0);
|
|
3670
|
+
const pathIds = this.toStringList(row.path_ids);
|
|
3671
|
+
const relKinds = this.toStringList(row.rel_kinds);
|
|
3672
|
+
const relProps = this.toStringList(row.rel_props);
|
|
3673
|
+
if (pathIds.length <= 1)
|
|
3674
|
+
continue;
|
|
3675
|
+
const mId = row.m_id == null ? null : asString(row.m_id);
|
|
3676
|
+
if (mId !== null) {
|
|
3677
|
+
addNode({ id: mId, kind: asString(row.m_kind), props: parseProps(row.m_props) });
|
|
3678
|
+
addProvenance(mId, seedId, asNumber(row.hop));
|
|
3679
|
+
}
|
|
3680
|
+
for (let i = 0; i + 1 < pathIds.length; i += 1) {
|
|
3681
|
+
addEdge({
|
|
3682
|
+
src: pathIds[i],
|
|
3683
|
+
dst: pathIds[i + 1],
|
|
3684
|
+
kind: relKinds[i] ?? "",
|
|
3685
|
+
props: parseProps(relProps[i])
|
|
3686
|
+
});
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
const nodes = nodeOrder.map((id) => ({
|
|
3690
|
+
entity: nodeEntities.get(id),
|
|
3691
|
+
provenance: nodeProvenance.get(id) ?? []
|
|
3692
|
+
}));
|
|
3693
|
+
return { seeds, nodes, edges: edgeOrder };
|
|
3694
|
+
}
|
|
3695
|
+
toStringList(value) {
|
|
3696
|
+
return Array.isArray(value) ? value.map((item) => asString(item)) : [];
|
|
3697
|
+
}
|
|
3698
|
+
toEntity(row) {
|
|
3699
|
+
return { id: asString(row.id), kind: asString(row.kind), props: parseProps(row.props) };
|
|
3700
|
+
}
|
|
3701
|
+
toRelation(row) {
|
|
3702
|
+
return {
|
|
3703
|
+
src: asString(row.src),
|
|
3704
|
+
dst: asString(row.dst),
|
|
3705
|
+
kind: asString(row.kind),
|
|
3706
|
+
props: parseProps(row.props)
|
|
3707
|
+
};
|
|
3708
|
+
}
|
|
3709
|
+
toNeighbor(row) {
|
|
3710
|
+
const relKinds = this.toStringList(row.rel_kinds);
|
|
3711
|
+
return {
|
|
3712
|
+
id: asString(row.id),
|
|
3713
|
+
kind: asString(row.kind),
|
|
3714
|
+
edgeKind: relKinds.length > 0 ? relKinds[relKinds.length - 1] : "",
|
|
3715
|
+
depth: asNumber(row.hop)
|
|
3716
|
+
};
|
|
3717
|
+
}
|
|
3718
|
+
async entityCount() {
|
|
3719
|
+
const rows = await this.queryInternal(`MATCH (e:${ENTITY_TABLE}) RETURN count(e) AS n;`);
|
|
3720
|
+
return rows.length === 0 ? 0 : asNumber(rows[0].n);
|
|
3721
|
+
}
|
|
3722
|
+
async relationCount() {
|
|
3723
|
+
const rows = await this.queryInternal(`MATCH ()-[r:${RELATION_TABLE}]->() RETURN count(r) AS n;`);
|
|
3724
|
+
return rows.length === 0 ? 0 : asNumber(rows[0].n);
|
|
3725
|
+
}
|
|
3726
|
+
async hasEmbedding(id) {
|
|
3727
|
+
const rows = await this.runInternal(`MATCH (e:${ENTITY_TABLE} {id: $id}) RETURN e.embedding IS NOT NULL AS has;`, { id });
|
|
3728
|
+
return rows.length > 0 && rows[0].has === true;
|
|
3729
|
+
}
|
|
3730
|
+
async close() {
|
|
3731
|
+
if (this.closing) {
|
|
3732
|
+
throw new Error("ladybug bridge close is already in progress");
|
|
3733
|
+
}
|
|
3734
|
+
if (this.initializing) {
|
|
3735
|
+
throw new Error("ladybug bridge close cannot overlap initialization");
|
|
3736
|
+
}
|
|
3737
|
+
if (this.graphRollbackInProgress) {
|
|
3738
|
+
throw new Error("ladybug bridge close cannot overlap graph rollback replay");
|
|
3739
|
+
}
|
|
3740
|
+
if (this.transcriptOperationReservations > 0 || this.transcriptOperationActive) {
|
|
3741
|
+
throw new Error("ladybug bridge close cannot overlap a transcript operation");
|
|
3742
|
+
}
|
|
3743
|
+
if (this.worldMemoryOperationReservations > 0) {
|
|
3744
|
+
throw new Error("ladybug bridge close cannot overlap a world memory host operation");
|
|
3745
|
+
}
|
|
3746
|
+
if (this.tx !== null) {
|
|
3747
|
+
throw new Error("ladybug bridge close requires the active graph transaction to commit or rollback");
|
|
3748
|
+
}
|
|
3749
|
+
this.closing = true;
|
|
3750
|
+
this.ready = false;
|
|
3751
|
+
try {
|
|
3752
|
+
if (this.conn) {
|
|
3753
|
+
await this.conn.close();
|
|
3754
|
+
this.conn = null;
|
|
3755
|
+
}
|
|
3756
|
+
if (this.db) {
|
|
3757
|
+
await this.db.close();
|
|
3758
|
+
this.db = null;
|
|
3759
|
+
}
|
|
3760
|
+
this.vectorIndexBuilt = false;
|
|
3761
|
+
this.ftsIndexBuilt = false;
|
|
3762
|
+
this.vectorIndexedEntityCount = 0;
|
|
3763
|
+
this.ftsIndexedEntityCount = 0;
|
|
3764
|
+
this.memoryEpisodeVectorIndexBuilt = false;
|
|
3765
|
+
this.memoryEpisodeFtsIndexBuilt = false;
|
|
3766
|
+
this.memoryDerivedVectorIndexBuilt = false;
|
|
3767
|
+
this.memoryDerivedFtsIndexBuilt = false;
|
|
3768
|
+
this.transcriptFtsBuilt = false;
|
|
3769
|
+
this.transcriptQueue = Promise.resolve();
|
|
3770
|
+
this.transcriptOperationReservations = 0;
|
|
3771
|
+
this.transcriptOperationActive = false;
|
|
3772
|
+
this.worldMemoryQueue = Promise.resolve();
|
|
3773
|
+
this.worldMemoryOperationReservations = 0;
|
|
3774
|
+
this.worldMemoryRevision = 0;
|
|
3775
|
+
this._writeSeq = 0;
|
|
3776
|
+
}
|
|
3777
|
+
finally {
|
|
3778
|
+
this.closing = false;
|
|
3779
|
+
}
|
|
3780
|
+
}
|
|
3781
|
+
}
|