@memory-river/core 0.2.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.
Files changed (86) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +222 -0
  3. package/README.zh-TW.md +186 -0
  4. package/dist/api.d.ts +100 -0
  5. package/dist/api.js +156 -0
  6. package/dist/cognition/causal-attribution.d.ts +36 -0
  7. package/dist/cognition/causal-attribution.js +239 -0
  8. package/dist/cognition/causal-engine.d.ts +105 -0
  9. package/dist/cognition/causal-engine.js +150 -0
  10. package/dist/cognition/conflict-detector.d.ts +39 -0
  11. package/dist/cognition/conflict-detector.js +193 -0
  12. package/dist/cognition/global-working-memory.d.ts +53 -0
  13. package/dist/cognition/global-working-memory.js +211 -0
  14. package/dist/cognition/hooks-engine.d.ts +99 -0
  15. package/dist/cognition/hooks-engine.js +672 -0
  16. package/dist/cognition/ralph-core.d.ts +28 -0
  17. package/dist/cognition/ralph-core.js +104 -0
  18. package/dist/distill/concentrator-adapter.d.ts +167 -0
  19. package/dist/distill/concentrator-adapter.js +1876 -0
  20. package/dist/engine.d.ts +402 -0
  21. package/dist/engine.js +2254 -0
  22. package/dist/index.d.ts +6 -0
  23. package/dist/index.js +3 -0
  24. package/dist/lifecycle/cleanup-engine.d.ts +80 -0
  25. package/dist/lifecycle/cleanup-engine.js +162 -0
  26. package/dist/lifecycle/cleanup-state.d.ts +34 -0
  27. package/dist/lifecycle/cleanup-state.js +50 -0
  28. package/dist/lifecycle/night-consolidation.d.ts +102 -0
  29. package/dist/lifecycle/night-consolidation.js +640 -0
  30. package/dist/lifecycle/night-recovery.d.ts +40 -0
  31. package/dist/lifecycle/night-recovery.js +107 -0
  32. package/dist/paths.d.ts +17 -0
  33. package/dist/paths.js +16 -0
  34. package/dist/pipeline/capsule-bridge.d.ts +35 -0
  35. package/dist/pipeline/capsule-bridge.js +86 -0
  36. package/dist/pipeline/compact-request.d.ts +30 -0
  37. package/dist/pipeline/compact-request.js +66 -0
  38. package/dist/pipeline/inbox-watcher.d.ts +112 -0
  39. package/dist/pipeline/inbox-watcher.js +1039 -0
  40. package/dist/ports.d.ts +29 -0
  41. package/dist/ports.js +1 -0
  42. package/dist/providers/embedder-v5.d.ts +46 -0
  43. package/dist/providers/embedder-v5.js +155 -0
  44. package/dist/providers/ollama-embedding.d.ts +25 -0
  45. package/dist/providers/ollama-embedding.js +166 -0
  46. package/dist/retrieval/abstractness-judge.d.ts +14 -0
  47. package/dist/retrieval/abstractness-judge.js +87 -0
  48. package/dist/retrieval/coverage-selection.d.ts +3 -0
  49. package/dist/retrieval/coverage-selection.js +53 -0
  50. package/dist/retrieval/cross-encoder-gate.d.ts +40 -0
  51. package/dist/retrieval/cross-encoder-gate.js +239 -0
  52. package/dist/retrieval/retriever-v4.d.ts +78 -0
  53. package/dist/retrieval/retriever-v4.js +1200 -0
  54. package/dist/skills/validate.d.ts +6 -0
  55. package/dist/skills/validate.js +69 -0
  56. package/dist/storage.d.ts +19 -0
  57. package/dist/storage.js +54 -0
  58. package/dist/store/aux-table-maintenance.d.ts +5 -0
  59. package/dist/store/aux-table-maintenance.js +64 -0
  60. package/dist/store/graph-enumerator.d.ts +21 -0
  61. package/dist/store/graph-enumerator.js +185 -0
  62. package/dist/store/graph-store.d.ts +107 -0
  63. package/dist/store/graph-store.js +478 -0
  64. package/dist/store/status-manager.d.ts +44 -0
  65. package/dist/store/status-manager.js +235 -0
  66. package/dist/store/store-v4.d.ts +339 -0
  67. package/dist/store/store-v4.js +2871 -0
  68. package/dist/transcript/keyword-search.d.ts +9 -0
  69. package/dist/transcript/keyword-search.js +67 -0
  70. package/dist/transcript/rehydrate-keyword.d.ts +6 -0
  71. package/dist/transcript/rehydrate-keyword.js +29 -0
  72. package/dist/transcript/rehydrate.d.ts +33 -0
  73. package/dist/transcript/rehydrate.js +285 -0
  74. package/dist/transcript/transcript-archive.d.ts +46 -0
  75. package/dist/transcript/transcript-archive.js +516 -0
  76. package/dist/types.d.ts +409 -0
  77. package/dist/types.js +104 -0
  78. package/dist/util/bounded-map.d.ts +1 -0
  79. package/dist/util/bounded-map.js +8 -0
  80. package/dist/util/rate-limiter.d.ts +12 -0
  81. package/dist/util/rate-limiter.js +54 -0
  82. package/dist/util/session-identity.d.ts +65 -0
  83. package/dist/util/session-identity.js +227 -0
  84. package/dist/util/util-hash.d.ts +1 -0
  85. package/dist/util/util-hash.js +4 -0
  86. package/package.json +59 -0
