rag-memory-epf-mcp 6.3.1 → 6.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -154,6 +154,50 @@ storeDocument(id, content, metadata)
154
154
 
155
155
  ## Changelog
156
156
 
157
+ ### v6.4.0 (2026-10-03)
158
+
159
+ - (Release candidate `6.4.0-rc.1` on the `next` dist-tag; `latest` stays 6.3.2 until the published artifact passes a
160
+ canary against a real project database.)
161
+
162
+ - **Changed** `hybridSearch` default ranking = reciprocal rank fusion of the vector and FTS5 candidate lists
163
+ (k=60). A chunk that only FTS5 finds — typically an exact identifier, filename or setting key — can now reach
164
+ the top; before, it scored at most 0.0164 and sat under every vector candidate in the measured corpora.
165
+ `relevance_score` on the default call is a rank score (at most 2/61, comparable only within one call). Measured on frozen snapshots: exact-identifier hit@10 7/20 → 18/20; worse on 0 of
166
+ 203 queries across three corpora.
167
+ - **Changed** the per-result summary path is opt-in (`RAG_MEMORY_SEARCH_SUMMARIES=on`). It embedded every
168
+ sentence fragment of every candidate (median 121 s per search) and its context boost pushed vector-0 chunks
169
+ to rank 1. `off` keeps working. Legacy paths (`summaries=on`, `useGraph: true`) keep the 6.3.2 formula.
170
+ - **Changed** with the default model (bge-m3) queries are embedded as raw text: the bge-v1.5 instruction the loader
171
+ prepended to every query is gone (bge-m3 needs none; stored vectors never had it). A custom `EMBEDDING_MODEL` keeps
172
+ the previous query instruction. Paraphrase hit@10 7 → 9 of 20, identifier hit@1 7 → 8,
173
+ document known-item (`hybridSearch`) unchanged or +1; entity known-item (`searchNodes`) hit@10 unchanged,
174
+ hit@1 -1 / -1 / +1 (hub / uap / hal). No re-index.
175
+ - Details and rollout: `docs/UPDATING.md`, `specs/changes/search-fusion-rrf/proposal.md`.
176
+
177
+ ### v6.3.2 (2026-09-25)
178
+
179
+ - **Changed — on macOS the engine takes `-wal` and `-shm` out of cloud sync.** It sets
180
+ `com.apple.fileprovider.ignore#P = 1` on both files after every open (SQLite recreates them after the
181
+ last connection closes, so once is not enough). Measured with Google Drive for desktop: the client
182
+ treated `-wal` as a synced item and put a new file at the path while the engine held the old one open
183
+ (`lsof -p` showed the descriptor inside `FileProvider/…/wharf/delete/`); from then on every write
184
+ failed with `SQLITE_IOERR` ("disk I/O error") until the engine restarted — 10 s to 3 min after start,
185
+ with one engine attached. With the attribute set, the same live database kept the same `-wal` for
186
+ 25 min of use. The main database file still syncs. Other platforms: no-op.
187
+ - **Changed — a swapped sidecar is detected and the connection reopened.** The idle tick compares the
188
+ `-wal`/`-shm` inodes with the ones recorded at open (two consecutive mismatches) and reopens with the
189
+ same configuration; an `SQLITE_IOERR*` from a checkpoint or a tool call reopens too. A tool that hit
190
+ the I/O error is retried once only if repeating it changes nothing (reads, `createEntities`,
191
+ `createRelations`, `addObservations`, `deleteEntities`, `deleteRelations`); other tools get the
192
+ error back after the reopen. A reopen waits for running tool calls, is not done while shutting
193
+ down, and is braked after 3 in 10 s. A failed reopen retries with backoff and, after 10 failures,
194
+ shuts the server down so the host shows it as failed. A reconciliation cut by a reopen reruns.
195
+ - Known limit: writes acknowledged in the last ~1 s before a swap can be lost if the old file is no
196
+ longer readable (the engine tries one checkpoint on the old handle first and logs `walsz-before=`).
197
+ - The 6.3.1 `wal_checkpoint(TRUNCATE)` is unchanged. `RESTART` (± `journal_size_limit`) was measured and
198
+ rejected: after SIGKILL its WAL replayed onto a foreign main file 3/3 times.
199
+ - No tool, argument, return shape or schema changed. Spec: `specs/changes/wal-sidecar-guard/`.
200
+
157
201
  ### v6.3.1
158
202
 
159
203
  - **Changed — the engine empties the WAL while it is idle.** It runs `wal_checkpoint(TRUNCATE)` itself:
package/dist/index.d.ts CHANGED
@@ -3,6 +3,11 @@ import { type SourceInput } from './src/observations/lifecycle.js';
3
3
  import { EmbeddingGate } from './src/embeddingGate.js';
4
4
  import type { EmbedPriority } from './src/embeddingGate.js';
5
5
  import { BackfillCoordinator } from './src/backfillCoordinator.js';
6
+ export declare const LEGACY_QUERY_INSTRUCTION = "Represent this sentence for searching relevant passages: ";
7
+ export declare function embeddingInput(text: string, isQuery: boolean, defaultModel?: boolean): string;
8
+ export declare function makeEmbedFn(model: (input: string, opts: any) => Promise<{
9
+ data: any;
10
+ }>, defaultModel: boolean): (text: string, dims: number, isQuery: boolean) => Promise<Float32Array<ArrayBuffer>>;
6
11
  export declare function compileFtsLiteralQuery(q: string): string | null;
7
12
  export declare function resolveDbFilePath(envValue: string | undefined, cwd: string, serverDir: string): string;
8
13
  export declare function parseMmapSize(raw: string | undefined): number;
@@ -51,6 +56,7 @@ interface DetailedContext {
51
56
  export declare class SyncCasConflictError extends Error {
52
57
  constructor(documentId: string);
53
58
  }
59
+ export declare function isIoError(e: unknown): boolean;
54
60
  export declare function setSyncFaultPoint(point: string | null, fn: (() => void) | null): void;
55
61
  export interface ReuseCandidate {
56
62
  rowid: number;
@@ -82,6 +88,7 @@ export declare class RAGKnowledgeGraphManager {
82
88
  gate?: EmbeddingGate;
83
89
  __testForceFkOff?: boolean;
84
90
  }): Promise<void>;
91
+ private openConnection;
85
92
  private ensureCurrentProfile;
86
93
  private buildRealLoader;
87
94
  startReconciliation(): Promise<void>;
@@ -102,6 +109,26 @@ export declare class RAGKnowledgeGraphManager {
102
109
  }>;
103
110
  }>;
104
111
  checkpointWal(): boolean;
112
+ walIno: bigint | null;
113
+ shmIno: bigint | null;
114
+ reopenCount: number;
115
+ private reopenTimes;
116
+ private inFlight;
117
+ private pendingReopen;
118
+ private walMismatchTicks;
119
+ private walWatchPausedUntil;
120
+ private walWatchOff;
121
+ private lastWalSize;
122
+ private dbDown;
123
+ shuttingDown: boolean;
124
+ onFatal: (() => void) | null;
125
+ guardSidecars(): void;
126
+ walTick(): boolean;
127
+ private stormCount;
128
+ reopenDb(reason: string, force?: boolean): boolean;
129
+ private openAfterClose;
130
+ private retryOpen;
131
+ runWithIoRecovery<T>(tool: string, fn: () => Promise<T>): Promise<T>;
105
132
  private walTimer;
106
133
  private walSoon;
107
134
  startWalKeeper(tickMs?: number): void;
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import Database from 'better-sqlite3';
6
6
  import * as sqliteVec from 'sqlite-vec';
7
7
  import { get_encoding } from 'tiktoken';
8
8
  import fsSync from 'fs';
9
+ import { execFileSync } from 'child_process';
9
10
  import path from 'path';
10
11
  import { fileURLToPath } from 'url';
11
12
  import { pipeline, env } from '@huggingface/transformers';