@@ -0,0 +1,2871 @@
1
+ /**
2
+ * LanceDB Store - RAM + SSD Dual-Write Architecture with WAL
3
+ * memory-river v4
4
+ *
5
+ * 核心原則:
6
+ * - RAM Disk (/dev/shm) 為主要讀寫目標(極速)
7
+ * - SSD 為異步備份(持久化)
8
+ * - WAL (Write-Ahead Log) 保護 update/delete 一致性
9
+ * - store() WAL + RAM 同步,SSD 異步寫入
10
+ * - update/delete 必須 WAL 先行 → 雙寫 → WAL commit
11
+ * - 讀取全部走 RAM(速度優先)
12
+ * - crash recovery replays every WAL change at-least-once, including unacknowledged deletes
13
+ */
14
+ import { randomUUID } from "node:crypto";
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import { Schema, Field, Int64, Utf8, Bool, Float64 } from "apache-arrow";
18
+ import { optimizeAuxTablesInConnection, recordAuxTableWrite, } from "./aux-table-maintenance.js";
19
+ // 動態載入 jieba
20
+ let jieba = null;
21
+ const loadJieba = async () => {
22
+ if (jieba)
23
+ return jieba;
24
+ const module = await import("nodejieba");
25
+ jieba = module.default ?? module;
26
+ return jieba;
27
+ };
28
+ let lancedbImportPromise = null;
29
+ const loadLanceDB = async () => {
30
+ if (!lancedbImportPromise) {
31
+ lancedbImportPromise = import("@lancedb/lancedb");
32
+ }
33
+ return await lancedbImportPromise;
34
+ };
35
+ // ============================================================================
36
+ // LanceDB Store - RAM + SSD Dual-Write
37
+ // ============================================================================
38
+ const TABLE_NAME = "memories";
39
+ const SUBSYSTEM_EFFECTIVENESS_TABLE = "subsystem_effectiveness";
40
+ const CONCENTRATOR_STATS_TABLE = "concentrator_stats";
41
+ const CONFLICT_STATS_TABLE = "conflict_stats";
42
+ const NIGHT_CONSOLIDATION_STATS_TABLE = "night_consolidation_stats";
43
+ const WAL_METADATA_TABLE = "wal_metadata";
44
+ const STATUS_AUDIT_LOG_TABLE = "status_audit_log";
45
+ const TRANSCRIPT_WATERMARK_TABLE = "transcript_watermark";
46
+ const SSD_FAILURE_THRESHOLD = 5;
47
+ const DEFAULT_SSD_RECOVERY_PROBE_INTERVAL_MS = 60_000;
48
+ const VISIBILITY_OVERFETCH = 20;
49
+ const verifiedFtsLabels = new Set();
50
+ export class SchemaViolationError extends Error {
51
+ violations;
52
+ constructor(message, violations) {
53
+ super(message);
54
+ this.violations = violations;
55
+ this.name = "SchemaViolationError";
56
+ }
57
+ }
58
+ // 預設健康度配置
59
+ const DEFAULT_HEALTH_CONFIG = {
60
+ initialScore: 100,
61
+ coreCategories: ["identity", "constraint", "business", "core_rule"],
62
+ coreImportanceThreshold: 0.85,
63
+ skillDecayFactor: 0.25,
64
+ };
65
+ const STRING_UPDATE_FIELDS = new Set([
66
+ "id",
67
+ "text",
68
+ "textTokens",
69
+ "category",
70
+ "parentId",
71
+ "metadata",
72
+ "slotKey",
73
+ "slotValue",
74
+ "extractionDomain",
75
+ "supersedes",
76
+ "sessionId",
77
+ "status",
78
+ ]);
79
+ const BOOLEAN_UPDATE_FIELDS = new Set([
80
+ "hasHooks",
81
+ ]);
82
+ const NUMERIC_UPDATE_FIELDS = new Set([
83
+ "importance",
84
+ "createdAt",
85
+ "updatedAt",
86
+ "confidence",
87
+ "lineCount",
88
+ "lastConcentratedAt",
89
+ "usageCount",
90
+ "lastUsedAt",
91
+ ]);
92
+ export function sqlStringLiteral(value) {
93
+ return `'${String(value).replace(/'/g, "''")}'`;
94
+ }
95
+ export function normalizeLanceUpdateValues(values) {
96
+ const normalized = {};
97
+ for (const [key, value] of Object.entries(values)) {
98
+ if (value === undefined)
99
+ continue;
100
+ if (value === null) {
101
+ normalized[key] = "NULL";
102
+ continue;
103
+ }
104
+ if (STRING_UPDATE_FIELDS.has(key)) {
105
+ normalized[key] = sqlStringLiteral(String(value));
106
+ continue;
107
+ }
108
+ if (NUMERIC_UPDATE_FIELDS.has(key)) {
109
+ const numeric = typeof value === "number" ? value : Number(value);
110
+ normalized[key] = Number.isFinite(numeric) ? String(numeric) : "NULL";
111
+ continue;
112
+ }
113
+ if (BOOLEAN_UPDATE_FIELDS.has(key)) {
114
+ normalized[key] = value ? "TRUE" : "FALSE";
115
+ continue;
116
+ }
117
+ if (typeof value === "string") {
118
+ normalized[key] = sqlStringLiteral(value);
119
+ continue;
120
+ }
121
+ if (typeof value === "number") {
122
+ normalized[key] = Number.isFinite(value) ? String(value) : "NULL";
123
+ continue;
124
+ }
125
+ if (typeof value === "boolean") {
126
+ normalized[key] = value ? "TRUE" : "FALSE";
127
+ continue;
128
+ }
129
+ normalized[key] = value;
130
+ }
131
+ return normalized;
132
+ }
133
+ function isValidRowId(row) {
134
+ return row != null && typeof row.id === 'string' && row.id.length > 0;
135
+ }
136
+ function isVisibleStatus(topStatus, metaStatus) {
137
+ const hiddenStatuses = new Set(["superseded", "deprecated", "trashed", "archived"]);
138
+ return !hiddenStatuses.has(topStatus || "active") && !hiddenStatuses.has(metaStatus || "active");
139
+ }
140
+ function isLanceTableNotFoundError(err) {
141
+ const code = String(err?.code ?? "");
142
+ const name = String(err?.name ?? "");
143
+ const message = String(err?.message ?? err);
144
+ return /TableNotFound/i.test(code)
145
+ || /TableNotFound/i.test(name)
146
+ || /not\s*found|does\s*not\s*exist|TableNotFound/i.test(message);
147
+ }
148
+ function metadataHasHooks(metadata) {
149
+ let meta = {};
150
+ if (typeof metadata === "string") {
151
+ if (metadata.trim() === "")
152
+ return false;
153
+ try {
154
+ meta = JSON.parse(metadata);
155
+ }
156
+ catch {
157
+ return false;
158
+ }
159
+ }
160
+ else if (metadata && typeof metadata === "object") {
161
+ meta = metadata;
162
+ }
163
+ return Array.isArray(meta?.hooks) && meta.hooks.length > 0;
164
+ }
165
+ export class MemoryStore {
166
+ dbPath;
167
+ ramDbPath;
168
+ vectorDim;
169
+ ssdRecoveryProbeIntervalMs;
170
+ // ── 雙寫連接 ────────────────────────────────────────────
171
+ ramDb = null;
172
+ ramTable = null;
173
+ ssdDb = null;
174
+ ssdTable = null;
175
+ subsystemEffectivenessRamTable = null;
176
+ subsystemEffectivenessSsdTable = null;
177
+ concentratorStatsRamTable = null;
178
+ concentratorStatsSsdTable = null;
179
+ conflictStatsRamTable = null;
180
+ conflictStatsSsdTable = null;
181
+ nightConsolidationStatsRamTable = null;
182
+ nightConsolidationStatsSsdTable = null;
183
+ walMetadataRamTable = null;
184
+ walMetadataSsdTable = null;
185
+ statusAuditLogRamTable = null;
186
+ statusAuditLogSsdTable = null;
187
+ transcriptWatermarkRamTable = null;
188
+ transcriptWatermarkSsdTable = null;
189
+ shutdownHooks = [];
190
+ initPromise = null;
191
+ healthConfig = DEFAULT_HEALTH_CONFIG;
192
+ _embedder;
193
+ // ── WAL 相關 ────────────────────────────────────────────
194
+ walDir;
195
+ walPath;
196
+ walRecovered = false; // WAL recovery 只執行一次
197
+ walTxnCounter = 0; // 單調遞增 transaction ID(精確控制 replay 順序)
198
+ lastCheckpointTxnId = 0;
199
+ walCheckpointInitialized = false;
200
+ walCheckpointUpdateQueue = Promise.resolve();
201
+ // ── RAM-Only Mode ───────────────────────────────────────
202
+ ssdAvailable = true;
203
+ ssdConsecutiveFailures = 0;
204
+ ssdRecoveryProbeTimer = null;
205
+ ssdRecoveryProbeInFlight = false;
206
+ ftsAvailable = false;
207
+ ssdFallback;
208
+ constructor(dbPath, // SSD 持久化路徑
209
+ ramDbPath, // RAM Disk 路徑
210
+ vectorDim, walFileOrHealthConfig, healthConfigOrEmbedder, embedder, ssdRecoveryProbeIntervalMs = DEFAULT_SSD_RECOVERY_PROBE_INTERVAL_MS) {
211
+ this.dbPath = dbPath;
212
+ this.ramDbPath = ramDbPath;
213
+ this.vectorDim = vectorDim;
214
+ this.ssdRecoveryProbeIntervalMs = ssdRecoveryProbeIntervalMs;
215
+ this.ssdFallback = path.resolve(dbPath) === path.resolve(ramDbPath);
216
+ const hasInjectedWal = typeof walFileOrHealthConfig === "string";
217
+ const healthConfig = hasInjectedWal
218
+ ? healthConfigOrEmbedder
219
+ : walFileOrHealthConfig;
220
+ this._embedder = hasInjectedWal
221
+ ? embedder
222
+ : healthConfigOrEmbedder;
223
+ this.walPath = hasInjectedWal
224
+ ? walFileOrHealthConfig
225
+ : path.join(path.dirname(dbPath), "wal.jsonl");
226
+ this.walDir = path.dirname(this.walPath);
227
+ if (healthConfig) {
228
+ this.healthConfig = { ...DEFAULT_HEALTH_CONFIG, ...healthConfig };
229
+ }
230
+ }
231
+ // ── HookStats 持久化所需的公開 API ─────────────────────────────────────────
232
+ get db() {
233
+ return this.ramDb;
234
+ }
235
+ /** SSD 持久化連接(供 GraphStore 共用) */
236
+ get ssd() {
237
+ return this.ssdDb;
238
+ }
239
+ onShutdown(fn) {
240
+ this.shutdownHooks.push(fn);
241
+ }
242
+ async ensureInitialized() {
243
+ if (this.ramTable)
244
+ return;
245
+ if (this.initPromise)
246
+ return this.initPromise;
247
+ this._ftsRetokenizingTables ??= new Set();
248
+ this.initPromise = this.doInitialize().catch((err) => {
249
+ this.initPromise = null;
250
+ throw err;
251
+ });
252
+ return this.initPromise;
253
+ }
254
+ async doInitialize() {
255
+ console.log(`[MemoryStore] Initializing... RAM=${this.ramDbPath} SSD=${this.dbPath}`);
256
+ // ── Step 1: 確保 RAM 目錄存在 ──────────────────────
257
+ const ramDir = this.ramDbPath;
258
+ try {
259
+ fs.mkdirSync(ramDir, { recursive: true });
260
+ }
261
+ catch (err) {
262
+ console.error(`[MemoryStore] Failed to create RAM directory ${ramDir}:`, err.message);
263
+ throw err;
264
+ }
265
+ // ── Step 2: 確保 WAL 目錄存在 ─────────────────────
266
+ try {
267
+ fs.mkdirSync(this.walDir, { recursive: true });
268
+ }
269
+ catch { }
270
+ // ── Step 3: Hydration - 如果 RAM 是空的,從 SSD 拷貝 ─
271
+ const ramContents = fs.readdirSync(ramDir);
272
+ if (this.ssdFallback) {
273
+ console.log('[MemoryStore] SSD fallback active; using one persistent store');
274
+ }
275
+ else if (ramContents.length === 0) {
276
+ console.log('[MemoryStore] RAM directory is empty; starting hydration from SSD...');
277
+ if (fs.existsSync(this.dbPath)) {
278
+ fs.cpSync(this.dbPath, ramDir, { recursive: true });
279
+ console.log('[MemoryStore] Hydration complete');
280
+ }
281
+ else {
282
+ console.log('[MemoryStore] SSD path does not exist; creating a new database');
283
+ // 確保 SSD 目錄也存在
284
+ fs.mkdirSync(this.dbPath, { recursive: true });
285
+ }
286
+ }
287
+ else {
288
+ console.log(`[MemoryStore] RAM directory contains data; using it without hydration`);
289
+ }
290
+ // ── Step 4: 建立雙連接 ──────────────────────────────
291
+ const lancedb = await loadLanceDB();
292
+ this.ramDb = await lancedb.connect(this.ramDbPath);
293
+ this.ssdDb = this.ssdFallback ? this.ramDb : await lancedb.connect(this.dbPath);
294
+ if (this.ssdFallback)
295
+ this.ssdAvailable = false;
296
+ // ── Step 5: 開啟雙 Table ────────────────────────────
297
+ this.ramTable = await this.initTable(this.ramDb, "ram");
298
+ this.ssdTable = this.ssdFallback ? this.ramTable : await this.initTable(this.ssdDb, "ssd");
299
+ await this.ensureWalMetadataTables();
300
+ await this.cleanupLegacyWalMetadataRow();
301
+ await this.restoreWalTxnCounter();
302
+ // ── Step 6: WAL Recovery(只執行一次)──────────────
303
+ if (!this.walRecovered) {
304
+ console.log('[MemoryStore] Checking WAL recovery...');
305
+ await this.recoverFromWal();
306
+ this.walRecovered = true;
307
+ }
308
+ // ── Step 7: Migration - 修複 parentId 欄位(只執行一次)─
309
+ if (!this._parentIdMigrationDone) {
310
+ console.log('[MemoryStore] Running parentId field repair...');
311
+ try {
312
+ const all = await this.queryAll(10000);
313
+ let fixed = 0;
314
+ for (const entry of all) {
315
+ if (entry.parentId)
316
+ continue; // already has value
317
+ try {
318
+ const meta = typeof entry.metadata === 'string'
319
+ ? JSON.parse(entry.metadata)
320
+ : (entry.metadata || {});
321
+ if (meta?.parentId) {
322
+ await this.update(entry.id, { parentId: meta.parentId });
323
+ fixed++;
324
+ }
325
+ }
326
+ catch { /* ignore */ }
327
+ }
328
+ console.log(`[MemoryStore] parentId field repair complete: ${fixed} records repaired`);
329
+ }
330
+ catch (err) {
331
+ console.warn('[MemoryStore] parentId field repair failed (non-fatal):', err.message);
332
+ }
333
+ this._parentIdMigrationDone = true;
334
+ }
335
+ await this.initSubsystemEffectivenessTable();
336
+ await this.ensureConcentratorStatsTables();
337
+ await this.ensureConflictStatsTables();
338
+ await this.ensureNightConsolidationStatsTables().catch((err) => {
339
+ console.warn("[MemoryStore] night_consolidation_stats initialization failed:", err?.message ?? err);
340
+ });
341
+ await this.ensureStatusAuditLogTables();
342
+ await this.ensureTranscriptWatermarkTables();
343
+ // ── Step 9: 確保 memories table 有 status column(P0-3 schema migration)─
344
+ await this.ensureStatusColumn();
345
+ console.log('[MemoryStore] Initialization complete');
346
+ }
347
+ async initTable(db, label) {
348
+ const tables = await db.tableNames();
349
+ let table;
350
+ if (tables.includes(TABLE_NAME)) {
351
+ table = await db.openTable(TABLE_NAME);
352
+ console.log(`[MemoryStore] [${label}] Opened existing table`);
353
+ }
354
+ else {
355
+ console.log(`[MemoryStore] [${label}] Creating new table...`);
356
+ const initialData = [{
357
+ id: "init_00000000000000000000000000000000",
358
+ text: "_SYSTEM_INIT_",
359
+ textTokens: "_SYSTEM_INIT_",
360
+ vector: Array(this.vectorDim).fill(0),
361
+ importance: 0.0,
362
+ category: "other",
363
+ parentId: "",
364
+ metadata: "{}",
365
+ createdAt: 0,
366
+ updatedAt: 0,
367
+ slotKey: "",
368
+ slotValue: "",
369
+ confidence: 0.0,
370
+ extractionDomain: "",
371
+ supersedes: "[]", // JSON string,與 LanceDB addColumns 保持一致
372
+ hasHooks: false,
373
+ }];
374
+ table = await db.createTable(TABLE_NAME, initialData);
375
+ console.log(`[MemoryStore] [${label}] New table created`);
376
+ }
377
+ // 確保 FTS index
378
+ await this.ensureFtsIndex(table, label);
379
+ await this.ensureHasHooksColumnAndIndex(table, label);
380
+ return table;
381
+ }
382
+ async ensureHasHooksColumnAndIndex(table, label) {
383
+ const lancedb = await loadLanceDB();
384
+ try {
385
+ const currentSchema = await table.schema();
386
+ const hasColumn = currentSchema.fields.some((field) => field.name === "hasHooks");
387
+ if (!hasColumn) {
388
+ await table.addColumns([{ name: "hasHooks", valueSql: "false" }]);
389
+ console.log(`[MemoryStore] [${label}] hasHooks column added (default false)`);
390
+ }
391
+ const existingIndices = await table.listIndices();
392
+ const hasIndex = existingIndices.some((index) => index.columns?.includes("hasHooks"));
393
+ if (!hasIndex) {
394
+ await table.createIndex("hasHooks", {
395
+ config: lancedb.Index.btree(),
396
+ replace: true,
397
+ });
398
+ console.log(`[MemoryStore] [${label}] hasHooks scalar index created`);
399
+ }
400
+ }
401
+ catch (err) {
402
+ console.warn(`[MemoryStore] [${label}] hasHooks column/index migration failed (non-fatal): ${err?.message ?? err}`);
403
+ }
404
+ }
405
+ async ensureFtsIndex(table, label) {
406
+ const lancedb = await loadLanceDB();
407
+ try {
408
+ const existingIndices = await table.listIndices();
409
+ const hasTextTokensFts = existingIndices.some((index) => index.indexType === "FTS" && index.columns?.includes("textTokens"));
410
+ if (!hasTextTokensFts) {
411
+ const idx = lancedb.Index.fts();
412
+ await table.createIndex("textTokens", {
413
+ config: idx,
414
+ replace: true,
415
+ });
416
+ }
417
+ // 立刻驗證(index 名稱為 column 名 + "_idx")
418
+ const indices = await table.listIndices();
419
+ const textTokensIdx = indices.find((i) => i.columns?.includes("textTokens"));
420
+ if (!textTokensIdx) {
421
+ throw new Error(`FTS index on column 'textTokens' not found. Available: ${JSON.stringify(indices)}`);
422
+ }
423
+ if (textTokensIdx.indexType !== "FTS") {
424
+ throw new Error(`FTS index type mismatch: expected FTS, got ${textTokensIdx.indexType}`);
425
+ }
426
+ if (!verifiedFtsLabels.has(label)) {
427
+ console.log(`[MemoryStore] [${label}] FTS index validation passed (name=${textTokensIdx.name}, FTS)`);
428
+ verifiedFtsLabels.add(label);
429
+ }
430
+ this.ftsAvailable = true;
431
+ if (!hasTextTokensFts) {
432
+ const retokenizingTables = this._ftsRetokenizingTables ??= new Set();
433
+ if (!retokenizingTables.has(table)) {
434
+ retokenizingTables.add(table);
435
+ void (async () => {
436
+ const batchSize = 50;
437
+ let offset = 0;
438
+ let scannedRows = 0;
439
+ let migratedRows = 0;
440
+ console.log(`[MemoryStore] [${label}] Background multilingual FTS retokenization started`);
441
+ try {
442
+ await new Promise(resolve => setImmediate(resolve));
443
+ while (true) {
444
+ const rows = await table.query()
445
+ .select(["id", "text", "textTokens"])
446
+ .offset(offset)
447
+ .limit(batchSize)
448
+ .toArray();
449
+ if (rows.length === 0)
450
+ break;
451
+ for (const row of rows) {
452
+ const textTokens = await this.tokenizeChinese(String(row.text ?? ""));
453
+ if (textTokens === String(row.textTokens ?? ""))
454
+ continue;
455
+ await table.update({
456
+ values: { textTokens },
457
+ where: `\`id\` = ${sqlStringLiteral(String(row.id))}`,
458
+ });
459
+ migratedRows++;
460
+ }
461
+ scannedRows += rows.length;
462
+ offset += rows.length;
463
+ console.log(`[MemoryStore] [${label}] Background FTS retokenization progress: ` +
464
+ `scanned=${scannedRows} updated=${migratedRows}`);
465
+ if (rows.length < batchSize)
466
+ break;
467
+ await new Promise(resolve => setImmediate(resolve));
468
+ }
469
+ console.log(`[MemoryStore] [${label}] Background multilingual FTS retokenization complete: ` +
470
+ `scanned=${scannedRows} updated=${migratedRows}`);
471
+ }
472
+ catch (err) {
473
+ console.warn(`[MemoryStore] [${label}] Background FTS retokenization stopped:`, err?.message ?? err);
474
+ }
475
+ finally {
476
+ retokenizingTables.delete(table);
477
+ }
478
+ })();
479
+ }
480
+ }
481
+ }
482
+ catch (err) {
483
+ console.error(`[MemoryStore] [${label}] Failed to create FTS index:`);
484
+ console.error(' error:', err);
485
+ console.error(' message:', err?.message);
486
+ console.error(' stack:', err?.stack);
487
+ throw err; // 驗證失敗直接拋,不要吞
488
+ }
489
+ }
490
+ // ============================================================================
491
+ // WAL 系統
492
+ // ============================================================================
493
+ /**
494
+ * 寫入一筆 WAL 條目,自動附加單調遞增的 txnId。
495
+ * ⚠️ txnId 是精確的 replay 順序控制依據,不可重複使用。
496
+ */
497
+ async appendWal(entry) {
498
+ const txnId = ++this.walTxnCounter;
499
+ try {
500
+ const line = JSON.stringify({ ...entry, txnId, timestamp: Date.now() }) + '\n';
501
+ const fh = await fs.promises.open(this.walPath, 'a');
502
+ try {
503
+ await fh.appendFile(line, 'utf-8');
504
+ await fh.datasync();
505
+ }
506
+ finally {
507
+ await fh.close();
508
+ }
509
+ }
510
+ catch (err) {
511
+ console.error('[MemoryStore] WAL append failed:', err.message);
512
+ throw err;
513
+ }
514
+ return txnId;
515
+ }
516
+ async restoreWalTxnCounter() {
517
+ let maxWalTxnId = 0;
518
+ if (fs.existsSync(this.walPath)) {
519
+ try {
520
+ const content = await fs.promises.readFile(this.walPath, 'utf-8');
521
+ for (const line of content.trim().split('\n').filter(Boolean)) {
522
+ try {
523
+ const txnId = Number(JSON.parse(line).txnId ?? 0);
524
+ if (txnId > maxWalTxnId)
525
+ maxWalTxnId = txnId;
526
+ }
527
+ catch { }
528
+ }
529
+ }
530
+ catch { }
531
+ }
532
+ const lastCommitted = await this.getLastCommittedTxnId();
533
+ this.lastCheckpointTxnId = lastCommitted;
534
+ this.walCheckpointInitialized = true;
535
+ this.walTxnCounter = Math.max(this.walTxnCounter, maxWalTxnId, lastCommitted);
536
+ }
537
+ /**
538
+ * 更新 wal_metadata 表的 last_committed_txn_id。
539
+ * 寫入後即表示這筆 txnId 及之前的操作已安全落地。
540
+ * ⚠️ 必須在 WAL commit line 寫入磁碟成功後才能更新(嚴格 ordered)。
541
+ */
542
+ async updateWalMetadata(lastTxnId) {
543
+ const update = this.walCheckpointUpdateQueue.then(async () => {
544
+ const nextCheckpointTxnId = Math.max(lastTxnId, this.lastCheckpointTxnId);
545
+ if (this.walCheckpointInitialized && nextCheckpointTxnId === this.lastCheckpointTxnId) {
546
+ return;
547
+ }
548
+ try {
549
+ await this.ensureWalMetadataTables();
550
+ const now = Date.now();
551
+ const metaRow = {
552
+ id: "checkpoint",
553
+ last_committed_txn_id: nextCheckpointTxnId,
554
+ updatedAt: now,
555
+ };
556
+ const lancedb = await loadLanceDB();
557
+ const arrowTable = lancedb.makeArrowTable
558
+ ? lancedb.makeArrowTable([metaRow], {
559
+ schema: new Schema([
560
+ new Field("id", new Utf8(), false),
561
+ new Field("last_committed_txn_id", new Int64()),
562
+ new Field("updatedAt", new Int64()),
563
+ ]),
564
+ })
565
+ : [metaRow];
566
+ // RAM / SSD 都保留同一份 checkpoint。現行 recovery 先從 SSD hydrate 到 RAM,
567
+ // 再由 RAM 進行讀取;雙寫可避免兩側 checkpoint 漂移。
568
+ await this.walMetadataRamTable
569
+ .mergeInsert(["id"])
570
+ .whenMatchedUpdateAll()
571
+ .whenNotMatchedInsertAll()
572
+ .execute(arrowTable);
573
+ await recordAuxTableWrite(this.walMetadataRamTable, "ram:wal_metadata");
574
+ if (this.ssdAvailable && this.walMetadataSsdTable) {
575
+ await this.walMetadataSsdTable
576
+ .mergeInsert(["id"])
577
+ .whenMatchedUpdateAll()
578
+ .whenNotMatchedInsertAll()
579
+ .execute(arrowTable);
580
+ await recordAuxTableWrite(this.walMetadataSsdTable, "ssd:wal_metadata");
581
+ }
582
+ this.lastCheckpointTxnId = nextCheckpointTxnId;
583
+ this.walCheckpointInitialized = true;
584
+ console.log(`[MemoryStore] [debug] WAL checkpoint persisted: txnId=${nextCheckpointTxnId}`);
585
+ }
586
+ catch (err) {
587
+ console.error('[MemoryStore] WAL checkpoint persistence failed:', err.message);
588
+ }
589
+ });
590
+ this.walCheckpointUpdateQueue = update;
591
+ await update;
592
+ }
593
+ /**
594
+ * 查詢目前已 commit 的最大 txnId(用於 recovery 起點)。
595
+ * 回傳 0 表示尚無任何 commit 記錄。
596
+ */
597
+ async getLastCommittedTxnId() {
598
+ try {
599
+ await this.ensureWalMetadataTables();
600
+ const result = await this.walMetadataRamTable
601
+ .query()
602
+ .where('id = "checkpoint"')
603
+ .limit(1)
604
+ .toArray();
605
+ if (result.length > 0 && result[0].last_committed_txn_id !== undefined) {
606
+ return Number(result[0].last_committed_txn_id);
607
+ }
608
+ }
609
+ catch { }
610
+ return 0;
611
+ }
612
+ async commitWal(id, txnId) {
613
+ try {
614
+ const line = JSON.stringify({ action: "commit", id, txnId, timestamp: Date.now() }) + '\n';
615
+ const fh = await fs.promises.open(this.walPath, 'a');
616
+ try {
617
+ await fh.appendFile(line, 'utf-8');
618
+ await fh.datasync();
619
+ }
620
+ finally {
621
+ await fh.close();
622
+ }
623
+ await this.updateWalMetadata(txnId);
624
+ }
625
+ catch (err) {
626
+ console.error('[MemoryStore] WAL commit failed:', err.message);
627
+ throw err;
628
+ }
629
+ }
630
+ /**
631
+ * WAL Recovery - 從上次已知的安全 checkpoint 開始 replay。
632
+ *
633
+ * At-least-once recovery:
634
+ * 1. 所有已進 WAL 的變更都以冪等方式 replay(包括尚未 ack 的操作)
635
+ * 2. 每筆 replay 成功後,立即更新 last_committed_txn_id(寫入 wal_metadata)
636
+ * 3. 若 replay 到一半再次當機,下次重啟會重試保留的 WAL 條目
637
+ *
638
+ * ⚠️ txnId 小的先 replay(嚴格ordered),避免因果鏈順序錯亂。
639
+ */
640
+ async recoverFromWal() {
641
+ try {
642
+ if (!fs.existsSync(this.walPath)) {
643
+ return;
644
+ }
645
+ const content = await fs.promises.readFile(this.walPath, 'utf-8');
646
+ const lines = content.trim().split('\n').filter(Boolean);
647
+ if (lines.length === 0) {
648
+ return;
649
+ }
650
+ // 讀取上次已確認的 checkpoint
651
+ const lastCommitted = await this.getLastCommittedTxnId();
652
+ this.lastCheckpointTxnId = lastCommitted;
653
+ this.walCheckpointInitialized = true;
654
+ console.log(`[MemoryStore] WAL recovery: ${lines.length} records, previous checkpoint txnId=${lastCommitted}`);
655
+ // 解析並依 txnId 排序(嚴格 ordered replay)
656
+ const entries = [];
657
+ for (const line of lines) {
658
+ try {
659
+ entries.push(JSON.parse(line));
660
+ }
661
+ catch { }
662
+ }
663
+ entries.sort((a, b) => (a.txnId ?? 0) - (b.txnId ?? 0));
664
+ let replayCount = 0;
665
+ let newLastCommitted = lastCommitted;
666
+ let replayFailed = false;
667
+ let failedTxnId = 0;
668
+ for (const entry of entries) {
669
+ const txnId = entry.txnId ?? 0;
670
+ // 單筆操作的 checkpoint 會在 SSD fire-and-forget 完成前推進,因此即使 txn 已
671
+ // checkpoint,仍須以 idempotent replay 確認 RAM/SSD 都已套用。
672
+ if (txnId <= lastCommitted && entry.action === 'batch_update') {
673
+ continue;
674
+ }
675
+ // commit 條目:更新 checkpoint
676
+ if (entry.action === 'commit') {
677
+ newLastCommitted = Math.max(newLastCommitted, txnId);
678
+ await this.updateWalMetadata(newLastCommitted);
679
+ console.log(`[MemoryStore] WAL commit checkpoint: txnId=${txnId}`);
680
+ continue;
681
+ }
682
+ // insert/update/delete/batch_update:執行 replay
683
+ const op = entry;
684
+ try {
685
+ console.log(`[MemoryStore] Replaying [${op.action}] id=${op.id || 'batch'} txnId=${txnId}...`);
686
+ if (op.action === 'insert') {
687
+ this.validateId(op.id);
688
+ if (!op.row || op.row.id !== op.id) {
689
+ throw new Error(`Invalid WAL insert row for id=${op.id}`);
690
+ }
691
+ const addIfMissing = async (table) => {
692
+ const existingRows = await table.countRows(`id = '${op.id}'`);
693
+ if (existingRows === 0) {
694
+ const row = { ...op.row };
695
+ try {
696
+ const schema = await table.schema?.();
697
+ if (schema?.fields?.some((field) => field.name === "hasHooks")) {
698
+ row.hasHooks = metadataHasHooks(row.metadata);
699
+ }
700
+ }
701
+ catch { }
702
+ await table.add([row]);
703
+ }
704
+ };
705
+ await addIfMissing(this.ramTable);
706
+ if (!this.ssdAvailable && !this.ssdFallback) {
707
+ throw new Error('SSD unavailable during insert replay');
708
+ }
709
+ if (!this.ssdFallback) {
710
+ await addIfMissing(this.ssdTable);
711
+ }
712
+ }
713
+ else if (op.action === 'update') {
714
+ this.validateId(op.id);
715
+ // F2:WAL 的 update values 可能含 vector 原始陣列——vector 必須走
716
+ // values 多載、其餘欄位走 SQL 表達式多載,且同一張表的兩次 update
717
+ // 要序列化(同 update() 本體,避免互搶 dataset version)。
718
+ const { vector: replayVector, ...restReplayValues } = (op.values ?? {});
719
+ const replayValues = normalizeLanceUpdateValues(restReplayValues);
720
+ if (!this.ssdAvailable && !this.ssdFallback) {
721
+ throw new Error('SSD unavailable during update replay');
722
+ }
723
+ const applyUpdate = async (table) => {
724
+ await table.update(replayValues, { where: `id = '${op.id}'` });
725
+ if (Array.isArray(replayVector)) {
726
+ await table.update({ where: `id = '${op.id}'`, values: { vector: replayVector } });
727
+ }
728
+ };
729
+ if (this.ssdFallback) {
730
+ await applyUpdate(this.ramTable);
731
+ }
732
+ else {
733
+ await Promise.all([
734
+ applyUpdate(this.ramTable),
735
+ applyUpdate(this.ssdTable),
736
+ ]);
737
+ }
738
+ }
739
+ else if (op.action === 'delete') {
740
+ this.validateId(op.id);
741
+ if (!this.ssdAvailable && !this.ssdFallback) {
742
+ throw new Error('SSD unavailable during delete replay');
743
+ }
744
+ if (this.ssdFallback) {
745
+ await this.ramTable.delete(`id = '${op.id}'`);
746
+ }
747
+ else {
748
+ await Promise.all([
749
+ this.ramTable.delete(`id = '${op.id}'`),
750
+ this.ssdTable.delete(`id = '${op.id}'`),
751
+ ]);
752
+ }
753
+ }
754
+ else if (op.action === 'batch_update' && Array.isArray(op.entries)) {
755
+ // P1 Fix #5: batch_update recovery
756
+ const failedIds = [];
757
+ for (const batchEntry of op.entries) {
758
+ try {
759
+ this.validateId(batchEntry.id);
760
+ const replayValues = normalizeLanceUpdateValues({
761
+ metadata: batchEntry.metadata,
762
+ hasHooks: metadataHasHooks(batchEntry.metadata),
763
+ updatedAt: Date.now(),
764
+ });
765
+ await this.ramTable.update(replayValues, { where: `id = '${batchEntry.id}'` });
766
+ if (this.ssdAvailable) {
767
+ await this.ssdTable.update(replayValues, { where: `id = '${batchEntry.id}'` });
768
+ }
769
+ }
770
+ catch (batchErr) {
771
+ failedIds.push(batchEntry.id);
772
+ console.warn(`[MemoryStore] batch_update replay failed for entry id=${batchEntry.id}:`, batchErr.message);
773
+ }
774
+ }
775
+ if (failedIds.length > 0) {
776
+ throw new Error(`batch_update replay failed for ids: ${failedIds.join(', ')}`);
777
+ }
778
+ }
779
+ // replay 成功,立即更新 checkpoint(下次當機從這裡繼續)
780
+ newLastCommitted = Math.max(newLastCommitted, txnId);
781
+ await this.updateWalMetadata(newLastCommitted);
782
+ console.log(`[MemoryStore] Replay succeeded [${op.action}] id=${op.id} txnId=${txnId}, checkpoint updated to ${newLastCommitted}`);
783
+ replayCount++;
784
+ }
785
+ catch (err) {
786
+ // replay 失敗停在這裡,下次重啟會再試
787
+ console.error(`[MemoryStore] Replay failed [${op.action}] id=${op.id} txnId=${txnId}: ${err.message}`);
788
+ console.error('[MemoryStore] Stopping recovery; the next restart will resume from the checkpoint');
789
+ replayFailed = true;
790
+ failedTxnId = txnId;
791
+ break;
792
+ }
793
+ }
794
+ if (replayFailed) {
795
+ const remainingEntries = entries.filter((entry) => (entry.txnId ?? 0) >= failedTxnId);
796
+ await this.rewriteWal(remainingEntries);
797
+ console.log(`[MemoryStore] WAL recovery incomplete, keeping ${remainingEntries.length} records for next retry`);
798
+ return;
799
+ }
800
+ if (replayCount === 0) {
801
+ console.log('[MemoryStore] All WAL operations executed or confirmed; clearing WAL');
802
+ }
803
+ else {
804
+ console.log(`[MemoryStore] WAL recovery complete; replayed ${replayCount} records`);
805
+ }
806
+ await this.clearWal();
807
+ }
808
+ catch (err) {
809
+ console.error('[MemoryStore] WAL recovery error:', err.message);
810
+ }
811
+ }
812
+ async rewriteWal(entries) {
813
+ const content = entries.length > 0
814
+ ? entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n'
815
+ : '';
816
+ const tempPath = `${this.walPath}.recovery-${process.pid}-${Date.now()}`;
817
+ try {
818
+ const fh = await fs.promises.open(tempPath, 'wx');
819
+ try {
820
+ await fh.writeFile(content, 'utf-8');
821
+ await fh.datasync();
822
+ }
823
+ finally {
824
+ await fh.close();
825
+ }
826
+ await fs.promises.rename(tempPath, this.walPath);
827
+ }
828
+ catch (err) {
829
+ await fs.promises.rm(tempPath, { force: true });
830
+ throw err;
831
+ }
832
+ }
833
+ async clearWal() {
834
+ try {
835
+ await fs.promises.writeFile(this.walPath, '', 'utf-8');
836
+ }
837
+ catch { }
838
+ }
839
+ // ============================================================================
840
+ // RAM-Only Mode Handler
841
+ // ============================================================================
842
+ handleSsdSuccess() {
843
+ this.ssdConsecutiveFailures = 0;
844
+ }
845
+ handleSsdError(err, operation) {
846
+ if (!this.ssdAvailable)
847
+ return;
848
+ this.ssdConsecutiveFailures++;
849
+ if (this.ssdConsecutiveFailures < SSD_FAILURE_THRESHOLD) {
850
+ console.warn(`[MemoryStore] SSD operation failed (${operation}); consecutive failures ${this.ssdConsecutiveFailures}/${SSD_FAILURE_THRESHOLD}. Error: ${err?.message ?? err}`);
851
+ return;
852
+ }
853
+ this.ssdAvailable = false;
854
+ console.log(`[MemoryStore] SSD failed ${SSD_FAILURE_THRESHOLD} consecutive times; switching to RAM-Only Mode. Last operation: ${operation}. Error: ${err?.message ?? err}`);
855
+ this.startSsdRecoveryProbe();
856
+ // TODO: 未來可發送 Discord 警告
857
+ }
858
+ startSsdRecoveryProbe() {
859
+ if (this.ssdRecoveryProbeTimer)
860
+ return;
861
+ this.ssdRecoveryProbeTimer = setInterval(() => {
862
+ void this.probeSsdRecovery();
863
+ }, this.ssdRecoveryProbeIntervalMs);
864
+ this.ssdRecoveryProbeTimer.unref();
865
+ }
866
+ async probeSsdRecovery() {
867
+ if (this.ssdAvailable || this.ssdRecoveryProbeInFlight || !this.ssdTable)
868
+ return;
869
+ this.ssdRecoveryProbeInFlight = true;
870
+ try {
871
+ await this.ssdTable.countRows();
872
+ this.ssdAvailable = true;
873
+ this.handleSsdSuccess();
874
+ this.stopSsdRecoveryProbe();
875
+ console.log('[MemoryStore] SSD recovery probe succeeded; leaving RAM-Only Mode');
876
+ }
877
+ catch (err) {
878
+ console.warn(`[MemoryStore] SSD recovery probe failed; retries will continue. Error: ${err?.message ?? err}`);
879
+ }
880
+ finally {
881
+ this.ssdRecoveryProbeInFlight = false;
882
+ }
883
+ }
884
+ stopSsdRecoveryProbe() {
885
+ if (!this.ssdRecoveryProbeTimer)
886
+ return;
887
+ clearInterval(this.ssdRecoveryProbeTimer);
888
+ this.ssdRecoveryProbeTimer = null;
889
+ }
890
+ async ensureConcentratorStatsTable(db, cached, label) {
891
+ if (cached)
892
+ return cached;
893
+ const tableName = CONCENTRATOR_STATS_TABLE;
894
+ const schema = new Schema([
895
+ new Field("id", new Utf8(), false),
896
+ new Field("canonicalKey", new Utf8(), false),
897
+ new Field("sessionId", new Utf8(), true),
898
+ new Field("provider", new Utf8(), false),
899
+ new Field("outcome", new Utf8(), false),
900
+ new Field("attemptedProviders", new Utf8(), false),
901
+ new Field("inputTokens", new Int64(), false),
902
+ new Field("outputTokens", new Int64(), true),
903
+ new Field("durationMs", new Int64(), false),
904
+ new Field("failureReason", new Utf8(), true),
905
+ new Field("createdAt", new Int64(), false),
906
+ ]);
907
+ try {
908
+ const table = await db.openTable(tableName);
909
+ const currentSchema = await table.schema();
910
+ const existingFields = new Set(currentSchema.fields.map((field) => field.name));
911
+ const missingColumns = [
912
+ { name: "canonicalKey", valueSql: "'unknown'" },
913
+ { name: "sessionId", valueSql: "CAST(NULL AS string)" },
914
+ { name: "provider", valueSql: "'all_failed'" },
915
+ { name: "attemptedProviders", valueSql: "'[]'" },
916
+ { name: "inputTokens", valueSql: "0" },
917
+ { name: "outputTokens", valueSql: "CAST(NULL AS BIGINT)" },
918
+ { name: "durationMs", valueSql: "0" },
919
+ { name: "failureReason", valueSql: "CAST(NULL AS string)" },
920
+ { name: "createdAt", valueSql: "0" },
921
+ ].filter((column) => !existingFields.has(column.name));
922
+ if (missingColumns.length > 0) {
923
+ await table.addColumns(missingColumns);
924
+ console.log(`[MemoryStore] [${label}] concentrator_stats columns added: ${missingColumns.map(c => c.name).join(",")}`);
925
+ return await db.openTable(tableName);
926
+ }
927
+ return table;
928
+ }
929
+ catch (err) {
930
+ if (!isLanceTableNotFoundError(err))
931
+ throw err;
932
+ await db.createEmptyTable(tableName, schema);
933
+ const table = await db.openTable(tableName);
934
+ console.log(`[MemoryStore] [${label}] concentrator_stats table created`);
935
+ return table;
936
+ }
937
+ }
938
+ async ensureConcentratorStatsTables() {
939
+ this.concentratorStatsRamTable = await this.ensureConcentratorStatsTable(this.ramDb, this.concentratorStatsRamTable, "ram");
940
+ if (this.ssdAvailable) {
941
+ try {
942
+ this.concentratorStatsSsdTable = await this.ensureConcentratorStatsTable(this.ssdDb, this.concentratorStatsSsdTable, "ssd");
943
+ this.handleSsdSuccess();
944
+ }
945
+ catch (err) {
946
+ this.handleSsdError(err, "ensure_concentrator_stats_table");
947
+ }
948
+ }
949
+ }
950
+ async ensureConflictStatsTable(db, cached, label) {
951
+ if (cached)
952
+ return cached;
953
+ const schema = new Schema([
954
+ new Field("ts", new Int64(), false),
955
+ new Field("operationName", new Utf8(), false),
956
+ new Field("callerPath", new Utf8(), false),
957
+ new Field("attempt", new Int64(), false),
958
+ new Field("finalOutcome", new Utf8(), false),
959
+ new Field("fragmentId", new Utf8(), true),
960
+ ]);
961
+ try {
962
+ return await db.openTable(CONFLICT_STATS_TABLE);
963
+ }
964
+ catch (err) {
965
+ if (!isLanceTableNotFoundError(err))
966
+ throw err;
967
+ await db.createEmptyTable(CONFLICT_STATS_TABLE, schema);
968
+ const table = await db.openTable(CONFLICT_STATS_TABLE);
969
+ console.log(`[MemoryStore] [${label}] conflict_stats table created`);
970
+ return table;
971
+ }
972
+ }
973
+ async ensureConflictStatsTables() {
974
+ this.conflictStatsRamTable = await this.ensureConflictStatsTable(this.ramDb, this.conflictStatsRamTable, "ram");
975
+ if (this.ssdAvailable) {
976
+ try {
977
+ this.conflictStatsSsdTable = await this.ensureConflictStatsTable(this.ssdDb, this.conflictStatsSsdTable, "ssd");
978
+ this.handleSsdSuccess();
979
+ }
980
+ catch (err) {
981
+ this.handleSsdError(err, "ensure_conflict_stats_table");
982
+ }
983
+ }
984
+ }
985
+ async ensureNightConsolidationStatsTable(db, cached, label) {
986
+ if (cached)
987
+ return cached;
988
+ const schema = new Schema([
989
+ new Field("id", new Utf8(), false),
990
+ new Field("runId", new Utf8(), false),
991
+ new Field("phase", new Utf8(), false),
992
+ new Field("ts", new Int64(), false),
993
+ new Field("outcome", new Utf8(), true),
994
+ new Field("durationMs", new Int64(), true),
995
+ new Field("candidateCount", new Int64(), true),
996
+ new Field("scannedCount", new Int64(), true),
997
+ new Field("decisionCount", new Int64(), true),
998
+ new Field("mergeCount", new Int64(), true),
999
+ new Field("deleteCount", new Int64(), true),
1000
+ new Field("deprecatedCount", new Int64(), true),
1001
+ new Field("updateCount", new Int64(), true),
1002
+ new Field("keepCount", new Int64(), true),
1003
+ new Field("attemptedCount", new Int64(), true),
1004
+ new Field("failedCount", new Int64(), true),
1005
+ new Field("batchIndex", new Int64(), true),
1006
+ new Field("batchSize", new Int64(), true),
1007
+ new Field("driftMs", new Int64(), true),
1008
+ new Field("scheduledFor", new Int64(), true),
1009
+ new Field("errorMessage", new Utf8(), true),
1010
+ new Field("metadata", new Utf8(), true),
1011
+ ]);
1012
+ try {
1013
+ return await db.openTable(NIGHT_CONSOLIDATION_STATS_TABLE);
1014
+ }
1015
+ catch (err) {
1016
+ if (!isLanceTableNotFoundError(err))
1017
+ throw err;
1018
+ await db.createEmptyTable(NIGHT_CONSOLIDATION_STATS_TABLE, schema);
1019
+ const table = await db.openTable(NIGHT_CONSOLIDATION_STATS_TABLE);
1020
+ console.log(`[MemoryStore] [${label}] night_consolidation_stats table created`);
1021
+ return table;
1022
+ }
1023
+ }
1024
+ async ensureNightConsolidationStatsTables() {
1025
+ this.nightConsolidationStatsRamTable = await this.ensureNightConsolidationStatsTable(this.ramDb, this.nightConsolidationStatsRamTable, "ram");
1026
+ if (this.ssdAvailable) {
1027
+ try {
1028
+ this.nightConsolidationStatsSsdTable = await this.ensureNightConsolidationStatsTable(this.ssdDb, this.nightConsolidationStatsSsdTable, "ssd");
1029
+ this.handleSsdSuccess();
1030
+ }
1031
+ catch (err) {
1032
+ this.handleSsdError(err, "ensure_night_consolidation_stats_table");
1033
+ }
1034
+ }
1035
+ }
1036
+ async ensureWalMetadataTable(db, cached, label) {
1037
+ if (cached)
1038
+ return cached;
1039
+ let table;
1040
+ try {
1041
+ table = await db.openTable(WAL_METADATA_TABLE);
1042
+ }
1043
+ catch {
1044
+ const schema = new Schema([
1045
+ new Field("id", new Utf8(), false),
1046
+ new Field("last_committed_txn_id", new Int64()),
1047
+ new Field("updatedAt", new Int64()),
1048
+ ]);
1049
+ await db.createEmptyTable(WAL_METADATA_TABLE, schema);
1050
+ table = await db.openTable(WAL_METADATA_TABLE);
1051
+ console.log(`[MemoryStore] [${label}] wal_metadata table created`);
1052
+ }
1053
+ return table;
1054
+ }
1055
+ async ensureWalMetadataTables() {
1056
+ this.walMetadataRamTable = await this.ensureWalMetadataTable(this.ramDb, this.walMetadataRamTable, "ram");
1057
+ if (this.ssdAvailable) {
1058
+ try {
1059
+ this.walMetadataSsdTable = await this.ensureWalMetadataTable(this.ssdDb, this.walMetadataSsdTable, "ssd");
1060
+ this.handleSsdSuccess();
1061
+ }
1062
+ catch (err) {
1063
+ this.handleSsdError(err, "ensure_wal_metadata_table");
1064
+ }
1065
+ }
1066
+ }
1067
+ async cleanupLegacyWalMetadataRow() {
1068
+ try {
1069
+ await this.ramTable.delete('id = "_wal_metadata"');
1070
+ if (this.ssdAvailable) {
1071
+ await this.ssdTable.delete('id = "_wal_metadata"');
1072
+ }
1073
+ console.log("[WAL] cleaned legacy _wal_metadata row from memories table");
1074
+ }
1075
+ catch (err) {
1076
+ console.warn("[WAL] legacy _wal_metadata cleanup skipped:", err.message);
1077
+ }
1078
+ }
1079
+ // ============================================================================
1080
+ // 工具方法
1081
+ // ============================================================================
1082
+ async tokenizeChinese(text) {
1083
+ const spans = text.match(/[\p{Script=Han}]+|[\p{L}\p{N}_]+/gu) ?? [];
1084
+ let jiebaModule = null;
1085
+ try {
1086
+ jiebaModule = await loadJieba();
1087
+ }
1088
+ catch {
1089
+ // Optional dependency: the fallback below still preserves ASCII words.
1090
+ }
1091
+ return spans.flatMap(span => {
1092
+ if (!/^\p{Script=Han}+$/u.test(span))
1093
+ return [span];
1094
+ if (jiebaModule) {
1095
+ return jiebaModule.cut(span)
1096
+ .map((token) => String(token).trim())
1097
+ .filter(Boolean);
1098
+ }
1099
+ return [...span];
1100
+ }).join(" ");
1101
+ }
1102
+ toJsVector(vector) {
1103
+ if (!vector)
1104
+ return [];
1105
+ if (Array.isArray(vector))
1106
+ return vector;
1107
+ if (vector.values && vector.values instanceof Float32Array) {
1108
+ return Array.from(vector.values);
1109
+ }
1110
+ try {
1111
+ return Array.from(vector);
1112
+ }
1113
+ catch {
1114
+ return [];
1115
+ }
1116
+ }
1117
+ parseMetadata(metaStr) {
1118
+ if (!metaStr)
1119
+ return {};
1120
+ try {
1121
+ return typeof metaStr === 'string' ? JSON.parse(metaStr) : metaStr;
1122
+ }
1123
+ catch {
1124
+ return {};
1125
+ }
1126
+ }
1127
+ hasHooksFromMetadata(metadata) {
1128
+ return metadataHasHooks(metadata);
1129
+ }
1130
+ // ========================================================================
1131
+ // CRUD Operations(雙寫架構)
1132
+ // ========================================================================
1133
+ /**
1134
+ * 🛡️ LanceDB Optimistic Concurrency 緩解器
1135
+ * 遇到 'Commit conflict' 時,隨機等待後重試 (Jitter Backoff)
1136
+ * 這對於雙寫與頻繁背景任務至關重要。
1137
+ */
1138
+ async lancedbRetry(operationName, fn, maxRetries = 5) {
1139
+ let attempt = 1;
1140
+ const MAX_TOTAL_BACKOFF_MS = 5000;
1141
+ let totalWaited = 0;
1142
+ while (true) {
1143
+ try {
1144
+ return await fn();
1145
+ }
1146
+ catch (err) {
1147
+ const errMsg = err.message || String(err);
1148
+ if (errMsg.includes('Commit conflict') || errMsg.includes('concurrent commit')) {
1149
+ if (attempt >= maxRetries || totalWaited >= MAX_TOTAL_BACKOFF_MS) {
1150
+ this.recordConflictStatBestEffort({
1151
+ operationName,
1152
+ callerPath: this.extractCallerPath(),
1153
+ attempt,
1154
+ finalOutcome: "failed",
1155
+ fragmentId: this.extractFragmentId(errMsg),
1156
+ });
1157
+ throw new Error(`[MemoryStore] ${operationName} 遭遇 Commit Conflict,超過重試上限 (${maxRetries} 次 / ${MAX_TOTAL_BACKOFF_MS}ms): ${errMsg}`);
1158
+ }
1159
+ this.recordConflictStatBestEffort({
1160
+ operationName,
1161
+ callerPath: this.extractCallerPath(),
1162
+ attempt,
1163
+ finalOutcome: "retry",
1164
+ fragmentId: this.extractFragmentId(errMsg),
1165
+ });
1166
+ const backoff = Math.min(Math.floor(Math.random() * 200) + attempt * 150, MAX_TOTAL_BACKOFF_MS - totalWaited);
1167
+ console.warn(`[MemoryStore] ${operationName} encountered a concurrency conflict; waiting ${backoff}ms before retry attempt ${attempt}...`);
1168
+ await new Promise(r => setTimeout(r, backoff));
1169
+ totalWaited += backoff;
1170
+ attempt++;
1171
+ }
1172
+ else {
1173
+ throw err;
1174
+ }
1175
+ }
1176
+ }
1177
+ }
1178
+ extractFragmentId(message) {
1179
+ const match = message.match(/Fragment\s*\{\s*id:\s*(\d+)/);
1180
+ return match?.[1] ?? null;
1181
+ }
1182
+ extractCallerPath() {
1183
+ const stack = new Error().stack || "";
1184
+ const frames = stack.split("\n").slice(1);
1185
+ for (const frame of frames) {
1186
+ const match = frame.match(/\(([^()]+):\d+:\d+\)/) || frame.match(/\s+at\s+([^\s]+):\d+:\d+/);
1187
+ const file = match?.[1];
1188
+ if (!file)
1189
+ continue;
1190
+ if (file.includes("store-v4."))
1191
+ continue;
1192
+ if (file.includes("node:") || file.includes("node_modules"))
1193
+ continue;
1194
+ if (!/(src|dist)\//.test(file))
1195
+ continue;
1196
+ return file;
1197
+ }
1198
+ return "unknown";
1199
+ }
1200
+ extractCallerPathFrames(limit = 2) {
1201
+ const stack = new Error().stack || "";
1202
+ const frames = [];
1203
+ for (const frame of stack.split("\n").slice(1)) {
1204
+ const match = frame.match(/\(([^()]+):\d+:\d+\)/) || frame.match(/\s+at\s+([^\s]+):\d+:\d+/);
1205
+ const file = match?.[1];
1206
+ if (!file)
1207
+ continue;
1208
+ if (file.includes("store-v4."))
1209
+ continue;
1210
+ if (file.includes("node:") || file.includes("node_modules"))
1211
+ continue;
1212
+ if (!/(src|dist|scripts|tests)\//.test(file))
1213
+ continue;
1214
+ frames.push(file);
1215
+ if (frames.length >= limit)
1216
+ break;
1217
+ }
1218
+ return frames.length > 0 ? frames.join(">") : "unknown";
1219
+ }
1220
+ recordConflictStatBestEffort(stat) {
1221
+ this.recordConflictStatRow(stat).catch((err) => {
1222
+ console.warn("[MemoryStore] Failed to write conflict_stats:", err?.message ?? err);
1223
+ });
1224
+ }
1225
+ async recordConflictStatRow(stat) {
1226
+ if (!this.ramDb)
1227
+ return;
1228
+ await this.ensureConflictStatsTables();
1229
+ const row = {
1230
+ ts: stat.ts ?? Date.now(),
1231
+ operationName: stat.operationName,
1232
+ callerPath: stat.callerPath || "unknown",
1233
+ attempt: Math.max(0, Math.floor(stat.attempt || 0)),
1234
+ finalOutcome: stat.finalOutcome,
1235
+ fragmentId: stat.fragmentId === undefined || stat.fragmentId === null ? null : String(stat.fragmentId),
1236
+ };
1237
+ await this.conflictStatsRamTable.add([row]);
1238
+ await recordAuxTableWrite(this.conflictStatsRamTable, "ram:conflict_stats");
1239
+ if (this.ssdAvailable && this.conflictStatsSsdTable) {
1240
+ await this.conflictStatsSsdTable.add([row])
1241
+ .then(async () => {
1242
+ this.handleSsdSuccess();
1243
+ await recordAuxTableWrite(this.conflictStatsSsdTable, "ssd:conflict_stats");
1244
+ })
1245
+ .catch((err) => {
1246
+ this.handleSsdError(err, "record_conflict_stat");
1247
+ });
1248
+ }
1249
+ }
1250
+ async recordConflictStat(stat) {
1251
+ await this.ensureInitialized();
1252
+ await this.recordConflictStatRow(stat);
1253
+ }
1254
+ async recordNightConsolidationStat(stat) {
1255
+ try {
1256
+ await this.ensureInitialized();
1257
+ await this.ensureNightConsolidationStatsTables();
1258
+ const nullableInt = (value) => {
1259
+ if (value === null || value === undefined)
1260
+ return null;
1261
+ const numeric = Number(value);
1262
+ return Number.isFinite(numeric) ? Math.floor(numeric) : null;
1263
+ };
1264
+ const metadata = stat.metadata && typeof stat.metadata !== "string"
1265
+ ? JSON.stringify(stat.metadata)
1266
+ : (stat.metadata ?? null);
1267
+ const row = {
1268
+ id: stat.id ?? randomUUID(),
1269
+ runId: stat.runId,
1270
+ phase: stat.phase,
1271
+ ts: nullableInt(stat.ts) ?? Date.now(),
1272
+ outcome: stat.outcome ?? null,
1273
+ durationMs: nullableInt(stat.durationMs),
1274
+ candidateCount: nullableInt(stat.candidateCount),
1275
+ scannedCount: nullableInt(stat.scannedCount),
1276
+ decisionCount: nullableInt(stat.decisionCount),
1277
+ mergeCount: nullableInt(stat.mergeCount),
1278
+ deleteCount: nullableInt(stat.deleteCount),
1279
+ deprecatedCount: nullableInt(stat.deprecatedCount),
1280
+ updateCount: nullableInt(stat.updateCount),
1281
+ keepCount: nullableInt(stat.keepCount),
1282
+ attemptedCount: nullableInt(stat.attemptedCount),
1283
+ failedCount: nullableInt(stat.failedCount),
1284
+ batchIndex: nullableInt(stat.batchIndex),
1285
+ batchSize: nullableInt(stat.batchSize),
1286
+ driftMs: nullableInt(stat.driftMs),
1287
+ scheduledFor: nullableInt(stat.scheduledFor),
1288
+ errorMessage: stat.errorMessage ? String(stat.errorMessage).slice(0, 500) : null,
1289
+ metadata,
1290
+ };
1291
+ await this.nightConsolidationStatsRamTable.add([row]);
1292
+ await recordAuxTableWrite(this.nightConsolidationStatsRamTable, "ram:night_consolidation_stats");
1293
+ if (this.ssdAvailable && this.nightConsolidationStatsSsdTable) {
1294
+ await this.nightConsolidationStatsSsdTable.add([row])
1295
+ .then(async () => {
1296
+ this.handleSsdSuccess();
1297
+ await recordAuxTableWrite(this.nightConsolidationStatsSsdTable, "ssd:night_consolidation_stats");
1298
+ })
1299
+ .catch((err) => {
1300
+ this.handleSsdError(err, "record_night_consolidation_stat");
1301
+ });
1302
+ }
1303
+ }
1304
+ catch (err) {
1305
+ console.warn("[MemoryStore] Failed to write night_consolidation_stats:", err?.message ?? err);
1306
+ }
1307
+ }
1308
+ validateEntrySchema(entry) {
1309
+ const violations = [];
1310
+ const isMsTimestamp = (value) => typeof value === "number" && Number.isFinite(value) && value >= 1e12;
1311
+ const isUnitInterval = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
1312
+ if (typeof entry.id !== "string" || entry.id.trim().length === 0) {
1313
+ violations.push("id-missing");
1314
+ }
1315
+ else if (!MemoryStore.UUID_RE.test(entry.id)) {
1316
+ violations.push("id-invalid");
1317
+ }
1318
+ if (!isMsTimestamp(entry.createdAt))
1319
+ violations.push("createdAt-not-ms");
1320
+ if (!isMsTimestamp(entry.updatedAt))
1321
+ violations.push("updatedAt-not-ms");
1322
+ if (!isUnitInterval(entry.importance))
1323
+ violations.push("importance-out-of-range");
1324
+ if (entry.confidence !== null && entry.confidence !== undefined && !isUnitInterval(entry.confidence)) {
1325
+ violations.push("confidence-out-of-range");
1326
+ }
1327
+ if (typeof entry.text !== "string" || entry.text.trim().length === 0) {
1328
+ violations.push("text-empty");
1329
+ }
1330
+ if (!Array.isArray(entry.vector) || entry.vector.length !== this.vectorDim) {
1331
+ violations.push("vector-invalid");
1332
+ }
1333
+ else if (entry.vector.some((value) => typeof value !== "number" || !Number.isFinite(value))) {
1334
+ violations.push("vector-non-finite");
1335
+ }
1336
+ let metadata = {};
1337
+ try {
1338
+ metadata = typeof entry.metadata === "string" ? JSON.parse(entry.metadata) : entry.metadata;
1339
+ }
1340
+ catch {
1341
+ violations.push("metadata-invalid-json");
1342
+ }
1343
+ const health = metadata?.health;
1344
+ if (!health || typeof health !== "object") {
1345
+ violations.push("metadata-health-missing");
1346
+ }
1347
+ else {
1348
+ if (typeof health.healthScore !== "number" || !Number.isFinite(health.healthScore)) {
1349
+ violations.push("metadata-healthScore-invalid");
1350
+ }
1351
+ if (typeof health.accessCount !== "number" || !Number.isFinite(health.accessCount)) {
1352
+ violations.push("metadata-accessCount-invalid");
1353
+ }
1354
+ if (!isMsTimestamp(health.lastAccessedAt)) {
1355
+ violations.push("metadata-lastAccessedAt-not-ms");
1356
+ }
1357
+ }
1358
+ return violations;
1359
+ }
1360
+ async rejectSchemaViolation(entry, violations) {
1361
+ const id = typeof entry?.id === "string" && MemoryStore.UUID_RE.test(entry.id) ? entry.id : "<missing-id>";
1362
+ const callerPath = `${this.extractCallerPathFrames(2)} violations=${violations.join(",")}`;
1363
+ const message = `[MemoryStore] schema violation rejected id=${id} violations=${violations.join(",")}`;
1364
+ console.error(message);
1365
+ try {
1366
+ await this.recordConflictStatRow({
1367
+ operationName: "schema_violation",
1368
+ callerPath,
1369
+ attempt: 0,
1370
+ finalOutcome: "rejected",
1371
+ fragmentId: id,
1372
+ });
1373
+ }
1374
+ catch (metricErr) {
1375
+ console.error("[MemoryStore] schema violation metric write failed:", metricErr?.message ?? metricErr);
1376
+ }
1377
+ throw new SchemaViolationError(message, violations);
1378
+ }
1379
+ /**
1380
+ * store() - append-only,風險最低
1381
+ * RAM 同步寫(快),SSD 異步寫(fire-and-forget)
1382
+ * WAL 在回應前同步落地,SSD 若未完成可於重啟時補寫
1383
+ */
1384
+ static MAX_TEXT_LENGTH = 50_000; // 50K chars ≈ ~12K tokens
1385
+ async store(entry) {
1386
+ await this.ensureInitialized();
1387
+ // 文字長度邊界:超長輸入截斷而非崩潰
1388
+ if (entry.text && entry.text.length > MemoryStore.MAX_TEXT_LENGTH) {
1389
+ console.warn(`[MemoryStore] Text exceeds limit (${entry.text.length} chars); truncating to ${MemoryStore.MAX_TEXT_LENGTH}`);
1390
+ entry = { ...entry, text: entry.text.slice(0, MemoryStore.MAX_TEXT_LENGTH) };
1391
+ }
1392
+ // 🛡️ 終極防爆閥:檢查空值、長度不對、以及陣列裡面是不是裝滿了垃圾 (null/NaN)
1393
+ const vec = entry.vector;
1394
+ // 1. 基本存在檢查
1395
+ if (!vec || !Array.isArray(vec)) {
1396
+ throw new Error(`Invalid vector rejected: not an array`);
1397
+ }
1398
+ // 2. 維度長度檢查 (這非常重要,必須符合你在 index.ts 設定的 1024 維)
1399
+ if (vec.length !== this.vectorDim) {
1400
+ throw new Error(`Invalid vector rejected: expected length ${this.vectorDim}, got ${vec.length}`);
1401
+ }
1402
+ // 3. 內容合法性檢查 (抓出幽靈!檢查是不是第一個元素就是 null, undefined 或 NaN)
1403
+ if (vec[0] == null || isNaN(vec[0])) {
1404
+ const preview = (entry.text || '').slice(0, 50);
1405
+ console.error(`[MemoryStore] Invalid vector rejected for "${preview}...": content contains NaN or null`);
1406
+ throw new Error(`Invalid vector rejected for: ${preview}`);
1407
+ }
1408
+ const textTokens = await this.tokenizeChinese(entry.text);
1409
+ const nowMs = Date.now();
1410
+ const metaObj = this.parseMetadata(entry.metadata);
1411
+ if (!metaObj.status) {
1412
+ this.initializeCreationStatus(metaObj);
1413
+ }
1414
+ const isCore = this.healthConfig.coreCategories.includes(entry.category) ||
1415
+ entry.importance >= this.healthConfig.coreImportanceThreshold;
1416
+ const isCapsule = !!metaObj?.capsuleType;
1417
+ const healthScore = isCore ? 100 : isCapsule ? 30 : this.healthConfig.initialScore;
1418
+ if (!metaObj.health) {
1419
+ metaObj.health = { healthScore, lastAccessedAt: nowMs, decayCount: 0, accessCount: 0, lastDecayedAt: nowMs };
1420
+ }
1421
+ // supersedes 是 string[],LanceDB 存成 JSON string
1422
+ // slotValue 可能是 object/array,也需要 JSON string
1423
+ const rawImportance = typeof entry.importance === 'number'
1424
+ ? entry.importance
1425
+ : parseFloat(entry.importance);
1426
+ const parsedImportance = Number.isFinite(rawImportance) ? rawImportance : 0.5;
1427
+ const sanitizedImportance = Math.max(0, Math.min(1, parsedImportance));
1428
+ if (sanitizedImportance !== parsedImportance) {
1429
+ console.warn(`[MemoryStore] importance out of range (${parsedImportance}), clamped to ${sanitizedImportance}`);
1430
+ }
1431
+ const sanitizedConfidence = entry.confidence !== undefined
1432
+ ? (typeof entry.confidence === 'number' && !isNaN(entry.confidence)
1433
+ ? entry.confidence
1434
+ : parseFloat(entry.confidence) || undefined)
1435
+ : undefined;
1436
+ // 🛡️ 根治 LanceDB Arrow 崩潰:只挑選 LanceDB schema 已知的欄位 🛡️
1437
+ // 且強制將所有 string 欄位轉型,如果收到 [] 陣列(LLM 幻覺)也能順利存入
1438
+ const safeString = (val) => {
1439
+ if (val === null || val === undefined)
1440
+ return val;
1441
+ if (typeof val === 'object')
1442
+ return JSON.stringify(val);
1443
+ return String(val);
1444
+ };
1445
+ const fullEntry = {
1446
+ id: randomUUID(),
1447
+ text: safeString(entry.text) || "",
1448
+ textTokens: safeString(textTokens),
1449
+ vector: entry.vector,
1450
+ importance: sanitizedImportance,
1451
+ category: safeString(entry.category) || "free_text",
1452
+ parentId: entry.parentId ? safeString(entry.parentId) : null,
1453
+ metadata: JSON.stringify(metaObj),
1454
+ createdAt: nowMs,
1455
+ updatedAt: nowMs,
1456
+ // 🛡️ LanceDB Node Binding Bug: Any dynamically added column that is OMITTED or set to undefined
1457
+ // triggers a Rust Panic or an Arrow Utf8 0-byte buffer overflow in subsequent batches!
1458
+ // The ONLY safe way to handle missing optional fields is to explicitly pass `null`.
1459
+ confidence: sanitizedConfidence !== undefined ? sanitizedConfidence : null,
1460
+ slotKey: entry.slotKey ? safeString(entry.slotKey) : null,
1461
+ slotValue: entry.slotValue !== undefined
1462
+ ? (typeof entry.slotValue === 'object' ? JSON.stringify(entry.slotValue) : entry.slotValue)
1463
+ : null,
1464
+ extractionDomain: entry.extractionDomain ? safeString(entry.extractionDomain) : null,
1465
+ supersedes: Array.isArray(entry.supersedes) && entry.supersedes.length > 0
1466
+ ? JSON.stringify(entry.supersedes)
1467
+ : null,
1468
+ lastConcentratedAt: entry.lastConcentratedAt ?? null,
1469
+ sessionId: entry.sessionId ? safeString(entry.sessionId) : null,
1470
+ // P0-3: 新記憶建立時直接以 active 寫入,避免 NOT NULL status schema 與二段補寫衝突。
1471
+ status: safeString(metaObj.status) || 'active',
1472
+ hasHooks: this.hasHooksFromMetadata(metaObj),
1473
+ };
1474
+ const schemaViolations = this.validateEntrySchema(fullEntry);
1475
+ if (schemaViolations.length > 0) {
1476
+ await this.rejectSchemaViolation(fullEntry, schemaViolations);
1477
+ }
1478
+ // WAL 先行,完整 row(含 vector)必須在回應前落地。
1479
+ const txnId = await this.appendWal({ action: "insert", id: fullEntry.id, row: fullEntry });
1480
+ // RAM 同步寫(主要)
1481
+ await this.lancedbRetry('store:ram', () => this.ramTable.add([fullEntry]));
1482
+ // SSD 異步寫(備份,fire-and-forget)
1483
+ if (this.ssdAvailable) {
1484
+ this.lancedbRetry('store:ssd', () => this.ssdTable.add([fullEntry]))
1485
+ .then(() => this.handleSsdSuccess())
1486
+ .catch((err) => {
1487
+ this.handleSsdError(err, 'store');
1488
+ });
1489
+ }
1490
+ // RAM 寫入成功後標記 transaction committed;SSD 可由 recovery 冪等補寫。
1491
+ await this.commitWal(fullEntry.id, txnId);
1492
+ await this.safeRecordCreationAudit({
1493
+ memoryId: fullEntry.id,
1494
+ source: entry.creationAuditSource ?? 'memory-store.store',
1495
+ meta: entry.creationAuditMeta,
1496
+ });
1497
+ return fullEntry;
1498
+ }
1499
+ /**
1500
+ * update() - 危險操作,WAL 先行
1501
+ * 1. append WAL(先寫 log)
1502
+ * 2. RAM + SSD 同時更新
1503
+ * 3. WAL commit
1504
+ */
1505
+ // newVector:F2 修復 — 改字不改向量。updates 物件本身仍禁止帶 vector(FORBIDDEN 檢查
1506
+ // 原樣保留,外部 caller 無法透過 updates 塞 vector),caller 需重新嵌入後經這個獨立參數傳入。
1507
+ async update(id, updates, newVector) {
1508
+ await this.ensureInitialized();
1509
+ const FORBIDDEN_UPDATE_FIELDS = ['id', 'textTokens', 'vector', 'createdAt'];
1510
+ for (const k of FORBIDDEN_UPDATE_FIELDS) {
1511
+ if (k in updates) {
1512
+ throw new Error(`update() rejected: cannot modify immutable field '${k}'`);
1513
+ }
1514
+ }
1515
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1516
+ if (!uuidRegex.test(id))
1517
+ throw new Error(`Invalid memory ID format: ${id}`);
1518
+ let values = { ...updates, updatedAt: Date.now() };
1519
+ if (updates.text) {
1520
+ values.textTokens = await this.tokenizeChinese(updates.text);
1521
+ values.text = updates.text;
1522
+ }
1523
+ if (updates.parentId !== undefined)
1524
+ values.parentId = updates.parentId || '';
1525
+ if (updates.metadata !== undefined) {
1526
+ // 如果 metadata 內有 parentId,同步更新 parentId 欄位
1527
+ try {
1528
+ const meta = typeof updates.metadata === 'string'
1529
+ ? JSON.parse(updates.metadata)
1530
+ : updates.metadata;
1531
+ if (meta?.parentId) {
1532
+ values.parentId = meta.parentId;
1533
+ }
1534
+ }
1535
+ catch { /* ignore parse errors */ }
1536
+ values.hasHooks = this.hasHooksFromMetadata(updates.metadata);
1537
+ }
1538
+ if (updates.importance !== undefined)
1539
+ values.importance = Number(values.importance);
1540
+ if (newVector !== undefined)
1541
+ values.vector = newVector;
1542
+ // 1. WAL 先行(取得 txnId):values 含 vector(若有)一併落地
1543
+ const txnId = await this.appendWal({ action: "update", id, values });
1544
+ // vector 必須走 LanceDB update() 的 values 多載(吃原始陣列),
1545
+ // 其餘欄位沿用既有的 valuesSql 多載(SQL literal 字串),兩者不可混用同一次呼叫。
1546
+ const { vector: _vectorForWal, ...restValues } = values;
1547
+ const lanceValues = normalizeLanceUpdateValues(restValues);
1548
+ // 2. RAM 同步更新(主要),SSD 異步降級(備份)
1549
+ await this.lancedbRetry('update:ram', () => this.ramTable.update(lanceValues, { where: `id = '${id}'` }));
1550
+ if (newVector !== undefined) {
1551
+ await this.lancedbRetry('update:ram-vector', () => this.ramTable.update({ where: `id = '${id}'`, values: { vector: newVector } }));
1552
+ }
1553
+ if (this.ssdAvailable) {
1554
+ // 兩個 SSD update 呼叫必須序列化(不能同時對同一張 LanceDB table 並發送出兩個
1555
+ // update commit),否則會互相搶 dataset version 造成 commit 失敗。
1556
+ let ssdChain = this.lancedbRetry('update:ssd', () => this.ssdTable.update(lanceValues, { where: `id = '${id}'` }));
1557
+ if (newVector !== undefined) {
1558
+ ssdChain = ssdChain.then(() => this.lancedbRetry('update:ssd-vector', () => this.ssdTable.update({ where: `id = '${id}'`, values: { vector: newVector } })));
1559
+ }
1560
+ ssdChain
1561
+ .then(() => this.handleSsdSuccess?.())
1562
+ .catch((err) => {
1563
+ this.handleSsdError(err, 'update');
1564
+ });
1565
+ }
1566
+ // 3. WAL commit(含 txnId,更新 checkpoint metadata)
1567
+ await this.commitWal(id, txnId);
1568
+ return true;
1569
+ }
1570
+ /**
1571
+ * delete() - 危險操作,WAL 先行
1572
+ * 1. WAL 先行
1573
+ * 2. RAM + SSD 同時刪
1574
+ * 3. WAL commit
1575
+ */
1576
+ async delete(id) {
1577
+ await this.ensureInitialized();
1578
+ this.validateId(id);
1579
+ // 1. WAL 先行(取得 txnId)
1580
+ const txnId = await this.appendWal({ action: "delete", id });
1581
+ // 2. RAM 同步刪除(主要),SSD 異步降級(備份)
1582
+ await this.lancedbRetry('delete:ram', () => this.ramTable.delete(`id = '${id}'`));
1583
+ if (this.ssdAvailable) {
1584
+ this.lancedbRetry('delete:ssd', () => this.ssdTable.delete(`id = '${id}'`))
1585
+ .then(() => this.handleSsdSuccess())
1586
+ .catch((err) => {
1587
+ this.handleSsdError(err, 'delete');
1588
+ });
1589
+ }
1590
+ // 3. WAL commit(含 txnId,更新 checkpoint metadata)
1591
+ await this.commitWal(id, txnId);
1592
+ return true;
1593
+ }
1594
+ // ========================================================================
1595
+ // 讀取操作(全部只讀 RAM - 速度優先)
1596
+ // ========================================================================
1597
+ static UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1598
+ validateId(id) {
1599
+ if (!MemoryStore.UUID_RE.test(id)) {
1600
+ throw new Error(`[MemoryStore] Invalid id format: ${id.slice(0, 40)}`);
1601
+ }
1602
+ }
1603
+ async getById(id, includeAllStatus = false) {
1604
+ await this.ensureInitialized();
1605
+ this.validateId(id);
1606
+ const results = await this.ramTable.query().where(`id = '${id}'`).limit(1).toArray();
1607
+ if (results.length === 0)
1608
+ return null;
1609
+ const row = includeAllStatus
1610
+ ? results[0]
1611
+ : results.find((candidate) => {
1612
+ const topStatus = candidate.status || 'active';
1613
+ const metaStatus = (() => { try {
1614
+ const m = this.parseMetadata(candidate.metadata);
1615
+ return m?.status;
1616
+ }
1617
+ catch {
1618
+ return undefined;
1619
+ } })();
1620
+ return topStatus === 'active' && (metaStatus == null || metaStatus === 'active');
1621
+ });
1622
+ if (!row)
1623
+ return null;
1624
+ return {
1625
+ id: row.id,
1626
+ text: row.text,
1627
+ textTokens: row.textTokens,
1628
+ vector: this.toJsVector(row.vector),
1629
+ importance: row.importance,
1630
+ category: row.category,
1631
+ parentId: row.parentId,
1632
+ metadata: row.metadata || '{}',
1633
+ createdAt: Number(row.createdAt) || Date.now(),
1634
+ updatedAt: Number(row.updatedAt) || Date.now(),
1635
+ };
1636
+ }
1637
+ async getByIds(ids, includeAllStatus = false) {
1638
+ await this.ensureInitialized();
1639
+ if (ids.length === 0)
1640
+ return [];
1641
+ const uniqueIds = [];
1642
+ const seen = new Set();
1643
+ for (const id of ids) {
1644
+ this.validateId(id);
1645
+ if (!seen.has(id)) {
1646
+ seen.add(id);
1647
+ uniqueIds.push(id);
1648
+ }
1649
+ }
1650
+ if (uniqueIds.length === 0)
1651
+ return [];
1652
+ const rowsById = new Map();
1653
+ const chunkSize = 200;
1654
+ for (let i = 0; i < uniqueIds.length; i += chunkSize) {
1655
+ const chunk = uniqueIds.slice(i, i + chunkSize);
1656
+ const predicate = `\`id\` IN (${chunk.map(sqlStringLiteral).join(", ")})`;
1657
+ const rows = await this.ramTable.query()
1658
+ .where(predicate)
1659
+ .limit(chunk.length)
1660
+ .toArray();
1661
+ for (const row of rows) {
1662
+ const id = row.id;
1663
+ if (rowsById.has(id))
1664
+ continue;
1665
+ if (!includeAllStatus) {
1666
+ const topStatus = row.status || 'active';
1667
+ const metaStatus = (() => { try {
1668
+ const m = this.parseMetadata(row.metadata);
1669
+ return m?.status;
1670
+ }
1671
+ catch {
1672
+ return undefined;
1673
+ } })();
1674
+ if (topStatus !== 'active' || (metaStatus != null && metaStatus !== 'active'))
1675
+ continue;
1676
+ }
1677
+ rowsById.set(id, row);
1678
+ }
1679
+ }
1680
+ const entries = [];
1681
+ for (const id of uniqueIds) {
1682
+ const row = rowsById.get(id);
1683
+ if (!row)
1684
+ continue;
1685
+ entries.push({
1686
+ id: row.id,
1687
+ text: row.text,
1688
+ textTokens: row.textTokens,
1689
+ vector: this.toJsVector(row.vector),
1690
+ importance: row.importance,
1691
+ category: row.category,
1692
+ parentId: row.parentId,
1693
+ metadata: row.metadata || '{}',
1694
+ createdAt: Number(row.createdAt) || Date.now(),
1695
+ updatedAt: Number(row.updatedAt) || Date.now(),
1696
+ });
1697
+ }
1698
+ return entries;
1699
+ }
1700
+ async count() {
1701
+ await this.ensureInitialized();
1702
+ return await this.ramTable.countRows();
1703
+ }
1704
+ async ensureSubsystemEffectivenessTable(db, cached, label) {
1705
+ if (cached)
1706
+ return cached;
1707
+ const schema = new Schema([
1708
+ new Field("id", new Utf8(), false),
1709
+ new Field("ts", new Utf8(), false),
1710
+ new Field("subsystem", new Utf8(), false),
1711
+ new Field("event", new Utf8(), false),
1712
+ new Field("entityId", new Utf8(), false),
1713
+ new Field("relatedId", new Utf8(), false),
1714
+ new Field("sessionKey", new Utf8(), false),
1715
+ new Field("sessionId", new Utf8(), false),
1716
+ new Field("queryHash", new Utf8(), false),
1717
+ new Field("outcome", new Utf8(), false),
1718
+ new Field("count", new Int64(), false),
1719
+ new Field("score", new Float64(), false),
1720
+ new Field("durationMs", new Int64(), false),
1721
+ new Field("metadata", new Utf8(), false),
1722
+ ]);
1723
+ try {
1724
+ return await db.openTable(SUBSYSTEM_EFFECTIVENESS_TABLE);
1725
+ }
1726
+ catch (err) {
1727
+ if (!isLanceTableNotFoundError(err))
1728
+ throw err;
1729
+ await db.createEmptyTable(SUBSYSTEM_EFFECTIVENESS_TABLE, schema);
1730
+ const table = await db.openTable(SUBSYSTEM_EFFECTIVENESS_TABLE);
1731
+ console.log(`[MemoryStore] [${label}] subsystem_effectiveness table created`);
1732
+ return table;
1733
+ }
1734
+ }
1735
+ async initSubsystemEffectivenessTable() {
1736
+ if (this.ramDb) {
1737
+ this.subsystemEffectivenessRamTable = await this.ensureSubsystemEffectivenessTable(this.ramDb, this.subsystemEffectivenessRamTable, "ram");
1738
+ }
1739
+ if (this.ssdAvailable) {
1740
+ try {
1741
+ this.subsystemEffectivenessSsdTable = await this.ensureSubsystemEffectivenessTable(this.ssdDb, this.subsystemEffectivenessSsdTable, "ssd");
1742
+ this.handleSsdSuccess();
1743
+ }
1744
+ catch (err) {
1745
+ this.handleSsdError(err, "ensure_subsystem_effectiveness_table");
1746
+ }
1747
+ }
1748
+ }
1749
+ async recordSubsystemEffectiveness(event) {
1750
+ await this.ensureInitialized();
1751
+ await this.initSubsystemEffectivenessTable();
1752
+ const metadata = event.metadata === null || event.metadata === undefined
1753
+ ? ""
1754
+ : typeof event.metadata === "string"
1755
+ ? event.metadata
1756
+ : JSON.stringify(event.metadata);
1757
+ const row = {
1758
+ id: event.id ?? randomUUID(),
1759
+ ts: event.ts ?? new Date().toISOString(),
1760
+ subsystem: event.subsystem ?? "",
1761
+ event: event.event ?? "",
1762
+ entityId: event.entityId ?? "",
1763
+ relatedId: event.relatedId ?? "",
1764
+ sessionKey: event.sessionKey ?? "",
1765
+ sessionId: event.sessionId ?? "",
1766
+ queryHash: event.queryHash ?? "",
1767
+ outcome: event.outcome ?? "",
1768
+ count: Math.max(0, Math.floor(event.count || 0)),
1769
+ score: Number.isFinite(event.score) ? event.score : 0,
1770
+ durationMs: Math.max(0, Math.floor(event.durationMs || 0)),
1771
+ metadata,
1772
+ };
1773
+ await this.lancedbRetry("record_subsystem_effectiveness:ram", () => this.subsystemEffectivenessRamTable.add([row]));
1774
+ await recordAuxTableWrite(this.subsystemEffectivenessRamTable, "ram:subsystem_effectiveness");
1775
+ if (this.ssdAvailable && this.subsystemEffectivenessSsdTable) {
1776
+ await this.lancedbRetry("record_subsystem_effectiveness:ssd", () => this.subsystemEffectivenessSsdTable.add([row]))
1777
+ .then(async () => {
1778
+ this.handleSsdSuccess();
1779
+ await recordAuxTableWrite(this.subsystemEffectivenessSsdTable, "ssd:subsystem_effectiveness");
1780
+ })
1781
+ .catch((err) => {
1782
+ this.handleSsdError(err, "record_subsystem_effectiveness");
1783
+ });
1784
+ }
1785
+ }
1786
+ async querySubsystemEffectiveness(filter = {}) {
1787
+ await this.ensureInitialized();
1788
+ await this.initSubsystemEffectivenessTable();
1789
+ const conditions = [];
1790
+ if (filter.subsystem)
1791
+ conditions.push(`subsystem = ${sqlStringLiteral(filter.subsystem)}`);
1792
+ if (filter.event)
1793
+ conditions.push(`event = ${sqlStringLiteral(filter.event)}`);
1794
+ if (filter.outcome)
1795
+ conditions.push(`outcome = ${sqlStringLiteral(filter.outcome)}`);
1796
+ if (filter.since)
1797
+ conditions.push(`ts >= ${sqlStringLiteral(filter.since)}`);
1798
+ let query = this.subsystemEffectivenessRamTable.query();
1799
+ if (conditions.length > 0) {
1800
+ query = query.where(conditions.join(" AND "));
1801
+ }
1802
+ const limit = Math.max(1, Math.floor(filter.limit ?? 100));
1803
+ const rows = await query.limit(limit).toArray();
1804
+ return rows.map((row) => ({
1805
+ id: String(row.id || ""),
1806
+ ts: String(row.ts || ""),
1807
+ subsystem: String(row.subsystem || ""),
1808
+ event: String(row.event || ""),
1809
+ entityId: String(row.entityId || ""),
1810
+ relatedId: String(row.relatedId || ""),
1811
+ sessionKey: String(row.sessionKey || ""),
1812
+ sessionId: String(row.sessionId || ""),
1813
+ queryHash: String(row.queryHash || ""),
1814
+ outcome: String(row.outcome || ""),
1815
+ count: Number(row.count) || 0,
1816
+ score: Number(row.score) || 0,
1817
+ durationMs: Number(row.durationMs) || 0,
1818
+ metadata: String(row.metadata || ""),
1819
+ }));
1820
+ }
1821
+ async recordConcentratorStat(stat) {
1822
+ await this.ensureInitialized();
1823
+ await this.ensureConcentratorStatsTables();
1824
+ const row = {
1825
+ id: stat.id ?? randomUUID(),
1826
+ canonicalKey: stat.canonicalKey?.trim() || "unknown",
1827
+ sessionId: stat.sessionId ?? null,
1828
+ provider: stat.provider,
1829
+ outcome: stat.outcome,
1830
+ attemptedProviders: stat.attemptedProviders,
1831
+ inputTokens: Math.max(0, Math.floor(stat.inputTokens || 0)),
1832
+ outputTokens: stat.outputTokens === null || stat.outputTokens === undefined ? null : Math.max(0, Math.floor(stat.outputTokens)),
1833
+ durationMs: Math.max(0, Math.floor(stat.durationMs || 0)),
1834
+ failureReason: stat.failureReason ?? null,
1835
+ createdAt: stat.createdAt ?? Date.now(),
1836
+ };
1837
+ const addRow = async (table) => {
1838
+ const currentSchema = await table.schema();
1839
+ const fieldNames = new Set(currentSchema.fields.map((field) => field.name));
1840
+ let attemptedProvidersForMeta = [];
1841
+ try {
1842
+ attemptedProvidersForMeta = JSON.parse(row.attemptedProviders || "[]");
1843
+ }
1844
+ catch { }
1845
+ const legacyFields = {
1846
+ timestamp: row.createdAt,
1847
+ sessionKey: row.canonicalKey,
1848
+ source: "concentrate",
1849
+ reason: row.failureReason,
1850
+ meta: JSON.stringify({
1851
+ provider: row.provider,
1852
+ attemptedProviders: attemptedProvidersForMeta,
1853
+ inputTokens: row.inputTokens,
1854
+ outputTokens: row.outputTokens,
1855
+ }),
1856
+ };
1857
+ const compatibleRow = Object.fromEntries(Object.entries({ ...row, ...legacyFields }).filter(([key]) => fieldNames.has(key)));
1858
+ await table.add([compatibleRow]);
1859
+ };
1860
+ await this.lancedbRetry("record_concentrator_stat:ram", () => addRow(this.concentratorStatsRamTable));
1861
+ await recordAuxTableWrite(this.concentratorStatsRamTable, "ram:concentrator_stats");
1862
+ if (this.ssdAvailable && this.concentratorStatsSsdTable) {
1863
+ await this.lancedbRetry("record_concentrator_stat:ssd", () => addRow(this.concentratorStatsSsdTable))
1864
+ .then(async () => {
1865
+ this.handleSsdSuccess();
1866
+ await recordAuxTableWrite(this.concentratorStatsSsdTable, "ssd:concentrator_stats");
1867
+ })
1868
+ .catch((err) => {
1869
+ this.handleSsdError(err, "record_concentrator_stat");
1870
+ });
1871
+ }
1872
+ }
1873
+ async queryConcentratorStats(opts = {}) {
1874
+ await this.ensureInitialized();
1875
+ await this.ensureConcentratorStatsTables();
1876
+ const conditions = [];
1877
+ if (opts.since !== undefined)
1878
+ conditions.push(`\`createdAt\` >= ${Math.floor(opts.since)}`);
1879
+ if (opts.provider)
1880
+ conditions.push(`provider = ${sqlStringLiteral(opts.provider)}`);
1881
+ if (opts.outcome)
1882
+ conditions.push(`outcome = ${sqlStringLiteral(opts.outcome)}`);
1883
+ if (opts.canonicalKey)
1884
+ conditions.push(`\`canonicalKey\` = ${sqlStringLiteral(opts.canonicalKey)}`);
1885
+ let query = this.concentratorStatsRamTable.query();
1886
+ if (conditions.length > 0) {
1887
+ query = query.where(conditions.join(" AND "));
1888
+ }
1889
+ const limit = Math.max(1, opts.limit ?? 100);
1890
+ const rows = await query.limit(limit).toArray();
1891
+ return rows.map((row) => ({
1892
+ id: String(row.id),
1893
+ canonicalKey: String(row.canonicalKey || row.sessionKey || "unknown"),
1894
+ sessionId: row.sessionId === null || row.sessionId === undefined ? null : String(row.sessionId),
1895
+ provider: String(row.provider || "all_failed"),
1896
+ outcome: row.outcome,
1897
+ attemptedProviders: typeof row.attemptedProviders === "string" ? row.attemptedProviders : "[]",
1898
+ inputTokens: Number(row.inputTokens) || 0,
1899
+ outputTokens: row.outputTokens === null || row.outputTokens === undefined ? null : Number(row.outputTokens),
1900
+ durationMs: Number(row.durationMs) || 0,
1901
+ failureReason: row.failureReason ? String(row.failureReason) : null,
1902
+ createdAt: Number(row.createdAt || row.timestamp) || 0,
1903
+ }));
1904
+ }
1905
+ async getRecentConcentratorStats(limit = 100) {
1906
+ return this.queryConcentratorStats({ limit });
1907
+ }
1908
+ // ========================================================================
1909
+ // Status Audit Log(P0-3: 記憶狀態變更 audit trail)
1910
+ // ========================================================================
1911
+ async ensureStatusAuditLogTable(db, cached, label) {
1912
+ if (cached)
1913
+ return cached;
1914
+ const tableName = STATUS_AUDIT_LOG_TABLE;
1915
+ let table;
1916
+ const schema = new Schema([
1917
+ new Field("id", new Utf8(), false),
1918
+ new Field("timestamp", new Int64(), false),
1919
+ new Field("memoryId", new Utf8(), false),
1920
+ new Field("fromStatus", new Utf8(), true), // nullable: 首次建立時為 null
1921
+ new Field("toStatus", new Utf8(), false),
1922
+ new Field("reason", new Utf8(), false),
1923
+ new Field("source", new Utf8(), false),
1924
+ new Field("supersededBy", new Utf8(), true), // nullable
1925
+ new Field("meta", new Utf8(), true), // nullable: JSON
1926
+ new Field("canonicalKey", new Utf8(), true), // nullable: session 識別
1927
+ new Field("partial", new Bool(), false), // NOT NULL: fail-safe 標記
1928
+ ]);
1929
+ try {
1930
+ table = await db.openTable(tableName);
1931
+ const currentSchema = await table.schema();
1932
+ const schemaMatches = currentSchema.fields.length === schema.fields.length
1933
+ && currentSchema.fields.every((field, index) => {
1934
+ const expected = schema.fields[index];
1935
+ return field.name === expected.name
1936
+ && field.nullable === expected.nullable
1937
+ && String(field.type) === String(expected.type);
1938
+ });
1939
+ if (!schemaMatches) {
1940
+ await db.dropTable(tableName);
1941
+ table = await db.createEmptyTable(tableName, schema);
1942
+ table = await db.openTable(tableName);
1943
+ console.log(`[MemoryStore] [${label}] status_audit_log schema rebuilt`);
1944
+ }
1945
+ }
1946
+ catch {
1947
+ await db.createEmptyTable(tableName, schema);
1948
+ table = await db.openTable(tableName);
1949
+ console.log(`[MemoryStore] [${label}] status_audit_log table created`);
1950
+ }
1951
+ return table;
1952
+ }
1953
+ async ensureStatusAuditLogTables() {
1954
+ this.statusAuditLogRamTable = await this.ensureStatusAuditLogTable(this.ramDb, this.statusAuditLogRamTable, "ram");
1955
+ if (this.ssdAvailable) {
1956
+ try {
1957
+ this.statusAuditLogSsdTable = await this.ensureStatusAuditLogTable(this.ssdDb, this.statusAuditLogSsdTable, "ssd");
1958
+ this.handleSsdSuccess();
1959
+ }
1960
+ catch (err) {
1961
+ this.handleSsdError(err, "ensure_status_audit_log_table");
1962
+ }
1963
+ }
1964
+ }
1965
+ /**
1966
+ * P0-3 Schema Migration: 確保 memories table 有 `status` column (Utf8, nullable)。
1967
+ * 舊資料全部預設為 'active'。使用 LanceDB addColumns API,idempotent。
1968
+ */
1969
+ async ensureStatusColumn() {
1970
+ const addIfMissing = async (table, label) => {
1971
+ try {
1972
+ const currentSchema = await table.schema();
1973
+ const hasStatus = currentSchema.fields.some((f) => f.name === 'status');
1974
+ if (hasStatus)
1975
+ return;
1976
+ await table.addColumns([{ name: 'status', valueSql: "'active'" }]);
1977
+ console.log(`[MemoryStore] [${label}] status column added (default 'active')`);
1978
+ }
1979
+ catch (err) {
1980
+ console.warn(`[MemoryStore] [${label}] status column migration failed (non-fatal): ${err.message}`);
1981
+ }
1982
+ };
1983
+ await addIfMissing(this.ramTable, 'ram');
1984
+ if (this.ssdAvailable && this.ssdTable) {
1985
+ await addIfMissing(this.ssdTable, 'ssd');
1986
+ }
1987
+ }
1988
+ async recordStatusAudit(audit) {
1989
+ await this.ensureInitialized();
1990
+ await this.ensureStatusAuditLogTables();
1991
+ const id = audit.id ?? randomUUID();
1992
+ const row = {
1993
+ id,
1994
+ timestamp: audit.timestamp ?? Date.now(),
1995
+ memoryId: audit.memoryId,
1996
+ fromStatus: audit.fromStatus ?? null,
1997
+ toStatus: audit.toStatus,
1998
+ reason: audit.reason,
1999
+ source: audit.source,
2000
+ supersededBy: audit.supersededBy ?? null,
2001
+ meta: audit.meta ?? null,
2002
+ canonicalKey: audit.canonicalKey ?? null,
2003
+ partial: audit.partial ?? false,
2004
+ };
2005
+ await this.lancedbRetry("record_status_audit:ram", () => this.statusAuditLogRamTable.add([row]));
2006
+ await recordAuxTableWrite(this.statusAuditLogRamTable, "ram:status_audit_log");
2007
+ if (this.ssdAvailable && this.statusAuditLogSsdTable) {
2008
+ await this.lancedbRetry("record_status_audit:ssd", () => this.statusAuditLogSsdTable.add([row]))
2009
+ .then(async () => {
2010
+ this.handleSsdSuccess();
2011
+ await recordAuxTableWrite(this.statusAuditLogSsdTable, "ssd:status_audit_log");
2012
+ })
2013
+ .catch((err) => {
2014
+ this.handleSsdError(err, "record_status_audit");
2015
+ });
2016
+ }
2017
+ return id;
2018
+ }
2019
+ async recordCreationAudit(req) {
2020
+ return this.recordStatusAudit({
2021
+ memoryId: req.memoryId,
2022
+ fromStatus: null,
2023
+ toStatus: 'active',
2024
+ reason: 'created',
2025
+ source: req.source,
2026
+ supersededBy: null,
2027
+ meta: req.meta ? JSON.stringify(req.meta) : null,
2028
+ canonicalKey: null,
2029
+ partial: false,
2030
+ });
2031
+ }
2032
+ /**
2033
+ * 查詢 status_audit_log(觀測用)。
2034
+ *
2035
+ * @note volatile — 只讀 RAM,重啟後遺失歷史。
2036
+ * 長期 audit 查詢需另開 P1 任務支援 readFromSsd 選項。
2037
+ */
2038
+ async queryStatusAudit(opts = {}) {
2039
+ await this.ensureInitialized();
2040
+ await this.ensureStatusAuditLogTables();
2041
+ const conditions = [];
2042
+ if (opts.memoryId)
2043
+ conditions.push(`\`memoryId\` = ${sqlStringLiteral(opts.memoryId)}`);
2044
+ if (opts.since !== undefined)
2045
+ conditions.push(`timestamp >= ${Math.floor(opts.since)}`);
2046
+ if (opts.source)
2047
+ conditions.push(`source = ${sqlStringLiteral(opts.source)}`);
2048
+ let query = this.statusAuditLogRamTable.query();
2049
+ if (conditions.length > 0) {
2050
+ query = query.where(conditions.join(" AND "));
2051
+ }
2052
+ const limit = Math.max(1, opts.limit ?? 100);
2053
+ const rows = await query.limit(limit).toArray();
2054
+ return rows.map((row) => ({
2055
+ id: String(row.id),
2056
+ timestamp: Number(row.timestamp) || 0,
2057
+ memoryId: String(row.memoryId),
2058
+ fromStatus: row.fromStatus ? String(row.fromStatus) : null,
2059
+ toStatus: String(row.toStatus),
2060
+ reason: String(row.reason),
2061
+ source: String(row.source),
2062
+ supersededBy: row.supersededBy ? String(row.supersededBy) : null,
2063
+ meta: row.meta ? String(row.meta) : null,
2064
+ canonicalKey: row.canonicalKey ? String(row.canonicalKey) : null,
2065
+ partial: Boolean(row.partial),
2066
+ }));
2067
+ }
2068
+ initializeCreationStatus(metaObj) {
2069
+ // 建立路徑必須在單次 insert 帶入 active,避免新表 status 欄位缺值;audit 於 insert 成功後補寫。
2070
+ metaObj.status = 'active';
2071
+ }
2072
+ async safeRecordCreationAudit(req) {
2073
+ try {
2074
+ await this.recordCreationAudit(req);
2075
+ }
2076
+ catch (err) {
2077
+ console.warn(`[MemoryStore] Failed to write creation audit (memoryId=${req.memoryId}): ${err.message}`);
2078
+ }
2079
+ }
2080
+ // ========================================================================
2081
+ // Transcript Watermark(P0-1.5 A: archive line-count persistence)
2082
+ // ========================================================================
2083
+ async ensureTranscriptWatermarkTable(db, cached, label) {
2084
+ if (cached)
2085
+ return cached;
2086
+ const schema = new Schema([
2087
+ new Field("canonicalKey", new Utf8(), false),
2088
+ new Field("sessionId", new Utf8(), true),
2089
+ new Field("lineCount", new Int64(), false),
2090
+ new Field("updatedAt", new Int64(), false),
2091
+ ]);
2092
+ let table;
2093
+ try {
2094
+ table = await db.openTable(TRANSCRIPT_WATERMARK_TABLE);
2095
+ }
2096
+ catch (err) {
2097
+ if (!isLanceTableNotFoundError(err))
2098
+ throw err;
2099
+ await db.createEmptyTable(TRANSCRIPT_WATERMARK_TABLE, schema);
2100
+ table = await db.openTable(TRANSCRIPT_WATERMARK_TABLE);
2101
+ console.log(`[MemoryStore] [${label}] transcript_watermark table created (was not found)`);
2102
+ return table;
2103
+ }
2104
+ const currentSchema = await table.schema();
2105
+ const hasSessionId = currentSchema.fields.some((field) => field.name === "sessionId");
2106
+ if (!hasSessionId) {
2107
+ await table.addColumns([{ name: "sessionId", valueSql: "CAST(NULL AS VARCHAR)" }]);
2108
+ table = await db.openTable(TRANSCRIPT_WATERMARK_TABLE);
2109
+ console.log(`[MemoryStore] [${label}] transcript_watermark sessionId column added`);
2110
+ }
2111
+ const migratedSchema = await table.schema();
2112
+ const schemaMatches = migratedSchema.fields.length === schema.fields.length
2113
+ && schema.fields.every((expected) => {
2114
+ const field = migratedSchema.fields.find((candidate) => candidate.name === expected.name);
2115
+ return !!field
2116
+ && field.nullable === expected.nullable
2117
+ && String(field.type) === String(expected.type);
2118
+ });
2119
+ if (!schemaMatches) {
2120
+ console.warn(`[MemoryStore] [${label}] schema mismatch, skip drop+recreate to avoid race`);
2121
+ return table;
2122
+ table = await db.createEmptyTable(TRANSCRIPT_WATERMARK_TABLE, schema);
2123
+ table = await db.openTable(TRANSCRIPT_WATERMARK_TABLE);
2124
+ console.log(`[MemoryStore] [${label}] transcript_watermark schema rebuilt`);
2125
+ }
2126
+ return table;
2127
+ }
2128
+ async ensureTranscriptWatermarkTables() {
2129
+ this.transcriptWatermarkRamTable = await this.ensureTranscriptWatermarkTable(this.ramDb, this.transcriptWatermarkRamTable, "ram");
2130
+ if (this.ssdAvailable) {
2131
+ try {
2132
+ this.transcriptWatermarkSsdTable = await this.ensureTranscriptWatermarkTable(this.ssdDb, this.transcriptWatermarkSsdTable, "ssd");
2133
+ this.handleSsdSuccess();
2134
+ }
2135
+ catch (err) {
2136
+ this.handleSsdError(err, "ensure_transcript_watermark_table");
2137
+ }
2138
+ }
2139
+ }
2140
+ async setTranscriptWatermark(canonicalKey, sessionId, lineCount) {
2141
+ await this.ensureInitialized();
2142
+ await this.ensureTranscriptWatermarkTables();
2143
+ const row = {
2144
+ canonicalKey,
2145
+ sessionId,
2146
+ lineCount,
2147
+ updatedAt: Date.now(),
2148
+ };
2149
+ const where = `\`canonicalKey\` = ${sqlStringLiteral(canonicalKey)}`;
2150
+ const values = normalizeLanceUpdateValues({
2151
+ sessionId: row.sessionId,
2152
+ lineCount: row.lineCount,
2153
+ updatedAt: row.updatedAt,
2154
+ });
2155
+ const upsert = async (table) => {
2156
+ const existing = await table.query().where(where).limit(1).toArray();
2157
+ if (existing.length > 0) {
2158
+ await table.update(values, { where });
2159
+ }
2160
+ else {
2161
+ await table.add([row]);
2162
+ }
2163
+ };
2164
+ await this.lancedbRetry("set_transcript_watermark:ram", () => upsert(this.transcriptWatermarkRamTable));
2165
+ await recordAuxTableWrite(this.transcriptWatermarkRamTable, "ram:transcript_watermark");
2166
+ if (this.ssdAvailable && this.transcriptWatermarkSsdTable) {
2167
+ await this.lancedbRetry("set_transcript_watermark:ssd", () => upsert(this.transcriptWatermarkSsdTable))
2168
+ .then(async () => {
2169
+ this.handleSsdSuccess();
2170
+ await recordAuxTableWrite(this.transcriptWatermarkSsdTable, "ssd:transcript_watermark");
2171
+ })
2172
+ .catch((err) => {
2173
+ this.handleSsdError(err, "set_transcript_watermark");
2174
+ });
2175
+ }
2176
+ }
2177
+ async getTranscriptWatermark(canonicalKey) {
2178
+ await this.ensureInitialized();
2179
+ await this.ensureTranscriptWatermarkTables();
2180
+ const where = `\`canonicalKey\` = ${sqlStringLiteral(canonicalKey)}`;
2181
+ const readOne = async (table) => {
2182
+ if (!table)
2183
+ return null;
2184
+ const rows = await table.query().where(where).limit(1).toArray();
2185
+ if (rows.length === 0)
2186
+ return null;
2187
+ const row = rows[0];
2188
+ return {
2189
+ canonicalKey: String(row.canonicalKey),
2190
+ sessionId: row.sessionId === null || row.sessionId === undefined ? null : String(row.sessionId),
2191
+ lineCount: Number(row.lineCount) || 0,
2192
+ updatedAt: Number(row.updatedAt) || 0,
2193
+ };
2194
+ };
2195
+ const ramRow = await readOne(this.transcriptWatermarkRamTable);
2196
+ if (ramRow)
2197
+ return ramRow;
2198
+ if (this.ssdAvailable && this.transcriptWatermarkSsdTable) {
2199
+ return await readOne(this.transcriptWatermarkSsdTable);
2200
+ }
2201
+ return null;
2202
+ }
2203
+ async vectorSearch(vector, limit = 5) {
2204
+ await this.ensureInitialized();
2205
+ // 🛡️ 空向量防禦:避免空向量打到 LanceDB 觸發 Arrow Float64 崩潰
2206
+ if (!vector || !Array.isArray(vector) || vector.length === 0) {
2207
+ return [];
2208
+ }
2209
+ try {
2210
+ const fetchLimit = Math.max(limit + VISIBILITY_OVERFETCH, limit * 3);
2211
+ const results = await this.ramTable.search(vector).limit(fetchLimit).toArray();
2212
+ // H1: 過濾非 active 狀態(同時檢查 row.status 與 metadata.status)
2213
+ const activeResults = results.filter((row) => {
2214
+ const topStatus = row.status || 'active';
2215
+ const metaStatus = (() => { try {
2216
+ const m = this.parseMetadata(row.metadata);
2217
+ return m?.status;
2218
+ }
2219
+ catch {
2220
+ return undefined;
2221
+ } })();
2222
+ return isVisibleStatus(topStatus, metaStatus);
2223
+ }).slice(0, limit);
2224
+ return activeResults.map((row) => ({
2225
+ entry: {
2226
+ id: row.id,
2227
+ text: row.text,
2228
+ textTokens: row.textTokens,
2229
+ vector: this.toJsVector(row.vector),
2230
+ importance: row.importance,
2231
+ category: row.category,
2232
+ parentId: row.parentId,
2233
+ metadata: row.metadata || '{}',
2234
+ createdAt: Number(row.createdAt) || Date.now(),
2235
+ updatedAt: Number(row.updatedAt) || Date.now(),
2236
+ },
2237
+ vectorScore: 1 / (1 + (row._distance ?? 0)),
2238
+ rankScore: 1 / (1 + (row._distance ?? 0)),
2239
+ rawDistance: row._distance ?? 0,
2240
+ bm25Score: 0, fusedScore: 0,
2241
+ }));
2242
+ }
2243
+ catch (err) {
2244
+ // 🛡️ LanceDB Arrow 錯誤防禦(通常是資料庫中有損壞的空向量記錄)
2245
+ const msg = String(err?.message || '');
2246
+ if (msg.includes('Float64') || msg.includes('Arrow') || msg.includes('buffers')) {
2247
+ console.warn(`[MemoryStore] vectorSearch Arrow error (possibly corrupted record); returning empty results: ${msg.slice(0, 100)}`);
2248
+ return [];
2249
+ }
2250
+ throw err;
2251
+ }
2252
+ }
2253
+ async ftsSearch(query, limit = 5) {
2254
+ await this.ensureInitialized();
2255
+ if (!this.ftsAvailable) {
2256
+ console.warn('[MemoryStore] FTS index unavailable; returning empty results');
2257
+ return [];
2258
+ }
2259
+ try {
2260
+ const fetchLimit = Math.max(limit + VISIBILITY_OVERFETCH, limit * 3);
2261
+ const tokenizedQuery = await this.tokenizeChinese(query);
2262
+ if (!tokenizedQuery)
2263
+ return [];
2264
+ const results = await this.ramTable
2265
+ .search(tokenizedQuery, "fts", ["textTokens"])
2266
+ .limit(fetchLimit)
2267
+ .toArray();
2268
+ const activeResults = results
2269
+ .filter((row) => Number.isFinite(row._score) && row._score > 0)
2270
+ .filter((row) => {
2271
+ const topStatus = row.status || 'active';
2272
+ const metaStatus = (() => { try {
2273
+ const m = this.parseMetadata(row.metadata);
2274
+ return m?.status;
2275
+ }
2276
+ catch {
2277
+ return undefined;
2278
+ } })();
2279
+ return isVisibleStatus(topStatus, metaStatus);
2280
+ })
2281
+ .slice(0, limit);
2282
+ return activeResults.map((row) => ({
2283
+ entry: {
2284
+ id: row.id,
2285
+ text: row.text,
2286
+ textTokens: row.textTokens,
2287
+ vector: this.toJsVector(row.vector),
2288
+ importance: row.importance,
2289
+ category: row.category,
2290
+ parentId: row.parentId,
2291
+ metadata: row.metadata || '{}',
2292
+ createdAt: Number(row.createdAt) || Date.now(),
2293
+ updatedAt: Number(row.updatedAt) || Date.now(),
2294
+ },
2295
+ vectorScore: 0,
2296
+ rankScore: 0,
2297
+ rawDistance: Number.POSITIVE_INFINITY,
2298
+ bm25Score: row._score ?? 0,
2299
+ fusedScore: 0,
2300
+ }));
2301
+ }
2302
+ catch (err) {
2303
+ console.warn('[MemoryStore] FTS search failed:', err.message);
2304
+ return [];
2305
+ }
2306
+ }
2307
+ /**
2308
+ * hybridVectorSearch - 統一混合搜尋(向量 + FTS + RRF Fusion)
2309
+ * 替代所有純向量搜尋調用
2310
+ */
2311
+ async hybridVectorSearch(query, limit = 5) {
2312
+ const RRF_K = 60;
2313
+ await this.ensureInitialized();
2314
+ const poolSize = limit * 2;
2315
+ let queryVector;
2316
+ let embeddingFailed = false;
2317
+ try {
2318
+ queryVector = await this._embed(query);
2319
+ }
2320
+ catch (err) {
2321
+ console.warn('[MemoryStore] Embedding failed; falling back to FTS:', err?.message ?? err);
2322
+ queryVector = [];
2323
+ embeddingFailed = true;
2324
+ }
2325
+ let vectorResults = [];
2326
+ if (!queryVector || queryVector.length === 0) {
2327
+ if (!embeddingFailed) {
2328
+ console.warn(`[MemoryStore] Vector generation failed (API 503); skipping vector matching and falling back to FTS`);
2329
+ }
2330
+ }
2331
+ else {
2332
+ vectorResults = await this.vectorSearch(queryVector, poolSize);
2333
+ }
2334
+ const ftsResults = this.ftsAvailable ? await this.ftsSearch(query, poolSize) : [];
2335
+ // RRF Fusion
2336
+ const fused = new Map();
2337
+ vectorResults.forEach((r, i) => {
2338
+ const score = 1 / (RRF_K + i + 1);
2339
+ fused.set(r.entry.id, {
2340
+ ...r,
2341
+ vectorScore: score,
2342
+ rankScore: score,
2343
+ bm25Score: 0,
2344
+ fusedScore: 0,
2345
+ });
2346
+ });
2347
+ ftsResults.forEach((r, i) => {
2348
+ const score = 1 / (RRF_K + i + 1);
2349
+ if (fused.has(r.entry.id)) {
2350
+ fused.get(r.entry.id).bm25Score = score;
2351
+ }
2352
+ else {
2353
+ fused.set(r.entry.id, {
2354
+ ...r,
2355
+ vectorScore: 0,
2356
+ rankScore: 0,
2357
+ rawDistance: Number.POSITIVE_INFINITY,
2358
+ bm25Score: score,
2359
+ fusedScore: 0,
2360
+ });
2361
+ }
2362
+ });
2363
+ const results = Array.from(fused.values());
2364
+ if (results.length === 0)
2365
+ return results;
2366
+ let existingIds = null;
2367
+ try {
2368
+ const ids = [...new Set(results.map(r => r.entry.id).filter(Boolean))];
2369
+ if (ids.length > 0) {
2370
+ const predicate = `\`id\` IN (${ids.map(sqlStringLiteral).join(", ")})`;
2371
+ const rows = await this.ramTable.query()
2372
+ .where(predicate)
2373
+ .select(["id"])
2374
+ .limit(ids.length)
2375
+ .toArray();
2376
+ existingIds = new Set(rows.map((row) => String(row.id)));
2377
+ }
2378
+ }
2379
+ catch (err) {
2380
+ console.warn("[PR-XR-1] stale candidate existence check failed:", err?.message ?? err);
2381
+ }
2382
+ // PR-XR-1: 先排除 current memories 不存在的 stale candidate,再排除非 active 狀態。
2383
+ return results.filter(r => {
2384
+ if (existingIds && !existingIds.has(r.entry.id))
2385
+ return false;
2386
+ try {
2387
+ const meta = this.parseMetadata(r.entry.metadata);
2388
+ return isVisibleStatus(undefined, meta?.status);
2389
+ }
2390
+ catch {
2391
+ return true;
2392
+ }
2393
+ }).slice(0, limit);
2394
+ }
2395
+ // ── Internal helper: embed via injected embedder ────────────────────────
2396
+ async _embed(text) {
2397
+ if (this._embedder) {
2398
+ return await this._embedder.embed(text);
2399
+ }
2400
+ return Array(this.vectorDim).fill(0);
2401
+ }
2402
+ /**
2403
+ * hybridSkillCapsuleSearch - 技能膠囊混合搜尋(hybridVectorSearch + keyword match)
2404
+ * 用於 autoRecall 結果組裝階段
2405
+ */
2406
+ async hybridSkillCapsuleSearch(query, limit = 3, filters = {}) {
2407
+ if (!query.trim())
2408
+ return [];
2409
+ // 直接用 hybridVectorSearch 搜 memories table
2410
+ const results = await this.hybridVectorSearch(query, limit * 3);
2411
+ // 篩選出帶 skillName metadata 的記憶
2412
+ const capsules = [];
2413
+ for (const r of results) {
2414
+ try {
2415
+ const meta = this.parseMetadata(r.entry.metadata);
2416
+ if (!meta?.skillName)
2417
+ continue;
2418
+ if (filters.capsuleVersion !== undefined && meta.capsuleVersion !== filters.capsuleVersion)
2419
+ continue;
2420
+ if (filters.status !== undefined && meta.status !== filters.status)
2421
+ continue;
2422
+ capsules.push({
2423
+ id: r.entry.id,
2424
+ skillName: meta.skillName,
2425
+ triggerConditions: meta.triggerConditions || [],
2426
+ executionSteps: meta.executionSteps || [],
2427
+ summary: r.entry.text.slice(0, 200),
2428
+ confidence: meta.confidence ?? r.entry.importance * 100,
2429
+ category: r.entry.category,
2430
+ createdAt: r.entry.createdAt,
2431
+ updatedAt: r.entry.updatedAt,
2432
+ usageCount: meta.usageCount ?? 0,
2433
+ lastUsedAt: meta.lastUsedAt ?? null,
2434
+ status: meta.status ?? 'active',
2435
+ });
2436
+ if (capsules.length >= limit)
2437
+ break;
2438
+ }
2439
+ catch { /* ignore parsing errors */ }
2440
+ }
2441
+ return capsules;
2442
+ }
2443
+ async query(predicate, limit = 100, includeAllStatus = false) {
2444
+ await this.ensureInitialized();
2445
+ const results = await this.ramTable.query().where(predicate).limit(limit).toArray();
2446
+ const visibleResults = includeAllStatus
2447
+ ? results
2448
+ : results.filter((row) => {
2449
+ const topStatus = row.status || 'active';
2450
+ const metaStatus = (() => { try {
2451
+ const m = this.parseMetadata(row.metadata);
2452
+ return m?.status;
2453
+ }
2454
+ catch {
2455
+ return undefined;
2456
+ } })();
2457
+ return topStatus === 'active' && (metaStatus == null || metaStatus === 'active');
2458
+ });
2459
+ return visibleResults.map((row) => ({
2460
+ id: row.id,
2461
+ text: row.text,
2462
+ textTokens: row.textTokens,
2463
+ vector: this.toJsVector(row.vector),
2464
+ importance: row.importance,
2465
+ category: row.category,
2466
+ parentId: row.parentId,
2467
+ metadata: row.metadata || '{}',
2468
+ createdAt: Number(row.createdAt) || Date.now(),
2469
+ updatedAt: Number(row.updatedAt) || Date.now(),
2470
+ }));
2471
+ }
2472
+ async queryAll(limit = 10000) {
2473
+ await this.ensureInitialized();
2474
+ const results = await this.ramTable.query().limit(limit).toArray();
2475
+ return results.map((row) => ({
2476
+ id: row.id,
2477
+ text: row.text,
2478
+ textTokens: row.textTokens,
2479
+ vector: this.toJsVector(row.vector),
2480
+ importance: row.importance,
2481
+ category: row.category,
2482
+ parentId: row.parentId,
2483
+ metadata: row.metadata || '{}',
2484
+ createdAt: Number(row.createdAt) || Date.now(),
2485
+ updatedAt: Number(row.updatedAt) || Date.now(),
2486
+ status: row.status,
2487
+ }));
2488
+ }
2489
+ async queryHookBearing() {
2490
+ await this.ensureInitialized();
2491
+ const results = await this.ramTable.query()
2492
+ .where("`hasHooks` = true")
2493
+ .select([
2494
+ "id",
2495
+ "text",
2496
+ "textTokens",
2497
+ "metadata",
2498
+ "importance",
2499
+ "category",
2500
+ "parentId",
2501
+ "createdAt",
2502
+ "updatedAt",
2503
+ "status",
2504
+ ])
2505
+ .toArray();
2506
+ return results.map((row) => ({
2507
+ id: row.id,
2508
+ text: row.text,
2509
+ textTokens: row.textTokens,
2510
+ vector: [],
2511
+ importance: row.importance,
2512
+ category: row.category,
2513
+ parentId: row.parentId,
2514
+ metadata: row.metadata || '{}',
2515
+ createdAt: Number(row.createdAt) || Date.now(),
2516
+ updatedAt: Number(row.updatedAt) || Date.now(),
2517
+ status: row.status,
2518
+ }));
2519
+ }
2520
+ async queryAllWithMeta(limit = 10000) {
2521
+ const all = await this.queryAll(limit);
2522
+ return all.map(entry => ({
2523
+ ...entry,
2524
+ metadataObj: this.parseMetadata(entry.metadata),
2525
+ }));
2526
+ }
2527
+ async recordMemoryRecalls(entries, recalledAt = Date.now()) {
2528
+ await this.ensureInitialized();
2529
+ const ids = Array.from(new Set(entries
2530
+ .map(entry => entry?.id)
2531
+ .filter((id) => typeof id === "string" && MemoryStore.UUID_RE.test(id))));
2532
+ if (ids.length === 0)
2533
+ return;
2534
+ const predicate = ids.map(id => `id = '${id.replace(/'/g, "''")}'`).join(" OR ");
2535
+ const rows = await this.ramTable.query()
2536
+ .where(predicate)
2537
+ .limit(ids.length)
2538
+ .toArray();
2539
+ const updates = rows.map((row) => {
2540
+ const id = row.id;
2541
+ const metaObj = this.parseMetadata(row.metadata);
2542
+ const currentCount = Number(metaObj.recallCount);
2543
+ metaObj.lastRecalledAt = recalledAt;
2544
+ metaObj.recallCount = Number.isFinite(currentCount) && currentCount > 0
2545
+ ? Math.floor(currentCount) + 1
2546
+ : 1;
2547
+ return { id, metadata: JSON.stringify(metaObj) };
2548
+ });
2549
+ await this.batchUpdateMemories(updates);
2550
+ }
2551
+ async getRecallStats(memoryId) {
2552
+ const entry = await this.getById(memoryId);
2553
+ if (!entry)
2554
+ return null;
2555
+ const now = Date.now();
2556
+ const metaObj = this.parseMetadata(entry.metadata);
2557
+ const lastRecalledAt = Number(metaObj.lastRecalledAt);
2558
+ const recallCount = Number(metaObj.recallCount);
2559
+ const normalizedLastRecalledAt = Number.isFinite(lastRecalledAt) && lastRecalledAt > 0
2560
+ ? lastRecalledAt
2561
+ : null;
2562
+ return {
2563
+ lastRecalledAt: normalizedLastRecalledAt,
2564
+ recallCount: Number.isFinite(recallCount) && recallCount > 0 ? Math.floor(recallCount) : 0,
2565
+ ageInDays: Math.max(0, (now - entry.createdAt) / 86400000),
2566
+ dormancyInDays: normalizedLastRecalledAt === null
2567
+ ? null
2568
+ : Math.max(0, (now - normalizedLastRecalledAt) / 86400000),
2569
+ };
2570
+ }
2571
+ /**
2572
+ * searchBySlotKey - 精準查詢同 slotKey 的所有版本
2573
+ * 用於 Structured Slot 的 supersedes 鏈查找
2574
+ */
2575
+ async searchBySlotKey(slotKey) {
2576
+ await this.ensureInitialized();
2577
+ if (!slotKey)
2578
+ return [];
2579
+ const escaped = `'${slotKey.replace(/'/g, "''")}'`;
2580
+ const results = await this.ramTable.query()
2581
+ .where(`\`slotKey\` = ${escaped}`)
2582
+ .limit(100)
2583
+ .toArray();
2584
+ return results.map((row) => ({
2585
+ id: row.id,
2586
+ text: row.text,
2587
+ textTokens: row.textTokens,
2588
+ vector: this.toJsVector(row.vector),
2589
+ importance: row.importance,
2590
+ category: row.category,
2591
+ parentId: row.parentId,
2592
+ metadata: row.metadata || '{}',
2593
+ createdAt: Number(row.createdAt) || Date.now(),
2594
+ updatedAt: Number(row.updatedAt) || Date.now(),
2595
+ slotKey: row.slotKey,
2596
+ slotValue: row.slotValue,
2597
+ supersedes: row.supersedes ? JSON.parse(row.supersedes) : undefined,
2598
+ confidence: row.confidence,
2599
+ extractionDomain: row.extractionDomain,
2600
+ }));
2601
+ }
2602
+ // ========================================================================
2603
+ // 🧠 數位新陈代谢系統(走 RAM table,decayMemories 內部用 delete() - 有 WAL 保護)
2604
+ // ========================================================================
2605
+ async boostHealth(id) {
2606
+ await this.ensureInitialized();
2607
+ const entry = await this.getById(id);
2608
+ if (!entry)
2609
+ return false;
2610
+ const textTokens = await this.tokenizeChinese(entry.text);
2611
+ const nowMs = Date.now();
2612
+ const metaObj = this.parseMetadata(entry.metadata);
2613
+ const isCore = this.healthConfig.coreCategories.includes(entry.category) ||
2614
+ entry.importance >= this.healthConfig.coreImportanceThreshold;
2615
+ if (isCore)
2616
+ return true;
2617
+ const health = metaObj.health || { healthScore: 100, accessCount: 0, decayCount: 0, lastDecayedAt: Date.now() };
2618
+ const oldScore = typeof health.healthScore === 'number' ? health.healthScore : 100;
2619
+ health.accessCount = (health.accessCount || 0) + 1;
2620
+ const frequencyBonus = health.accessCount * 5;
2621
+ const baseBoost = 20;
2622
+ health.healthScore = Math.min(100, oldScore + baseBoost + frequencyBonus);
2623
+ health.lastAccessedAt = Date.now();
2624
+ metaObj.health = health;
2625
+ await this.update(id, { metadata: JSON.stringify(metaObj) });
2626
+ return true;
2627
+ }
2628
+ /**
2629
+ * 批次更新多筆記錄的 metadata(不走一筆一筆 WAL,直接寫 table + 單筆 batch WAL entry)。
2630
+ * 用於 decayMemories 批次收集完後一次性寫入,減少 O(N) WAL I/O。
2631
+ *
2632
+ * ⚠️ 犧牲了逐筆 WAL entry 的精細度,但換來批次效能。
2633
+ * 萬一寫入中途當機,batch 內部分記錄可能未落地,需靠下次 recovery 重跑。
2634
+ */
2635
+ async batchUpdateMemories(updates) {
2636
+ if (updates.length === 0)
2637
+ return;
2638
+ await this.ensureInitialized();
2639
+ // 單筆 WAL entry 代表整個 batch(含逐筆 values,支援 recovery replay)
2640
+ const txnId = await this.appendWal({
2641
+ action: "batch_update",
2642
+ ids: updates.map(u => u.id),
2643
+ count: updates.length,
2644
+ entries: updates, // P1 Fix #5: 記錄每筆的 id + metadata,讓 recovery 能 replay
2645
+ });
2646
+ // 直接對 RAM table 批次寫入(繞過逐筆 WAL overhead)
2647
+ const failedIds = [];
2648
+ for (const { id, metadata } of updates) {
2649
+ this.validateId(id);
2650
+ try {
2651
+ const lanceValues = normalizeLanceUpdateValues({
2652
+ metadata,
2653
+ hasHooks: this.hasHooksFromMetadata(metadata),
2654
+ updatedAt: Date.now(),
2655
+ });
2656
+ await this.lancedbRetry('batch_update:ram', () => this.ramTable.update(lanceValues, { where: `id = '${id}'` }));
2657
+ if (this.ssdAvailable) {
2658
+ await this.lancedbRetry('batch_update:ssd', () => this.ssdTable.update(lanceValues, { where: `id = '${id}'` }));
2659
+ }
2660
+ }
2661
+ catch (err) {
2662
+ // 靜音處理,避免 LanceDB 底層警告洗頻,只在真出錯時才印
2663
+ if (!err.message?.includes('Fragment')) {
2664
+ console.warn(`[MemoryStore] Batch update failed for entry (id=${id}):`, err.message);
2665
+ }
2666
+ failedIds.push(id);
2667
+ }
2668
+ }
2669
+ if (failedIds.length > 0) {
2670
+ throw new Error(`batch_update failed for ids: ${failedIds.join(', ')}`);
2671
+ }
2672
+ await this.commitWal("batch", txnId);
2673
+ }
2674
+ async decayMemories(decayPerRun = 5, deleteThreshold = 0, options = {}) {
2675
+ await this.ensureInitialized();
2676
+ const allMemories = await this.ramTable.query().limit(10000).toArray();
2677
+ let decayed = 0, deleted = 0, coreProtected = 0, wouldDecay = 0, wouldDelete = 0;
2678
+ let deferredDecay = 0, deferredDelete = 0;
2679
+ const effectiveCoreCategories = options.coreCategories ?? this.healthConfig.coreCategories;
2680
+ const effectiveCoreImportanceThreshold = options.coreImportanceThreshold ?? this.healthConfig.coreImportanceThreshold;
2681
+ const protectSkillCapsules = options.skillCapsuleProtection ?? true;
2682
+ const isDryRun = options.dryRun ?? false;
2683
+ const maxDelete = options.maxDelete === undefined ? Infinity : Math.max(0, Math.floor(options.maxDelete));
2684
+ const maxDecay = options.maxDecay === undefined ? Infinity : Math.max(0, Math.floor(options.maxDecay));
2685
+ const deleteCandidateSummary = {
2686
+ count: 0,
2687
+ firstId: null,
2688
+ lastId: null,
2689
+ minCreatedAt: null,
2690
+ maxCreatedAt: null,
2691
+ createdAtByDay: {},
2692
+ };
2693
+ // P1 批次 WAL:先收集所有更新,最後一次性寫入
2694
+ const pendingUpdates = [];
2695
+ for (const row of allMemories) {
2696
+ if (!isValidRowId(row)) {
2697
+ console.warn('[decayMemories] skipping row with invalid id:', {
2698
+ idType: typeof row?.id,
2699
+ idValue: row?.id,
2700
+ text: typeof row?.text === 'string' ? row.text.slice(0, 50) : null,
2701
+ createdAt: row?.createdAt,
2702
+ });
2703
+ continue;
2704
+ }
2705
+ const memoryRow = row;
2706
+ const id = memoryRow.id;
2707
+ if (id.startsWith("init_"))
2708
+ continue;
2709
+ const isCore = effectiveCoreCategories.includes(memoryRow.category) ||
2710
+ memoryRow.importance >= effectiveCoreImportanceThreshold;
2711
+ if (isCore) {
2712
+ coreProtected++;
2713
+ continue;
2714
+ }
2715
+ // 技能膠囊不參與灰塵清理(只響應用戶明確刪除)
2716
+ const metaObj = this.parseMetadata(memoryRow.metadata);
2717
+ if (protectSkillCapsules && metaObj?.capsuleType === 'skill_capsule') {
2718
+ continue;
2719
+ }
2720
+ const health = metaObj.health || { healthScore: 100, lastAccessedAt: memoryRow.createdAt || Date.now(), accessCount: 0, decayCount: 0 };
2721
+ const currentHealth = typeof health.healthScore === 'number' ? health.healthScore : 100;
2722
+ const lastDecayedAt = health.lastDecayedAt || health.lastAccessedAt || memoryRow.createdAt || Date.now();
2723
+ const hoursPassed = (Date.now() - lastDecayedAt) / (1000 * 60 * 60);
2724
+ let hpLost = hoursPassed * 0.15;
2725
+ // P3: accessCount 連動 - 存取頻率越高,損耗越慢(最低 0.2)
2726
+ const accessFactor = Math.max(0.2, 1 - (health.accessCount || 0) * 0.01);
2727
+ hpLost = hpLost * accessFactor;
2728
+ if (metaObj?.capsuleVersion === 2 && metaObj?.status === 'active') {
2729
+ hpLost = hpLost * this.healthConfig.skillDecayFactor;
2730
+ }
2731
+ const newScore = Math.max(0, Math.round(currentHealth - hpLost));
2732
+ if (newScore === currentHealth && newScore > 0) {
2733
+ continue;
2734
+ }
2735
+ if (newScore <= deleteThreshold) {
2736
+ wouldDelete++;
2737
+ deleteCandidateSummary.count++;
2738
+ deleteCandidateSummary.firstId ??= id;
2739
+ deleteCandidateSummary.lastId = id;
2740
+ const createdAt = Number(memoryRow.createdAt ?? 0);
2741
+ if (Number.isFinite(createdAt) && createdAt > 0) {
2742
+ deleteCandidateSummary.minCreatedAt = deleteCandidateSummary.minCreatedAt === null ? createdAt : Math.min(deleteCandidateSummary.minCreatedAt, createdAt);
2743
+ deleteCandidateSummary.maxCreatedAt = deleteCandidateSummary.maxCreatedAt === null ? createdAt : Math.max(deleteCandidateSummary.maxCreatedAt, createdAt);
2744
+ const day = new Date(createdAt).toISOString().slice(0, 10);
2745
+ deleteCandidateSummary.createdAtByDay[day] = (deleteCandidateSummary.createdAtByDay[day] ?? 0) + 1;
2746
+ }
2747
+ if (!isDryRun) {
2748
+ if (deleted >= maxDelete) {
2749
+ deferredDelete++;
2750
+ }
2751
+ else if (options.deleteWith) {
2752
+ const deletedViaHook = await options.deleteWith(id);
2753
+ if (deletedViaHook) {
2754
+ deleted++;
2755
+ }
2756
+ }
2757
+ else {
2758
+ await this.delete(id); // delete 仍個別呼叫(WAL 保護刪除)
2759
+ deleted++;
2760
+ }
2761
+ }
2762
+ }
2763
+ else {
2764
+ wouldDecay++;
2765
+ health.healthScore = newScore;
2766
+ health.decayCount = (health.decayCount || 0) + 1;
2767
+ health.lastDecayedAt = Date.now();
2768
+ metaObj.health = health;
2769
+ // P1: 收集到批次陣列,最後一次性寫入
2770
+ if (!isDryRun) {
2771
+ if (decayed >= maxDecay) {
2772
+ deferredDecay++;
2773
+ continue;
2774
+ }
2775
+ pendingUpdates.push({ id, metadata: JSON.stringify(metaObj) });
2776
+ decayed++;
2777
+ }
2778
+ }
2779
+ }
2780
+ // P1: 最後一次性批次寫入(單筆 WAL entry)
2781
+ if (!isDryRun && pendingUpdates.length > 0) {
2782
+ await this.batchUpdateMemories(pendingUpdates);
2783
+ }
2784
+ // 🧹 在經歷了大量的 update 與 delete 之後,執行碎片重組 (Compaction)
2785
+ try {
2786
+ if (isDryRun) {
2787
+ console.log(`[Decay] Dry run: skipping optimize`);
2788
+ return { decayed, deleted, coreProtected, wouldDecay, wouldDelete, deferredDecay, deferredDelete, deleteCandidateSummary };
2789
+ }
2790
+ console.log(`[Decay] Optimizing storage...`);
2791
+ await this.ramTable.optimize();
2792
+ if (this.ssdAvailable) {
2793
+ await this.ssdTable.optimize();
2794
+ }
2795
+ console.log(`[Decay] Storage optimization complete`);
2796
+ }
2797
+ catch (err) {
2798
+ console.warn(`[Decay] Storage optimization failed (non-fatal):`, err.message);
2799
+ }
2800
+ await optimizeAuxTablesInConnection(this.ramDb, "ram");
2801
+ if (this.ssdAvailable) {
2802
+ await optimizeAuxTablesInConnection(this.ssdDb, "ssd");
2803
+ }
2804
+ console.log(`[Decay] decayed=${decayed} deleted=${deleted} coreProtected=${coreProtected} wouldDecay=${wouldDecay} wouldDelete=${wouldDelete} dryRun=${isDryRun}`);
2805
+ return { decayed, deleted, coreProtected, wouldDecay, wouldDelete, deferredDecay, deferredDelete, deleteCandidateSummary };
2806
+ }
2807
+ async getHealthStats() {
2808
+ await this.ensureInitialized();
2809
+ const allMemories = await this.ramTable.query().limit(10000).toArray();
2810
+ let core = 0, healthy = 0, decaying = 0, critical = 0;
2811
+ for (const row of allMemories) {
2812
+ if (!isValidRowId(row)) {
2813
+ console.warn('[getHealthStats] skipping row with invalid id:', {
2814
+ idType: typeof row?.id,
2815
+ idValue: row?.id,
2816
+ text: typeof row?.text === 'string' ? row.text.slice(0, 50) : null,
2817
+ createdAt: row?.createdAt,
2818
+ });
2819
+ continue;
2820
+ }
2821
+ const memoryRow = row;
2822
+ if (memoryRow.id.startsWith("init_"))
2823
+ continue;
2824
+ const isCore = this.healthConfig.coreCategories.includes(memoryRow.category) ||
2825
+ memoryRow.importance >= this.healthConfig.coreImportanceThreshold;
2826
+ if (isCore) {
2827
+ core++;
2828
+ continue;
2829
+ }
2830
+ const metaObj = this.parseMetadata(memoryRow.metadata);
2831
+ const score = typeof metaObj.health?.healthScore === 'number' ? metaObj.health.healthScore : 100;
2832
+ if (score >= 80)
2833
+ healthy++;
2834
+ else if (score >= 30)
2835
+ decaying++;
2836
+ else
2837
+ critical++;
2838
+ }
2839
+ return { total: allMemories.length, core, healthy, decaying, critical };
2840
+ }
2841
+ // ========================================================================
2842
+ // Graceful Shutdown
2843
+ // ========================================================================
2844
+ async shutdown() {
2845
+ console.log('[MemoryStore] Closing connections...');
2846
+ this.stopSsdRecoveryProbe();
2847
+ // 1. 先序列跑所有 shutdown hooks(HooksEngine 的 flush 在 WAL 關閉前執行)
2848
+ for (const hook of this.shutdownHooks) {
2849
+ try {
2850
+ await hook();
2851
+ }
2852
+ catch (err) {
2853
+ console.error('[MemoryStore] Shutdown hook failed:', err.message);
2854
+ }
2855
+ }
2856
+ try {
2857
+ if (this.ramDb) {
2858
+ await this.ramDb.close();
2859
+ console.log('[MemoryStore] RAM connection closed');
2860
+ }
2861
+ if (this.ssdDb) {
2862
+ await this.ssdDb.close();
2863
+ console.log('[MemoryStore] SSD connection closed');
2864
+ }
2865
+ console.log('[MemoryStore] Graceful shutdown complete');
2866
+ }
2867
+ catch (err) {
2868
+ console.error('[MemoryStore] Error during shutdown:', err.message);
2869
+ }
2870
+ }
2871
+ }