@@ -66,6 +67,24 @@ function printBanner(opts) {
66
67
  // operators (every term is double-quoted; special characters stripped exactly
67
68
  // as the pre-3.6 hybridSearch sanitizer did). Returns null when nothing
68
69
  // searchable remains (contract: caller returns empty results + warning).
70
+ // search-fusion-rrf (query input): what the model sees for a text. For the DEFAULT model (bge-m3) queries are
71
+ // embedded as raw text — the model card: "the BGE-M3 model no longer requires adding instructions to the
72
+ // queries"; measured on the frozen hub snapshot (93 queries, full-corpus vector rank): 36 better, 6 worse, 51 same.
73
+ // A CUSTOM model (EMBEDDING_MODEL set to something else) keeps the pre-change query instruction: the evidence
74
+ // above is for bge-m3 only, and some models (e.g. bge v1.5) still recommend an instruction on queries.
75
+ // Passages are always raw. `isQuery` stays in the signature and in the embedding cache key.
76
+ export const LEGACY_QUERY_INSTRUCTION = 'Represent this sentence for searching relevant passages: ';
77
+ export function embeddingInput(text, isQuery, defaultModel = true) {
78
+ return isQuery && !defaultModel ? `${LEGACY_QUERY_INSTRUCTION}${text}` : text;
79
+ }
80
+ // The embed function handed to the gate. Exported so the input wiring is testable with a stub model.
81
+ export function makeEmbedFn(model, defaultModel) {
82
+ return async (text, dims, isQuery) => {
83
+ const input = embeddingInput(text, isQuery, defaultModel);
84
+ const r = await model(input, { pooling: 'cls', normalize: true });
85
+ return new Float32Array(r.data.slice(0, dims));
86
+ };
87
+ }
69
88
  export function compileFtsLiteralQuery(q) {
70
89
  const sanitized = q.replace(/["\*\(\)\-]/g, ' ').trim();
71
90
  if (!sanitized)
@@ -136,6 +155,30 @@ const TEXT_BUILDER_VERSION = 'tb1';
136
155
  export class SyncCasConflictError extends Error {
137
156
  constructor(documentId) { super(`sync CAS conflict on ${documentId}`); this.name = 'SyncCasConflictError'; }
138
157
  }
158
+ // Sidecar guard (specs/changes/wal-sidecar-guard). macOS File Provider attribute that takes an
159
+ // item out of sync; Google Drive for desktop honours it (measured: isExcludedFromSync = 1).
160
+ const SYNC_EXCLUDE_ATTR = 'com.apple.fileprovider.ignore#P';
161
+ const REOPEN_STORM_WINDOW_MS = 10_000;
162
+ const REOPEN_STORM_MAX = 3;
163
+ const REOPEN_MAX_TRIES = 10;
164
+ const WAL_WATCH_PAUSE_MS = 60_000;
165
+ // Tools that may be retried once after an I/O error reopened the connection: reads, and writes
166
+ // whose repetition changes nothing (INSERT OR IGNORE / content dedup / deletes). Everything else
167
+ // gets the error back so the caller decides — e.g. syncDocumentFromFile spans several
168
+ // transactions and its repeat-safety is not proven.
169
+ const IDEMPOTENT_TOOLS = new Set([
170
+ 'readGraph', 'searchNodes', 'openNodes', 'getNeighbors', 'hybridSearch', 'getDetailedContext',
171
+ 'getKnowledgeGraphStats', 'listDocuments', 'exportGraph', 'getMigrationStatus', 'getGraphMetrics',
172
+ 'detectCommunities', 'analyzeGraphStructure', 'getObservationHistory',
173
+ 'createEntities', 'createRelations', 'addObservations',
174
+ // deleteObservations / deleteDocuments are left out on purpose: a retry after a committed
175
+ // delete reports 0 deleted, which a caller reads as "nothing was deleted".
176
+ 'deleteEntities', 'deleteRelations',
177
+ ]);
178
+ export function isIoError(e) {
179
+ const code = e?.code;
180
+ return typeof code === 'string' && code.startsWith('SQLITE_IOERR');
181
+ }
139
182
  // Test-only fault hook (v13 setMigrationFaultPoint 선례 — 환경변수 금지: 상시 스위치는
140
183
  // 오설정 한 줄로 sync 를 깬다).
141
184
  let __syncFaultHook = null;
@@ -219,38 +262,7 @@ export class RAGKnowledgeGraphManager {
219
262
  // 호출부에서 읽히게 하기 위해서다.
220
263
  async initialize(opts = {}) {
221
264
  console.error('🚀 Initializing RAG Knowledge Graph MCP Server...');
222
- this.db = new Database(DB_FILE_PATH);
223
- sqliteVec.load(this.db);
224
- this.db.pragma('journal_mode = WAL');
225
- this.db.pragma('synchronous = NORMAL');
226
- this.db.pragma('busy_timeout = 5000');
227
- this.db.pragma('cache_size = -32000');
228
- this.db.pragma('temp_store = MEMORY');
229
- // Opt-in mmap (see MMAP_SIZE above). Read the applied value back so the banner reports
230
- // what SQLite did, not what we asked for (a compile-time cap can lower it silently).
231
- this.db.pragma(`mmap_size = ${MMAP_SIZE}`);
232
- this.mmapApplied = Number(this.db.pragma('mmap_size', { simple: true }));
233
- this.db.pragma('foreign_keys = ON');
234
- // spec §5.2: 관찰 lifecycle 의 무결성은 전부 FK CASCADE 를 전제한다 — root 를 지우면
235
- // revision 이, revision 을 지우면 source 가 따라가야 history 가 고아로 남지 않는다.
236
- // FK 가 꺼진 채 돌면 그 계약이 조용히 무효가 되고, 그게 최악이다. 스키마를 건드리기
237
- // 전에(= runMigrations 앞에서) 멈춘다.
238
- // 트랜잭션 내부에서는 이 pragma 가 no-op 이므로(실측 before=1·during=1·after=1)
239
- // 부팅 시점 확인이 유일한 방어 지점이다.
240
- //
241
- // 음성 대조군 주입은 **인자로만** 받는다. 환경변수로 두면 프로덕션 경로에
242
- // "부팅을 막는 스위치"가 상시 존재하게 되고, 오설정 한 줄로 서버가 안 뜬다
243
- // (advisor beta 자기의심 2 = "더 나쁘다"). 테스트는 manager 를 직접 만들므로
244
- // 인자 주입으로 충분하다.
245
- if (opts.__testForceFkOff)
246
- this.db.pragma('foreign_keys = OFF');
247
- {
248
- const fk = this.db.pragma('foreign_keys', { simple: true });
249
- if (Number(fk) !== 1) {
250
- throw new Error(`foreign_keys is ${fk}, expected 1. The observation lifecycle relies on FK CASCADE ` +
251
- `for history integrity; refusing to run migrations without it.`);
252
- }
253
- }
265
+ this.openConnection(opts.__testForceFkOff === true);
254
266
  this.encoding = get_encoding("cl100k_base");
255
267
  await this.runMigrations();
256
268
  this.currentProfileId = this.ensureCurrentProfile();
@@ -259,6 +271,8 @@ export class RAGKnowledgeGraphManager {
259
271
  this.db.prepare(`INSERT INTO server_meta (key, value) VALUES ('current_default_chunker', ?)
260
272
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
261
273
  .run(effectiveSignature(DEFAULT_MAX_TOKENS));
274
+ // The writes above created the sidecars; mark them before a sync client can take them.
275
+ this.guardSidecars();
262
276
  this.embeddingsMode = opts.skipModel
263
277
  ? 'off'
264
278
  : (process.env.RAG_MEMORY_EMBEDDINGS || 'lazy');
@@ -287,6 +301,42 @@ export class RAGKnowledgeGraphManager {
287
301
  const systemInfo = getSystemInfo();
288
302
  console.error(`📊 System Info: ${systemInfo.toolCounts.total} tools available (${systemInfo.toolCounts.knowledgeGraph} knowledge graph, ${systemInfo.toolCounts.rag} RAG, ${systemInfo.toolCounts.graphQuery} query)`);
289
303
  }
304
+ // Opens the connection and applies everything the engine attaches to it. Shared by
305
+ // initialize() and reopenDb() so a reopened connection is configured exactly like the first.
306
+ openConnection(forceFkOff = false) {
307
+ this.db = new Database(DB_FILE_PATH);
308
+ sqliteVec.load(this.db);
309
+ this.db.pragma('journal_mode = WAL');
310
+ this.db.pragma('synchronous = NORMAL');
311
+ this.db.pragma('busy_timeout = 5000');
312
+ this.db.pragma('cache_size = -32000');
313
+ this.db.pragma('temp_store = MEMORY');
314
+ // Opt-in mmap (see MMAP_SIZE above). Read the applied value back so the banner reports
315
+ // what SQLite did, not what we asked for (a compile-time cap can lower it silently).
316
+ this.db.pragma(`mmap_size = ${MMAP_SIZE}`);
317
+ this.mmapApplied = Number(this.db.pragma('mmap_size', { simple: true }));
318
+ this.db.pragma('foreign_keys = ON');
319
+ // spec §5.2: 관찰 lifecycle 의 무결성은 전부 FK CASCADE 를 전제한다 — root 를 지우면
320
+ // revision 이, revision 을 지우면 source 가 따라가야 history 가 고아로 남지 않는다.
321
+ // FK 가 꺼진 채 돌면 그 계약이 조용히 무효가 되고, 그게 최악이다. 스키마를 건드리기
322
+ // 전에(= runMigrations 앞에서) 멈춘다.
323
+ // 트랜잭션 내부에서는 이 pragma 가 no-op 이므로(실측 before=1·during=1·after=1)
324
+ // 부팅 시점 확인이 유일한 방어 지점이다.
325
+ //
326
+ // 음성 대조군 주입은 **인자로만** 받는다. 환경변수로 두면 프로덕션 경로에
327
+ // "부팅을 막는 스위치"가 상시 존재하게 되고, 오설정 한 줄로 서버가 안 뜬다
328
+ // (advisor beta 자기의심 2 = "더 나쁘다"). 테스트는 manager 를 직접 만들므로
329
+ // 인자 주입으로 충분하다.
330
+ if (forceFkOff)
331
+ this.db.pragma('foreign_keys = OFF');
332
+ {
333
+ const fk = this.db.pragma('foreign_keys', { simple: true });
334
+ if (Number(fk) !== 1) {
335
+ throw new Error(`foreign_keys is ${fk}, expected 1. The observation lifecycle relies on FK CASCADE ` +
336
+ `for history integrity; refusing to run migrations without it.`);
337
+ }
338
+ }
339
+ }
290
340
  // Upsert the stored-vector compatibility profile (spec §6c layer 2) and record
291
341
  // retrieval config in server_meta (layer 3 — never a backfill trigger).
292
342
  ensureCurrentProfile() {
@@ -303,8 +353,12 @@ export class RAGKnowledgeGraphManager {
303
353
  .get(EMBEDDING_MODEL, MODEL_REVISION, MODEL_DTYPE, dims, 'cls', 1);
304
354
  this.db.prepare(`INSERT INTO server_meta(key,value) VALUES('current_profile_id',?)
305
355
  ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(String(row.id));
306
- this.db.prepare(`INSERT INTO server_meta(key,value) VALUES('query_prefix_version','1')
307
- ON CONFLICT(key) DO NOTHING`).run();
356
+ // query_prefix_version: diagnostic marker of the query-input convention of the engine that last INITIALISED
357
+ // this DB — 1 = instruction prepended to queries (<= 6.3.2, and custom models now), 2 = raw queries (default
358
+ // bge-m3 since search-fusion-rrf). It is written at init, not per query: it does not prove what the last query
359
+ // used, which engines are attached, or vector compatibility. Nothing reads it for logic.
360
+ this.db.prepare(`INSERT INTO server_meta(key,value) VALUES('query_prefix_version',?)
361
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(IS_DEFAULT_MODEL_CONFIG ? '2' : '1');
308
362
  return row.id;
309
363
  }
310
364
  // Real model loader used by the gate: version-independent cache dir with a
@@ -342,11 +396,7 @@ export class RAGKnowledgeGraphManager {
342
396
  if (role === 'owner')
343
397
  lock.markComplete();
344
398
  console.error(`✅ ${EMBEDDING_MODEL} model loaded (${MODEL_DTYPE})`);
345
- return async (text, dims, isQuery) => {
346
- const input = isQuery ? `Represent this sentence for searching relevant passages: ${text}` : text;
347
- const r = await model(input, { pooling: 'cls', normalize: true });
348
- return new Float32Array(r.data.slice(0, dims));
349
- };
399
+ return makeEmbedFn(model, IS_DEFAULT_MODEL_CONFIG);
350
400
  }
351
401
  catch (e) {
352
402
  // Cache policy by CAUSE and ROLE (beta 2R B3 -> 4R M1 -> 5R M1), unit-
@@ -544,6 +594,7 @@ export class RAGKnowledgeGraphManager {
544
594
  // session construction at exit — risks an ugly abort message, never data
545
595
  // loss. Hanging forever is the alternative and is worse.
546
596
  async shutdownAll() {
597
+ this.shuttingDown = true;
547
598
  console.error('\n🧹 Cleaning up...');
548
599
  try {
549
600
  await this.coordinator?.shutdown(5000);
@@ -626,10 +677,206 @@ export class RAGKnowledgeGraphManager {
626
677
  this.db.pragma('busy_timeout = 5000');
627
678
  }
628
679
  }
629
- catch {
680
+ catch (e) {
681
+ // An I/O error here means the connection is pointing at a file that is no longer the one
682
+ // at the path (see guardSidecars). Retrying on the same connection cannot succeed.
683
+ if (isIoError(e))
684
+ this.reopenDb(`checkpoint-${e.code}`, true);
630
685
  return false; // inside a transaction, or the file is locked — the next tick retries
631
686
  }
632
687
  }
688
+ // --- Sidecar guard (specs/changes/wal-sidecar-guard) -------------------------------------
689
+ // Measured 2026-09-25 (macOS + Google Drive): the sync client treated `-wal` as a synced item
690
+ // and swapped a new file in at the path while the engine held the old one open; every write
691
+ // then failed with SQLITE_IOERR until restart. Excluding the sidecars from sync takes them out
692
+ // of the client's hands, and it matches the WAL-exit contract: a WAL that never leaves this
693
+ // machine cannot be replayed onto another machine's main file. The attribute lives on the
694
+ // inode and SQLite recreates both files after the last connection closes, so it is set after
695
+ // every open and whenever a sidecar at the path is not the one recorded here.
696
+ walIno = null;
697
+ shmIno = null;
698
+ reopenCount = 0;
699
+ reopenTimes = [];
700
+ inFlight = 0; // tool calls running (reopen waits for them)
701
+ pendingReopen = null;
702
+ walMismatchTicks = 0; // a swap must be seen on 2 consecutive ticks
703
+ walWatchPausedUntil = 0; // storm brake for the inode watch
704
+ walWatchOff = false; // file system gives no inode (ino 0)
705
+ lastWalSize = 0;
706
+ dbDown = null;
707
+ shuttingDown = false;
708
+ // Set by main(): the full shutdown (closes the MCP transport first, then the engine). Without it
709
+ // the process would stay alive on stdin with no database.
710
+ onFatal = null;
711
+ guardSidecars() {
712
+ const wal = DB_FILE_PATH + '-wal', shm = DB_FILE_PATH + '-shm';
713
+ if (process.platform === 'darwin') {
714
+ for (const p of [wal, shm]) {
715
+ if (!fsSync.existsSync(p))
716
+ continue;
717
+ try {
718
+ execFileSync('/usr/bin/xattr', ['-w', SYNC_EXCLUDE_ATTR, '1', p], { stdio: 'ignore' });
719
+ }
720
+ catch (e) {
721
+ // Fail open: the attribute is protection, not a correctness condition.
722
+ console.error(`⚠️ sidecar sync-exclude failed: ${p}: ${e.message}`);
723
+ }
724
+ }
725
+ }
726
+ const w = fsSync.statSync(wal, { throwIfNoEntry: false, bigint: true });
727
+ const h = fsSync.statSync(shm, { throwIfNoEntry: false, bigint: true });
728
+ this.walIno = w ? w.ino : null;
729
+ this.shmIno = h ? h.ino : null;
730
+ if (w && w.ino === 0n && !this.walWatchOff) {
731
+ this.walWatchOff = true;
732
+ console.error('⚠️ file system reports inode 0 — sidecar swap watch disabled');
733
+ }
734
+ this.walMismatchTicks = 0;
735
+ }
736
+ // Runs on the WAL keeper tick. Returns true when it reopened.
737
+ walTick() {
738
+ if (this.shuttingDown)
739
+ return false;
740
+ if (!this.db)
741
+ return this.dbDown ? this.retryOpen() : false;
742
+ if (this.walWatchOff || Date.now() < this.walWatchPausedUntil)
743
+ return false;
744
+ const w = fsSync.statSync(DB_FILE_PATH + '-wal', { throwIfNoEntry: false, bigint: true });
745
+ const h = fsSync.statSync(DB_FILE_PATH + '-shm', { throwIfNoEntry: false, bigint: true });
746
+ if (this.walIno === null) {
747
+ // Nothing recorded yet (no write had created the file): record and mark it now.
748
+ if (w)
749
+ this.guardSidecars();
750
+ return false;
751
+ }
752
+ const walOk = !!w && w.ino === this.walIno;
753
+ const shmOk = this.shmIno === null || (!!h && h.ino === this.shmIno);
754
+ if (walOk && shmOk) {
755
+ this.walMismatchTicks = 0;
756
+ if (w)
757
+ this.lastWalSize = Number(w.size);
758
+ return false;
759
+ }
760
+ // Two consecutive ticks, so a value that flickers back within a second is not a swap.
761
+ if (++this.walMismatchTicks < 2)
762
+ return false;
763
+ const reason = !w ? 'wal-missing' : !walOk ? 'wal-inode-changed' : 'shm-inode-changed';
764
+ if (this.inFlight > 0) {
765
+ this.pendingReopen = reason;
766
+ return false;
767
+ }
768
+ return this.reopenDb(reason);
769
+ }
770
+ stormCount() {
771
+ const now = Date.now();
772
+ return this.reopenTimes.filter(t => now - t < REOPEN_STORM_WINDOW_MS).length;
773
+ }
774
+ // Close and reopen the connection with the same configuration. Migrations are not rerun.
775
+ // Returns false when there was nothing to reopen or the reopen failed (the engine then
776
+ // retries with backoff from the keeper tick and, after REOPEN_MAX_TRIES, exits so the host
777
+ // sees a failed server instead of a live one that answers every call with an error).
778
+ // force = an I/O error was seen on this handle: reopen even inside the storm window (holding a
779
+ // dead handle helps nobody). The storm brake is for the inode watch.
780
+ reopenDb(reason, force = false) {
781
+ if (this.shuttingDown || !this.db)
782
+ return false;
783
+ if (!force && this.stormCount() >= REOPEN_STORM_MAX) {
784
+ // Keep the connection; pause the inode watch so a flickering file system cannot make
785
+ // this a once-a-second close/open loop. An IOERR path still gets its error.
786
+ this.walWatchPausedUntil = Date.now() + WAL_WATCH_PAUSE_MS;
787
+ console.error(`⚠️ reopen storm (${REOPEN_STORM_MAX} in ${REOPEN_STORM_WINDOW_MS / 1000}s) — watch paused, reason=${reason}`);
788
+ return false;
789
+ }
790
+ const old = this.db;
791
+ // Salvage: frames committed to the old WAL after the last checkpoint (≤1 s) go to the main
792
+ // file if the old descriptor is still readable. An I/O error here just means they are gone.
793
+ // busy_timeout 0: another engine's read snapshot must not stall this for 5 s.
794
+ try {
795
+ old.pragma('busy_timeout = 0');
796
+ old.pragma('wal_checkpoint(TRUNCATE)');
797
+ }
798
+ catch { /* old file unreadable */ }
799
+ try {
800
+ old.close();
801
+ }
802
+ catch { /* the handle may already be unusable */ }
803
+ this.db = null;
804
+ this.reopenTimes.push(Date.now());
805
+ return this.openAfterClose(reason);
806
+ }
807
+ openAfterClose(reason) {
808
+ try {
809
+ this.openConnection();
810
+ this.guardSidecars();
811
+ }
812
+ catch (e) {
813
+ try {
814
+ this.db?.close();
815
+ }
816
+ catch { /* half-open */ }
817
+ this.db = null;
818
+ const tries = (this.dbDown?.tries ?? 0) + 1;
819
+ const delay = Math.min(1000 * 2 ** (tries - 1), 60_000);
820
+ this.dbDown = { reason, tries, nextAt: Date.now() + delay };
821
+ console.error(`❌ db reopen failed (reason=${reason}, try=${tries}): ${e.message}`);
822
+ if (tries >= REOPEN_MAX_TRIES) {
823
+ console.error('❌ database cannot be reopened — exiting so the host shows a failed server');
824
+ process.exitCode = 1;
825
+ if (this.onFatal)
826
+ this.onFatal();
827
+ else
828
+ void this.shutdownAll().finally(() => process.exit(process.exitCode ?? 1));
829
+ }
830
+ return false;
831
+ }
832
+ this.dbDown = null;
833
+ this.pendingReopen = null;
834
+ this.reopenCount++;
835
+ console.error(`🔁 db reopened (reason=${reason}, n=${this.reopenCount}, walsz-before=${this.lastWalSize})`);
836
+ // A reconciliation cut short by the old handle is 'pending', not failed — run it again.
837
+ if (this.coordinator?.reconState === 'pending')
838
+ void this.startReconciliation().catch(() => { });
839
+ return true;
840
+ }
841
+ retryOpen() {
842
+ if (this.shuttingDown)
843
+ return false;
844
+ if (!this.dbDown || Date.now() < this.dbDown.nextAt)
845
+ return false;
846
+ return this.openAfterClose(`retry-${this.dbDown.reason}`);
847
+ }
848
+ // Tool-call wrapper: an SQLITE_IOERR* reopens the connection. The call is retried once only
849
+ // when the tool is idempotent — a failed transaction is rolled back whole, but a tool can
850
+ // span several transactions, so a non-idempotent one gets the error back (after the reopen)
851
+ // and the caller decides. Too many reopens in a short window → no retry, surface the error.
852
+ async runWithIoRecovery(tool, fn) {
853
+ if (!this.db && this.dbDown)
854
+ this.retryOpen();
855
+ const handle = this.db;
856
+ this.inFlight++;
857
+ try {
858
+ try {
859
+ return await fn();
860
+ }
861
+ catch (e) {
862
+ if (!isIoError(e))
863
+ throw e;
864
+ const storm = this.stormCount() >= REOPEN_STORM_MAX;
865
+ // Several calls hit by one swap reopen once: if another call already replaced the
866
+ // handle, just use the new one.
867
+ const reopened = (this.db !== null && this.db !== handle)
868
+ || this.reopenDb(`tool-${tool}-${e.code}`, true);
869
+ if (storm || !reopened || !IDEMPOTENT_TOOLS.has(tool))
870
+ throw e;
871
+ return await fn();
872
+ }
873
+ }
874
+ finally {
875
+ this.inFlight--;
876
+ if (this.inFlight === 0 && this.pendingReopen)
877
+ this.reopenDb(this.pendingReopen);
878
+ }
879
+ }
633
880
  walTimer = null;
634
881
  walSoon = null;
635
882
  // Every write path ends up here without having to be listed: the periodic tick looks at
@@ -638,7 +885,15 @@ export class RAGKnowledgeGraphManager {
638
885
  startWalKeeper(tickMs = 1000) {
639
886
  if (this.walTimer)
640
887
  return;
641
- this.walTimer = setInterval(() => { this.checkpointWal(); }, tickMs);
888
+ this.walTimer = setInterval(() => {
889
+ try {
890
+ this.walTick();
891
+ }
892
+ catch (e) {
893
+ console.error(`⚠️ wal tick: ${e.message}`);
894
+ }
895
+ this.checkpointWal();
896
+ }, tickMs);
642
897
  this.walTimer.unref();
643
898
  }
644
899
  noteActivity(delayMs = 200) {
@@ -648,6 +903,7 @@ export class RAGKnowledgeGraphManager {
648
903
  this.walSoon.unref();
649
904
  }
650
905
  cleanup() {
906
+ this.shuttingDown = true;
651
907
  if (this.walTimer) {
652
908
  clearInterval(this.walTimer);
653
909
  this.walTimer = null;
@@ -3311,6 +3567,9 @@ export class RAGKnowledgeGraphManager {
3311
3567
  throw new Error('Database not initialized');
3312
3568
  if (!this.encoding)
3313
3569
  throw new Error('Tokenizer not initialized');
3570
+ // search-fusion-rrf: summary mode is read ONCE, at call entry (before any await), and used for the
3571
+ // whole call. Opt-in since this change (was opt-out via =off in v5..6.3.2).
3572
+ const summariesOn = process.env.RAG_MEMORY_SEARCH_SUMMARIES === 'on';
3314
3573
  console.error(`🔍 Enhanced hybrid search: "${query}"`);
3315
3574
  // Parity with searchNodes (beta 1R supplement): an unsearchable query gets
3316
3575
  // an explicit warning instead of a silent empty envelope.
@@ -3374,6 +3633,11 @@ export class RAGKnowledgeGraphManager {
3374
3633
  }
3375
3634
  catch (embErr) {
3376
3635
  vectorDegraded = true;
3636
+ // search-fusion-rrf (D5): a failure on a LATER cross-lingual variant used to leave the earlier
3637
+ // variants' vector candidates in resultMap while the envelope said 'fts-only'. Drop them so the
3638
+ // degraded call really is FTS-only (no vector rank, no summary, no graph) in every mode.
3639
+ resultMap.clear();
3640
+ primaryQueryEmbedding = null;
3377
3641
  // 'inference_error' (not 'model_not_ready'): model_state may still read
3378
3642
  // 'ready' here — a contradictory reason pair confused callers (beta B6).
3379
3643
  degradationReason = this.degradationReason() ?? 'inference_error';
@@ -3381,6 +3645,12 @@ export class RAGKnowledgeGraphManager {
3381
3645
  }
3382
3646
  }
3383
3647
  const vectorResults = Array.from(resultMap.values()).sort((a, b) => a.distance - b.distance);
3648
+ // search-fusion-rrf: vector rank (1-based, best distance across variants) for rank fusion.
3649
+ // Taken here, before FTS5-only candidates are appended to vectorResults below.
3650
+ const vectorRankOf = new Map(vectorResults.map((r, i) => [r.chunk_id, i + 1]));
3651
+ // Default ranking = reciprocal rank fusion of the vector list and the FTS5 list (k=60).
3652
+ // The legacy formula stays for the opt-in diagnostics (summaries on, graph re-ranker on).
3653
+ const rankFusion = !useGraph && !summariesOn;
3384
3654
  // FTS5 full-text search as additional signal (Reciprocal Rank Fusion)
3385
3655
  const ftsBoostMap = new Map();
3386
3656
  try {
@@ -3569,12 +3839,12 @@ export class RAGKnowledgeGraphManager {
3569
3839
  graphBoost += Math.min(entityBoost, 0.4);
3570
3840
  }
3571
3841
  // Generate semantic summary (skip when degraded — no embeddings available).
3572
- // RAG_MEMORY_SEARCH_SUMMARIES=off: diagnostic escape hatch (v5) — the summary
3573
- // path embeds EVERY sentence of EVERY candidate (~100+ inferences per search,
3574
- // measured 90-120s cold). Off = preview slices + relevanceScore 0; ranking
3575
- // then rests on vectorSimilarity + boosts. Default unchanged.
3842
+ // search-fusion-rrf: the summary path is OPT-IN (RAG_MEMORY_SEARCH_SUMMARIES=on). It embeds
3843
+ // every sentence fragment of every candidate one at a time (~400 inferences per search on a
3844
+ // real corpus, median 121 s measured 2026-10-03) and its unbounded context boost let
3845
+ // vector-0 chunks take rank 1 (known-item hit@1 9/10 off vs 4/10 on). Default = preview slices.
3576
3846
  let summary, keyHighlight, relevanceScore;
3577
- if (vectorDegraded || !primaryQueryEmbedding || process.env.RAG_MEMORY_SEARCH_SUMMARIES === 'off') {
3847
+ if (vectorDegraded || !primaryQueryEmbedding || !summariesOn) {
3578
3848
  keyHighlight = result.text.slice(0, 150);
3579
3849
  summary = result.text.slice(0, 300);
3580
3850
  relevanceScore = 0;
@@ -3584,8 +3854,11 @@ export class RAGKnowledgeGraphManager {
3584
3854
  ));
3585
3855
  }
3586
3856
  const vectorSimilarity = Math.max(0, 1 - result.distance / 2);
3587
- const ftsBoost = ftsBoostMap.get(result.chunk_id) || 0;
3588
- const finalScore = Math.max(vectorSimilarity, relevanceScore) + graphBoost + ftsBoost;
3857
+ const ftsBoost = ftsBoostMap.get(result.chunk_id) || 0; // = 1/(60 + FTS rank), 0 if not an FTS hit
3858
+ const vectorRank = vectorRankOf.get(result.chunk_id);
3859
+ const finalScore = rankFusion
3860
+ ? (vectorRank !== undefined ? 1 / (60 + vectorRank) : 0) + ftsBoost
3861
+ : Math.max(vectorSimilarity, relevanceScore) + graphBoost + ftsBoost;
3589
3862
  // Determine document title and source ID
3590
3863
  let documentTitle;
3591
3864
  let sourceId;
@@ -4127,116 +4400,119 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4127
4400
  // Validate arguments using our structured schema
4128
4401
  const validatedArgs = validateToolArgs(name, args);
4129
4402
  ragKgManager.noteActivity(); // fold the WAL shortly after this call, whatever it wrote
4130
- switch (name) {
4131
- // Original MCP tools
4132
- case "createEntities":
4133
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.createEntities(validatedArgs.entities), null, 2) }] };
4134
- case "createRelations":
4135
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.createRelations(validatedArgs.relations), null, 2) }] };
4136
- case "addObservations":
4137
- // v13: status·sources 를 그대로 넘긴다. 여기서 떨어뜨리면 스키마가 받아도
4138
- // 엔진에 도달하지 않아 provenance 가 조용히 사라진다.
4139
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.addObservations(validatedArgs.observations), null, 2) }] };
4140
- // v13 observation lifecycle (spec §6.1 / §6.2)
4141
- case "correctObservation":
4142
- return { content: [{ type: "text", text: JSON.stringify({ observation_id: await ragKgManager.correctObservation(validatedArgs.observation_id, validatedArgs.content, validatedArgs.change_kind ?? 'correction', validatedArgs.reason) }, null, 2) }] };
4143
- case "retractObservation":
4144
- await ragKgManager.retractObservation(validatedArgs.observation_id, validatedArgs.reason);
4145
- return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'retracted' }, null, 2) }] };
4146
- case "restoreObservation":
4147
- await ragKgManager.restoreObservation(validatedArgs.observation_id, validatedArgs.reason);
4148
- return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'active' }, null, 2) }] };
4149
- case "approveObservation":
4150
- await ragKgManager.approveObservation(validatedArgs.observation_id, validatedArgs.reason);
4151
- return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'active' }, null, 2) }] };
4152
- case "declineObservation":
4153
- await ragKgManager.declineObservation(validatedArgs.observation_id, validatedArgs.reason);
4154
- return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'retracted' }, null, 2) }] };
4155
- case "purgeObservation":
4156
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.purgeObservation(validatedArgs.observation_id, validatedArgs.confirm), null, 2) }] };
4157
- case "getObservationHistory":
4158
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getObservationHistory({
4159
- entity_name: validatedArgs.entity_name,
4160
- observation_id: validatedArgs.observation_id,
4161
- root_id: validatedArgs.root_id,
4162
- }), null, 2) }] };
4163
- case "deleteEntities":
4164
- await ragKgManager.deleteEntities(validatedArgs.entityNames);
4165
- return { content: [{ type: "text", text: "Entities deleted successfully" }] };
4166
- case "deleteObservations":
4167
- // v3.6 (spec §5c, breaking): structured per-entity results replace the
4168
- // bare success string — mixed embedded/queued/no-op states are visible.
4169
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.deleteObservations(validatedArgs.deletions), null, 2) }] };
4170
- case "deleteRelations":
4171
- await ragKgManager.deleteRelations(validatedArgs.relations);
4172
- return { content: [{ type: "text", text: "Relations deleted successfully" }] };
4173
- case "updateRelations":
4174
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.updateRelations(validatedArgs.updates), null, 2) }] };
4175
- case "readGraph":
4176
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.readGraph(), null, 2) }] };
4177
- case "searchNodes":
4178
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.searchNodes(validatedArgs.query, validatedArgs.limit || 10, validatedArgs.since, validatedArgs.until), null, 2) }] };
4179
- case "openNodes":
4180
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.openNodes(validatedArgs.names), null, 2) }] };
4181
- case "getNeighbors":
4182
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getNeighbors(validatedArgs.entityNames, validatedArgs.depth || 1, validatedArgs.relationType), null, 2) }] };
4183
- // New RAG tools
4184
- case "storeDocument":
4185
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.storeDocument(validatedArgs.id, validatedArgs.content, validatedArgs.metadata || {}), null, 2) }] };
4186
- case "chunkDocument":
4187
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.chunkDocument(validatedArgs.documentId, { maxTokens: validatedArgs.maxTokens, overlap: validatedArgs.overlap }), null, 2) }] };
4188
- case "embedChunks":
4189
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.embedChunks(validatedArgs.documentId), null, 2) }] };
4190
- case "extractTerms":
4191
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.extractTerms(validatedArgs.documentId, { minLength: validatedArgs.minLength, includeCapitalized: validatedArgs.includeCapitalized, customPatterns: validatedArgs.customPatterns }), null, 2) }] };
4192
- case "linkEntitiesToDocument":
4193
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.linkEntitiesToDocument(validatedArgs.documentId, validatedArgs.entityNames), null, 2) }] };
4194
- case "hybridSearch":
4195
- const limit = typeof validatedArgs.limit === 'number' ? validatedArgs.limit : 5;
4196
- // v5.3.0: graph is opt-in — only an explicit true enables the re-ranker (schema default false).
4197
- const useGraph = validatedArgs.useGraph === true;
4198
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.hybridSearch(validatedArgs.query, limit, useGraph), null, 2) }] };
4199
- case "getDetailedContext":
4200
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getDetailedContext(validatedArgs.chunkId, validatedArgs.includeSurrounding !== false), null, 2) }] };
4201
- case "getKnowledgeGraphStats":
4202
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getKnowledgeGraphStats(), null, 2) }] };
4203
- case "deleteDocuments":
4204
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.deleteDocuments(validatedArgs.documentIds), null, 2) }] };
4205
- case "listDocuments":
4206
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.listDocuments(validatedArgs.includeMetadata !== false), null, 2) }] };
4207
- case "syncDocumentFromFile":
4208
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.syncDocumentFromFile(validatedArgs.path, validatedArgs.documentId, {
4209
- metadata: validatedArgs.metadata,
4210
- content: validatedArgs.content,
4211
- excludePattern: validatedArgs.excludePattern,
4212
- entityNames: validatedArgs.entityNames,
4213
- chunkParams: validatedArgs.chunkParams,
4214
- }), null, 2) }] };
4215
- // NEW: Entity embedding tools
4216
- case "embedAllEntities":
4217
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.embedAllEntities(), null, 2) }] };
4218
- // NEW: Export/Import tools
4219
- case "exportGraph":
4220
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.exportGraph(), null, 2) }] };
4221
- case "importGraph":
4222
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.importGraph(validatedArgs.data, { merge: validatedArgs.merge !== false }), null, 2) }] };
4223
- // NEW: Migration tools
4224
- case "getMigrationStatus":
4225
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getMigrationStatus(), null, 2) }] };
4226
- case "runMigrations":
4227
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.runMigrations(), null, 2) }] };
4228
- case "rollbackMigration":
4229
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.rollbackMigration(validatedArgs.targetVersion), null, 2) }] };
4230
- // Graph Analytics tools (graphology)
4231
- case "getGraphMetrics":
4232
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getGraphMetrics(validatedArgs.entityNames, validatedArgs.metrics, validatedArgs.limit || 10), null, 2) }] };
4233
- case "detectCommunities":
4234
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.detectCommunities(validatedArgs.resolution || 1.0), null, 2) }] };
4235
- case "analyzeGraphStructure":
4236
- return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.analyzeGraphStructure(), null, 2) }] };
4237
- default:
4238
- throw new Error(`Unknown tool: ${name}`);
4239
- }
4403
+ // An SQLITE_IOERR* reopens the connection; idempotent tools are retried once (wal-sidecar-guard).
4404
+ return await ragKgManager.runWithIoRecovery(name, async () => {
4405
+ switch (name) {
4406
+ // Original MCP tools
4407
+ case "createEntities":
4408
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.createEntities(validatedArgs.entities), null, 2) }] };
4409
+ case "createRelations":
4410
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.createRelations(validatedArgs.relations), null, 2) }] };
4411
+ case "addObservations":
4412
+ // v13: status·sources 를 그대로 넘긴다. 여기서 떨어뜨리면 스키마가 받아도
4413
+ // 엔진에 도달하지 않아 provenance 가 조용히 사라진다.
4414
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.addObservations(validatedArgs.observations), null, 2) }] };
4415
+ // v13 observation lifecycle (spec §6.1 / §6.2)
4416
+ case "correctObservation":
4417
+ return { content: [{ type: "text", text: JSON.stringify({ observation_id: await ragKgManager.correctObservation(validatedArgs.observation_id, validatedArgs.content, validatedArgs.change_kind ?? 'correction', validatedArgs.reason) }, null, 2) }] };
4418
+ case "retractObservation":
4419
+ await ragKgManager.retractObservation(validatedArgs.observation_id, validatedArgs.reason);
4420
+ return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'retracted' }, null, 2) }] };
4421
+ case "restoreObservation":
4422
+ await ragKgManager.restoreObservation(validatedArgs.observation_id, validatedArgs.reason);
4423
+ return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'active' }, null, 2) }] };
4424
+ case "approveObservation":
4425
+ await ragKgManager.approveObservation(validatedArgs.observation_id, validatedArgs.reason);
4426
+ return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'active' }, null, 2) }] };
4427
+ case "declineObservation":
4428
+ await ragKgManager.declineObservation(validatedArgs.observation_id, validatedArgs.reason);
4429
+ return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'retracted' }, null, 2) }] };
4430
+ case "purgeObservation":
4431
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.purgeObservation(validatedArgs.observation_id, validatedArgs.confirm), null, 2) }] };
4432
+ case "getObservationHistory":
4433
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getObservationHistory({
4434
+ entity_name: validatedArgs.entity_name,
4435
+ observation_id: validatedArgs.observation_id,
4436
+ root_id: validatedArgs.root_id,
4437
+ }), null, 2) }] };
4438
+ case "deleteEntities":
4439
+ await ragKgManager.deleteEntities(validatedArgs.entityNames);
4440
+ return { content: [{ type: "text", text: "Entities deleted successfully" }] };
4441
+ case "deleteObservations":
4442
+ // v3.6 (spec §5c, breaking): structured per-entity results replace the
4443
+ // bare success string — mixed embedded/queued/no-op states are visible.
4444
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.deleteObservations(validatedArgs.deletions), null, 2) }] };
4445
+ case "deleteRelations":
4446
+ await ragKgManager.deleteRelations(validatedArgs.relations);
4447
+ return { content: [{ type: "text", text: "Relations deleted successfully" }] };
4448
+ case "updateRelations":
4449
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.updateRelations(validatedArgs.updates), null, 2) }] };
4450
+ case "readGraph":
4451
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.readGraph(), null, 2) }] };
4452
+ case "searchNodes":
4453
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.searchNodes(validatedArgs.query, validatedArgs.limit || 10, validatedArgs.since, validatedArgs.until), null, 2) }] };
4454
+ case "openNodes":
4455
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.openNodes(validatedArgs.names), null, 2) }] };
4456
+ case "getNeighbors":
4457
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getNeighbors(validatedArgs.entityNames, validatedArgs.depth || 1, validatedArgs.relationType), null, 2) }] };
4458
+ // New RAG tools
4459
+ case "storeDocument":
4460
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.storeDocument(validatedArgs.id, validatedArgs.content, validatedArgs.metadata || {}), null, 2) }] };
4461
+ case "chunkDocument":
4462
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.chunkDocument(validatedArgs.documentId, { maxTokens: validatedArgs.maxTokens, overlap: validatedArgs.overlap }), null, 2) }] };
4463
+ case "embedChunks":
4464
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.embedChunks(validatedArgs.documentId), null, 2) }] };
4465
+ case "extractTerms":
4466
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.extractTerms(validatedArgs.documentId, { minLength: validatedArgs.minLength, includeCapitalized: validatedArgs.includeCapitalized, customPatterns: validatedArgs.customPatterns }), null, 2) }] };
4467
+ case "linkEntitiesToDocument":
4468
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.linkEntitiesToDocument(validatedArgs.documentId, validatedArgs.entityNames), null, 2) }] };
4469
+ case "hybridSearch":
4470
+ const limit = typeof validatedArgs.limit === 'number' ? validatedArgs.limit : 5;
4471
+ // v5.3.0: graph is opt-in — only an explicit true enables the re-ranker (schema default false).
4472
+ const useGraph = validatedArgs.useGraph === true;
4473
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.hybridSearch(validatedArgs.query, limit, useGraph), null, 2) }] };
4474
+ case "getDetailedContext":
4475
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getDetailedContext(validatedArgs.chunkId, validatedArgs.includeSurrounding !== false), null, 2) }] };
4476
+ case "getKnowledgeGraphStats":
4477
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getKnowledgeGraphStats(), null, 2) }] };
4478
+ case "deleteDocuments":
4479
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.deleteDocuments(validatedArgs.documentIds), null, 2) }] };
4480
+ case "listDocuments":
4481
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.listDocuments(validatedArgs.includeMetadata !== false), null, 2) }] };
4482
+ case "syncDocumentFromFile":
4483
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.syncDocumentFromFile(validatedArgs.path, validatedArgs.documentId, {
4484
+ metadata: validatedArgs.metadata,
4485
+ content: validatedArgs.content,
4486
+ excludePattern: validatedArgs.excludePattern,
4487
+ entityNames: validatedArgs.entityNames,
4488
+ chunkParams: validatedArgs.chunkParams,
4489
+ }), null, 2) }] };
4490
+ // NEW: Entity embedding tools
4491
+ case "embedAllEntities":
4492
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.embedAllEntities(), null, 2) }] };
4493
+ // NEW: Export/Import tools
4494
+ case "exportGraph":
4495
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.exportGraph(), null, 2) }] };
4496
+ case "importGraph":
4497
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.importGraph(validatedArgs.data, { merge: validatedArgs.merge !== false }), null, 2) }] };
4498
+ // NEW: Migration tools
4499
+ case "getMigrationStatus":
4500
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getMigrationStatus(), null, 2) }] };
4501
+ case "runMigrations":
4502
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.runMigrations(), null, 2) }] };
4503
+ case "rollbackMigration":
4504
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.rollbackMigration(validatedArgs.targetVersion), null, 2) }] };
4505
+ // Graph Analytics tools (graphology)
4506
+ case "getGraphMetrics":
4507
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getGraphMetrics(validatedArgs.entityNames, validatedArgs.metrics, validatedArgs.limit || 10), null, 2) }] };
4508
+ case "detectCommunities":
4509
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.detectCommunities(validatedArgs.resolution || 1.0), null, 2) }] };
4510
+ case "analyzeGraphStructure":
4511
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.analyzeGraphStructure(), null, 2) }] };
4512
+ default:
4513
+ throw new Error(`Unknown tool: ${name}`);
4514
+ }
4515
+ });
4240
4516
  }
4241
4517
  catch (error) {
4242
4518
  // v3.6 (spec §5c): machine-distinguishable failures. Embedding-gate errors
@@ -4301,6 +4577,7 @@ async function main() {
4301
4577
  if (shuttingDown)
4302
4578
  return;
4303
4579
  shuttingDown = true;
4580
+ ragKgManager.shuttingDown = true; // no reopen inside the exit window (wal-sidecar-guard)
4304
4581
  // Synchronously, before anything is awaited: a host may SIGKILL before the settle
4305
4582
  // sequence below completes (codex: ~185 ms after SIGTERM).
4306
4583
  ragKgManager.checkpointWal();
@@ -4325,6 +4602,7 @@ async function main() {
4325
4602
  // Ctrl+Break; registering them is harmless where they never fire.
4326
4603
  process.on('SIGHUP', shutdown);
4327
4604
  process.on('SIGBREAK', shutdown);
4605
+ ragKgManager.onFatal = shutdown; // a database that cannot be reopened ends the server (wal-sidecar-guard)
4328
4606
  ragKgManager.startWalKeeper();
4329
4607
  process.on('exit', () => { try {
4330
4608
  ragKgManager.cleanup();
@@ -35,6 +35,7 @@ export declare class BackfillCoordinator {
35
35
  private kickTimer;
36
36
  private sweepTimer;
37
37
  private shuttingDown;
38
+ private connLossReruns;
38
39
  private scanning;
39
40
  private scanPromise;
40
41
  private rerunRequested;
@@ -14,6 +14,15 @@
14
14
  // kick() merely triggers a debounced scan. All reconciliation writes are
15
15
  // per-row transactions with a CAS re-check so a foreground embed that lands
16
16
  // first is never downgraded (6R note 3).
17
+ // A handle closed by the engine's reopen (wal-sidecar-guard), or an I/O error from a sidecar the
18
+ // sync client swapped out: the run was cut, not broken.
19
+ const CONN_LOSS_RERUN_MAX = 3;
20
+ function isConnectionLoss(e) {
21
+ const code = e?.code;
22
+ if (typeof code === 'string' && code.startsWith('SQLITE_IOERR'))
23
+ return true;
24
+ return e instanceof Error && /database connection is not open/i.test(e.message);
25
+ }
17
26
  const RECON_BATCH = 50;
18
27
  const KICK_DEBOUNCE_MS = 200;
19
28
  const SWEEP_MS = 5 * 60_000;
@@ -25,6 +34,7 @@ export class BackfillCoordinator {
25
34
  kickTimer = null;
26
35
  sweepTimer = null;
27
36
  shuttingDown = false;
37
+ connLossReruns = 0;
28
38
  scanning = false;
29
39
  scanPromise = null;
30
40
  rerunRequested = false;
@@ -178,6 +188,7 @@ export class BackfillCoordinator {
178
188
  if (remaining !== 0)
179
189
  throw new Error(`complete invariant violated: ${remaining} unreconciled vectors remain`);
180
190
  this.recon = 'complete';
191
+ this.connLossReruns = 0;
181
192
  this.snapshot = null;
182
193
  console.error('✅ provenance reconciliation complete');
183
194
  this.kick();
@@ -190,6 +201,18 @@ export class BackfillCoordinator {
190
201
  this.reconPromise = null;
191
202
  return;
192
203
  }
204
+ if (isConnectionLoss(e) && this.connLossReruns < CONN_LOSS_RERUN_MAX) {
205
+ // The engine reopened the connection under us (wal-sidecar-guard). Not broken: run
206
+ // again on the new handle. If there is no handle right now (reopen failed), the engine
207
+ // starts reconciliation itself once it reopens.
208
+ this.connLossReruns++;
209
+ this.recon = 'pending';
210
+ this.reconPromise = null;
211
+ if (this.deps.db())
212
+ setTimeout(() => { void this.runReconciliation(); }, 0).unref?.();
213
+ console.error(`↻ reconciliation interrupted by a reconnect — rerun ${this.connLossReruns}/${CONN_LOSS_RERUN_MAX}`);
214
+ return;
215
+ }
193
216
  this.recon = 'failed';
194
217
  this.reconError = e instanceof Error ? e.message : String(e);
195
218
  console.error(`❌ reconciliation failed — vector search stays disabled: ${this.reconError}`);
@@ -277,7 +277,7 @@ export const addObservationsTool = {
277
277
  };
278
278
  // === HYBRID SEARCH TOOL ===
279
279
  const hybridSearchCapability = {
280
- description: 'Perform advanced hybrid search combining vector similarity with knowledge graph traversal',
280
+ description: 'Perform hybrid search over vector and FTS5 candidates with default reciprocal rank fusion and an optional legacy graph re-ranker',
281
281
  parameters: {
282
282
  type: 'object',
283
283
  properties: {
@@ -300,7 +300,8 @@ const hybridSearchCapability = {
300
300
  },
301
301
  };
302
302
  const hybridSearchDescription = () => `<description>
303
- Hybrid document search: vector similarity + FTS5 BM25 over document chunks, with an **opt-in** knowledge-graph re-ranker.
303
+ Hybrid document search: vector similarity + FTS5 BM25 over document chunks, fused by rank (reciprocal rank fusion,
304
+ k=60, so an exact identifier that only FTS5 finds can still enter the returned top results), with an **opt-in** knowledge-graph re-ranker.
304
305
  Default (useGraph false) is the harm-reduced default for finding a fact you know exists (known-item retrieval).
305
306
  useGraph: true is a legacy/experimental re-ranker kept for backward compatibility and controlled evaluation — it does not
306
307
  add candidates, it only re-orders the vector/FTS pool, and measured on three real corpora (2026-08-17) it pushed the exact
@@ -310,14 +311,14 @@ chunk out of the top-5 in ~40% of known-item queries. For relationship explorati
310
311
  <importantNotes>
311
312
  - (!important!) **Graph re-ranking is opt-in (default false since v5.3.0)** — it re-orders candidates by matched/connected entity links, which is measured to hurt known-item retrieval; enable it only for backward compatibility or controlled evaluation
312
313
  - (!important!) Graph re-ranking does not generate candidates — for "what is connected to X" use openNodes -> getNeighbors
313
- - (!important!) Results include similarity scores, graph boost, and hybrid rankings
314
+ - (!important!) relevance_score is a RANK score on the default call: 1/(60 + vector rank) + 1/(60 + FTS rank), at most 2/61; it is not a similarity or probability and is only comparable within one call (vector_similarity and fts_boost are reported alongside)
314
315
  - (!important!) **Best results when knowledge graph is well-populated** with entities and relationships
315
316
  </importantNotes>
316
317
 
317
318
  <whenToUseThisTool>
318
319
  - When you need comprehensive search across documents and knowledge
319
320
  - For complex queries requiring conceptual understanding
320
- - When exploring relationships between concepts
321
+ - Exact identifiers (file names, setting keys, function names, hashes) as well as natural-language questions
321
322
  - **Before making decisions** - to gather all relevant information
322
323
  - When researching topics that span multiple domains
323
324
  - For discovery of implicit connections and patterns
@@ -325,7 +326,8 @@ chunk out of the top-5 in ~40% of known-item queries. For relationship explorati
325
326
 
326
327
  <features>
327
328
  - Vector similarity search using sentence transformers
328
- - Knowledge graph traversal for conceptual enhancement
329
+ - Rank fusion of vector and FTS5 candidates: an exact term found only by FTS5 can enter the returned top results even when vector candidates are present
330
+ - Optional legacy graph re-ranker (re-orders only; no traversal — use openNodes -> getNeighbors)
329
331
  - Hybrid scoring combining multiple relevance signals
330
332
  - Entity association highlighting
331
333
  - Configurable result limits and graph usage
@@ -333,7 +335,7 @@ chunk out of the top-5 in ~40% of known-item queries. For relationship explorati
333
335
  </features>
334
336
 
335
337
  <bestPractices>
336
- - Use natural language queries rather than keywords
338
+ - Natural-language questions and exact identifiers both work; pass the identifier itself when you know it
337
339
  - Keep graph re-ranking off for "find the fact I know exists"; for "what is connected to this" use openNodes -> getNeighbors, not useGraph
338
340
  - Start with broader queries, then narrow down based on results
339
341
  - Review entity associations to understand why results were selected
@@ -342,7 +344,7 @@ chunk out of the top-5 in ~40% of known-item queries. For relationship explorati
342
344
  </bestPractices>
343
345
 
344
346
  <parameters>
345
- - query: Natural language search query (string, required)
347
+ - query: Search query — a natural-language question or an exact term/identifier (string, required)
346
348
  - limit: Maximum results to return, default 5 (number, optional)
347
349
  - useGraph: Enable knowledge graph re-ranking, default false / opt-in (boolean, optional)
348
350
  </parameters>
package/docs/UPDATING.md CHANGED
@@ -108,6 +108,52 @@ path and holder pid (e.g. `.download-<key>.lock`). Verify the holder process
108
108
  is genuinely gone or hung (`ps -p <pid>`), then remove the lock file manually;
109
109
  the next start becomes a clean download owner.
110
110
 
111
+ ## v6.4.0 (schema v14, unchanged): `hybridSearch` default = rank fusion, summaries opt-in
112
+
113
+ (Release candidate `6.4.0-rc.1` on the `next` dist-tag first; `latest` stays 6.3.2 until the published artifact
114
+ passes a canary against a real project database.)
115
+
116
+ **What changed.** (1) The per-result summary path runs only with `RAG_MEMORY_SEARCH_SUMMARIES=on` (it used to
117
+ run unless the variable was `off`; `off` still means off). (2) With summaries off and `useGraph: false` — the
118
+ default call — results are ranked by reciprocal rank fusion of the vector list and the FTS5 list:
119
+ `relevance_score = 1/(60 + vector rank) + 1/(60 + FTS rank)`, a list a chunk is absent from contributes 0.
120
+ Healthy-path candidate gathering and depth are unchanged. If vector search fails after an earlier query variant
121
+ succeeded, the call now discards those partial vector candidates and returns a true FTS-only result (the previous
122
+ version could keep them while reporting `fts-only`). `summaries=on` or `useGraph: true` keep the 6.3.2 formula
123
+ (`max(vector_similarity, summary relevance) + graph boost + FTS term`) unchanged.
124
+
125
+ **Why.** Measured 2026-10-03 on the frozen eval snapshots (spec `specs/changes/search-fusion-rrf/proposal.md`):
126
+ the summary path embedded ~400 sentence fragments per search (median 121 s) and its unbounded context boost let
127
+ vector-0 chunks take rank 1 (known-item hit@1 9/10 with summaries off vs 4/10 on). With summaries off, a chunk
128
+ found only by FTS scored at most 0.0164 against ~0.4-0.6 for every vector candidate, so exact-identifier
129
+ queries surfaced their target in the top 10 for 7/20 queries although FTS ranked it first for 17/20. Rank fusion:
130
+ 18/20, and worse than the old formula on 0 of 203 queries across three corpora.
131
+
132
+ **Query input (same release).** With the default model (bge-m3) queries are embedded as raw text; a custom
133
+ `EMBEDDING_MODEL` keeps the previous query instruction (the evidence below is for bge-m3 only). Up to 6.3.2 the loader prepended the bge-v1.5
134
+ instruction `Represent this sentence for searching relevant passages: ` to every query (hybridSearch, searchNodes,
135
+ graph seeds) while stored vectors were embedded without it; the bge-m3 model card says the model needs no query
136
+ instruction. Measured on the frozen snapshots: hybridSearch paraphrase hit@10 7 -> 9 / 20 and MRR 0.217 -> 0.275,
137
+ identifier hit@1 7 -> 8, known-item unchanged on hub / uap and hit@1 +1 on hal; searchNodes known-item (40 entities per
138
+ corpus) hit@10 unchanged, hit@1 -1 / -1 / +1. No re-index: only query vectors change; rolling back means a restart.
139
+ `server_meta.query_prefix_version` is a diagnostic marker written at init (2 = raw queries with the default model,
140
+ 1 = instruction); it does not prove what the last query used.
141
+
142
+ **What a caller notices.** On the default call `relevance_score` is a rank score — at most 2/61 ≈ 0.0328, no lower
143
+ bound (a chunk in one list at rank r gets 1/(60+r)), comparable only within one call; do not threshold it against
144
+ old values or across queries/modes. `vector_similarity` and `fts_boost` keep their meaning. `content_summary`
145
+ / `key_highlight` are preview slices unless summaries are turned on. The fragment-summary work is gone from default
146
+ searches: measured query-cached medians were 20 / 10 / 6 ms on the frozen hub / uap / hal runs; with the query
147
+ embedding cache cleared before every call (one Mac, final build) medians were about 220 / 228 /
148
+ 203 ms (max 888 ms). Other machines and corpora are unmeasured — this is not a universal millisecond guarantee. With
149
+ `useGraph: true` and the variable unset, summaries are now off; set `RAG_MEMORY_SEARCH_SUMMARIES=on` to reproduce
150
+ the previous summary-enabled graph path.
151
+
152
+ **Fleet rollout.** No migration. Recommended like v5.3.0: publish an rc on the `next` dist-tag first, run it
153
+ against a real project database (identifier query + known-item probe), then promote to `latest`.
154
+ Regression locks: `test/search-fusion-rrf.test.mjs`, `test/search-summaries-off.test.mjs`, and
155
+ `test/graph-context-explain.test.mjs` (legacy path byte-identical with summaries on).
156
+
111
157
  ## v5.3.0 (schema v14, unchanged): `hybridSearch` graph re-ranking is opt-in
112
158
 
113
159
  **What changed.** `useGraph` defaults to `false` (was `true`) — in the manager signature, the tool JSON
@@ -207,8 +253,10 @@ the real v14 (this is exactly how code-v8 never ran in production).
207
253
  **Diagnostic env (v5)**: `RAG_MEMORY_SEARCH_SUMMARIES=off` disables the per-result
208
254
  sentence-similarity summaries in `hybridSearch` (which embed every sentence of every
209
255
  candidate — 100+ inferences, 90-120s cold per search, measured). Off = preview-slice
210
- summaries, `relevance_score` 0, ranking rests on vector similarity + boosts. Default
211
- unchanged. The 3-arm release harness sets this uniformly across all arms.
256
+ summaries, the internal summary relevance is 0 (the response's `relevance_score` is still the final score) and
257
+ ranking rests on vector similarity + boosts. **Superseded by the next
258
+ release: summaries are off by default and opt-in with `=on`** (see the section above). The 3-arm release
259
+ harness sets this uniformly across all arms.
212
260
 
213
261
  ## v3.6 breaking response changes
214
262
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rag-memory-epf-mcp",
3
- "version": "6.3.1",
3
+ "version": "6.4.0-rc.1",
4
4
  "engines": {
5
5
  "node": ">=24"
6
6
  },
@@ -45,7 +45,7 @@
45
45
  "prepare": "npm run build",
46
46
  "watch": "tsc --watch",
47
47
  "verify:invariants": "node test/chunk-invariants.test.mjs",
48
- "verify:engine": "node test/engine-smoke.test.mjs && node test/launch-smoke.test.mjs && node test/sync-atomicity.test.mjs && node test/dedup.test.mjs && node test/search-degradation.test.mjs && node test/entity-embed-cap.test.mjs && node test/migration12.test.mjs && node test/model-cache.test.mjs && node test/embedding-gate.test.mjs && node test/lazy-boot.test.mjs && node test/reconciliation.test.mjs && node test/backfill.test.mjs && node test/fts-query.test.mjs && node test/search-contracts.test.mjs && node test/tool-contracts.test.mjs && node test/bounded-exit.test.mjs && node test/observation-schema.test.mjs && node test/search-graph-default.test.mjs && node test/observation-migration.test.mjs && node test/observation-lifecycle.test.mjs && node test/observation-contracts.test.mjs && node test/observation-search.test.mjs && node test/observation-cascade.test.mjs && node test/observation-realdata.test.mjs && node test/chunker-c.test.mjs && node test/migration14.test.mjs && node test/chunk-params-validation.test.mjs && node test/vector-reuse.test.mjs && node test/entity-range-linking.test.mjs && node test/alias-link-gate.test.mjs && node test/entity-name-boundary.test.mjs && node test/stats-chunking.test.mjs && node test/migration14-realdata.test.mjs && node test/migration14-realdata-sync.test.mjs && node test/search-summaries-off.test.mjs && node test/document-return-contracts.test.mjs && node test/observation-date-prefix.test.mjs && node test/delete-entities-cascade.test.mjs && node test/delete-entities-kg-hygiene.test.mjs && node test/backup-publish-portable.test.mjs && node test/graph-context-explain.test.mjs && node test/eval-graph-role-libs.test.mjs && node test/eval-graph-role-t5b.test.mjs && node --test test/eval-graph-role-t8-fix.test.mjs && node --test test/eval-graph-role-t7-upstream.test.mjs && node --test test/eval-graph-role-t11-decision.test.mjs && node --test test/eval-graph-role-prereq-fix.test.mjs && node test/db-path.test.mjs && node test/mmap-option.test.mjs && node test/wal-exit.test.mjs",
48
+ "verify:engine": "node test/engine-smoke.test.mjs && node test/launch-smoke.test.mjs && node test/sync-atomicity.test.mjs && node test/dedup.test.mjs && node test/search-degradation.test.mjs && node test/entity-embed-cap.test.mjs && node test/migration12.test.mjs && node test/model-cache.test.mjs && node test/embedding-gate.test.mjs && node test/lazy-boot.test.mjs && node test/reconciliation.test.mjs && node test/backfill.test.mjs && node test/fts-query.test.mjs && node test/search-contracts.test.mjs && node test/tool-contracts.test.mjs && node test/bounded-exit.test.mjs && node test/observation-schema.test.mjs && node test/search-graph-default.test.mjs && node test/observation-migration.test.mjs && node test/observation-lifecycle.test.mjs && node test/observation-contracts.test.mjs && node test/observation-search.test.mjs && node test/observation-cascade.test.mjs && node test/observation-realdata.test.mjs && node test/chunker-c.test.mjs && node test/migration14.test.mjs && node test/chunk-params-validation.test.mjs && node test/vector-reuse.test.mjs && node test/entity-range-linking.test.mjs && node test/alias-link-gate.test.mjs && node test/entity-name-boundary.test.mjs && node test/stats-chunking.test.mjs && node test/migration14-realdata.test.mjs && node test/migration14-realdata-sync.test.mjs && node test/search-summaries-off.test.mjs && node test/search-fusion-rrf.test.mjs && node test/query-input.test.mjs && node test/document-return-contracts.test.mjs && node test/observation-date-prefix.test.mjs && node test/delete-entities-cascade.test.mjs && node test/delete-entities-kg-hygiene.test.mjs && node test/backup-publish-portable.test.mjs && node test/graph-context-explain.test.mjs && node test/eval-graph-role-libs.test.mjs && node test/eval-graph-role-t5b.test.mjs && node --test test/eval-graph-role-t8-fix.test.mjs && node --test test/eval-graph-role-t7-upstream.test.mjs && node --test test/eval-graph-role-t11-decision.test.mjs && node --test test/eval-graph-role-prereq-fix.test.mjs && node test/db-path.test.mjs && node test/mmap-option.test.mjs && node test/wal-exit.test.mjs && node test/wal-sidecar-guard.test.mjs",
49
49
  "test": "npm run build && npm run verify:invariants && npm run verify:engine",
50
50
  "prepublishOnly": "npm run build && npm run verify:invariants && npm run verify:engine"
51
51
  },