rag-memory-epf-mcp 4.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@ A **project-local RAG memory** MCP server — knowledge graph + multilingual vec
15
15
  - **Graph-aware scoring** — per-entity geometric decay (0.5^i) with hard cap prevents any single document from dominating results
16
16
  - **38 MCP tools** — knowledge graph CRUD, observation lifecycle (correct / retract / history), document pipeline, hybrid search, multi-hop traversal, graph analytics (centrality / community detection / structure), export/import, temporal queries
17
17
  - **Observations that hold their history** — corrections supersede instead of overwrite, search returns only current facts, and every revision keeps its provenance
18
- - **Codepoint-safe chunking** — chunk offsets are Unicode codepoints, language-neutral across SQL `substr`, Python slicing, and JS `[...str]` iteration. Korean/CJK/emoji documents stay aligned. Verified by a publish-time invariant test.
18
+ - **Structure-anchored chunking (c1, v5)** — boundaries anchor to markdown structure (fence-aware, H1–H4 first, block-greedy, exact-token fallback), so editing the top of a file no longer re-embeds the whole document: unchanged text reuses its stored vectors at sync time. Chunk offsets are Unicode codepoints, language-neutral across SQL `substr`, Python slicing, and JS `[...str]` iteration; a publish-time invariant gate locks the gap-free partition. `overlap` is retired (omit or 0).
19
19
  - **SQLite optimized** — WAL mode, 32MB cache, 256MB mmap, FTS5 triggers, 7 indexes
20
20
  - **MCP SDK 1.27.1** — Tool Annotations (readOnly/destructive/idempotent), latest protocol 2025-11-25
21
21
 
@@ -64,7 +64,7 @@ Place this `.mcp.json` in each project folder with its own `DB_FILE_PATH`. Each
64
64
  ### Document Pipeline (9)
65
65
  | Tool | Description | Annotation |
66
66
  |------|------------|------------|
67
- | `storeDocument` | Store documents with metadata | idempotent |
67
+ | `storeDocument` | Store documents with metadata. Replacing an existing document reports what it destroyed: `{ replaced, deletedChunks }` | idempotent |
68
68
  | `chunkDocument` | Create text chunks with configurable parameters | — |
69
69
  | `embedChunks` | Generate 1024-dim embeddings + auto-link entities | idempotent |
70
70
  | `embedAllEntities` | Batch embed all entities (32 parallel) | idempotent |
@@ -72,7 +72,7 @@ Place this `.mcp.json` in each project folder with its own `DB_FILE_PATH`. Each
72
72
  | `linkEntitiesToDocument` | Link entities to chunks where they actually appear (text-matched) | idempotent |
73
73
  | `deleteDocuments` | Remove documents and associated data | destructive |
74
74
  | `listDocuments` | View all stored documents | readOnly |
75
- | `syncDocumentFromFile` | One-call server-side sync: reads file + delete/store/chunk/embed/link, content stays off model context. Atomic (embed-first transaction swap) + `content_hash` dedup (skips unchanged files) | idempotent |
75
+ | `syncDocumentFromFile` | One-call server-side sync: reads file + delete/store/chunk/embed/link, content stays off model context. Atomic (embed-first transaction swap) + `content_hash` dedup (skips unchanged files). `excludePattern` strips regions before indexing, and the hash follows the stripped text so changing the pattern re-indexes | idempotent |
76
76
 
77
77
  ### Search & Retrieval (9)
78
78
  | Tool | Description | Annotation |
@@ -105,7 +105,7 @@ Place this `.mcp.json` in each project folder with its own `DB_FILE_PATH`. Each
105
105
 
106
106
  ```
107
107
  storeDocument(id, content, metadata)
108
- → chunkDocument(documentId, maxTokens, overlap)
108
+ → chunkDocument(documentId, maxTokens) # overlap retired in v5 (omit or 0)
109
109
  → embedChunks(documentId)
110
110
  ├── generates vector embeddings for each chunk
111
111
  ├── auto-links entities to chunks (word boundary + CJK aware)
package/dist/index.d.ts CHANGED
@@ -46,6 +46,22 @@ interface DetailedContext {
46
46
  entities: string[];
47
47
  metadata: Record<string, any>;
48
48
  }
49
+ export declare class SyncCasConflictError extends Error {
50
+ constructor(documentId: string);
51
+ }
52
+ export declare function setSyncFaultPoint(point: string | null, fn: (() => void) | null): void;
53
+ export interface ReuseCandidate {
54
+ rowid: number;
55
+ text: string;
56
+ input_hash: string | null;
57
+ profile_id: number | null;
58
+ provenance_state: string | null;
59
+ embedding: Buffer | null;
60
+ }
61
+ export declare function selectReusableVector(candidates: ReuseCandidate[], text: string, currentProfileId: number, sha256hex: (s: string) => string): {
62
+ vec: Buffer;
63
+ provenance: 'verified' | 'legacy_assumed';
64
+ } | null;
49
65
  export declare class RAGKnowledgeGraphManager {
50
66
  private db;
51
67
  private encoding;
@@ -205,11 +221,13 @@ export declare class RAGKnowledgeGraphManager {
205
221
  private translateQueryWithMap;
206
222
  private buildCrossLingualVariants;
207
223
  private extractTermsFromText;
208
- private chunkText;
224
+ private chunkStructured;
225
+ private validateChunkParams;
209
226
  private generateEmbedding;
210
227
  syncDocumentFromFile(filePath: string, documentId: string, options?: {
211
228
  metadata?: Record<string, any>;
212
229
  content?: string;
230
+ excludePattern?: string | string[];
213
231
  entityNames?: string[];
214
232
  chunkParams?: {
215
233
  maxTokens?: number;
@@ -225,10 +243,18 @@ export declare class RAGKnowledgeGraphManager {
225
243
  warning?: string;
226
244
  skipped?: boolean;
227
245
  reason?: string;
246
+ embedding_status?: string;
247
+ reusedChunks: number;
248
+ newlyEmbeddedChunks: number;
249
+ queuedChunks: number;
250
+ deletedChunks: number;
251
+ chunkerTransitioned: boolean;
228
252
  }>;
229
253
  storeDocument(id: string, content: string, metadata?: Record<string, any>): Promise<{
230
254
  id: string;
231
255
  stored: boolean;
256
+ replaced: boolean;
257
+ deletedChunks: number;
232
258
  }>;
233
259
  chunkDocument(documentId: string, options?: {
234
260
  maxTokens?: number;
@@ -252,6 +278,7 @@ export declare class RAGKnowledgeGraphManager {
252
278
  errors?: string[];
253
279
  }>;
254
280
  private hasCJK;
281
+ private buildEntityRangeFinder;
255
282
  private buildEntityMatcher;
256
283
  private autoLinkEntities;
257
284
  extractTerms(documentId: string, options?: {
@@ -378,6 +405,8 @@ export declare class RAGKnowledgeGraphManager {
378
405
  version: number;
379
406
  description: string;
380
407
  }>;
408
+ semanticRollback?: boolean;
409
+ warning?: string;
381
410
  }>;
382
411
  }
383
412
  export {};
package/dist/index.js CHANGED
@@ -26,7 +26,7 @@ import { rebuildProjection, deleteStaleKgChunks } from './src/observations/proje
26
26
  import { addRevision, correctRevision, transitionStatus, linkSources, nextProjectionOrder } from './src/observations/lifecycle.js';
27
27
  import { getObservationHistory } from './src/observations/history.js';
28
28
  // Import chunk text algorithm (extracted for publish-time invariant testing)
29
- import { chunkText as splitTextIntoChunks } from './src/chunkText.js';
29
+ import { chunkStructured as chunkStructuredText, effectiveSignature, isCurrentFormatSignature, LEGACY_SIGNATURE, DEFAULT_MAX_TOKENS } from './src/chunkerC.js';
30
30
  import { migrations } from './src/migrations/migrations.js';
31
31
  // v3.6 lite install: model lifecycle + version-independent cache (A′ boundary)
32
32
  import { EmbeddingGate, GateNotReadyError, GateDisabledError, TerminalConfigError } from './src/embeddingGate.js';
@@ -109,6 +109,36 @@ const TEXT_BUILDER_VERSION = 'tb1';
109
109
  // edge belongs to an adjacent chunk and must be removed so TextDecoder does
110
110
  // not emit U+FFFD. Pass trimHead/trimTail=false to preserve head/tail bytes.
111
111
  // (Implementation moved to src/chunkText.ts for testability.)
112
+ export class SyncCasConflictError extends Error {
113
+ constructor(documentId) { super(`sync CAS conflict on ${documentId}`); this.name = 'SyncCasConflictError'; }
114
+ }
115
+ // Test-only fault hook (v13 setMigrationFaultPoint 선례 — 환경변수 금지: 상시 스위치는
116
+ // 오설정 한 줄로 sync 를 깬다).
117
+ let __syncFaultHook = null;
118
+ export function setSyncFaultPoint(point, fn) {
119
+ __syncFaultHook = point && fn ? (p) => { if (p === point)
120
+ fn(); } : null;
121
+ }
122
+ // Pure vector-reuse decision (spec §5.2 조건 1~5). Returns an OWNED Buffer copy:
123
+ // a Buffer read back from SQLite has no byteOffset-0 guarantee, and inserting
124
+ // `.buffer` of a subarray would write the wrong 4,096 bytes (advisor r5-9).
125
+ export function selectReusableVector(candidates, text, currentProfileId, sha256hex) {
126
+ const h = sha256hex(text);
127
+ for (const r of candidates) {
128
+ if (!r.embedding)
129
+ continue; // 조건 1: 벡터 실존
130
+ if (r.input_hash !== h)
131
+ continue; // 조건 2: input_hash 일치
132
+ if (r.text !== text)
133
+ continue; // 조건 3: exact text 최종판정
134
+ if (r.profile_id !== currentProfileId)
135
+ continue; // 조건 4: 현행 프로필
136
+ if (r.provenance_state !== 'verified' && r.provenance_state !== 'legacy_assumed')
137
+ continue; // 조건 5 (NULL 제외)
138
+ return { vec: Buffer.from(r.embedding), provenance: r.provenance_state }; // owned copy
139
+ }
140
+ return null;
141
+ }
112
142
  function safeRowid(value) {
113
143
  const n = Number(value);
114
144
  if (!Number.isInteger(n) || n < 0) {
@@ -116,6 +146,28 @@ function safeRowid(value) {
116
146
  }
117
147
  return n;
118
148
  }
149
+ // Remove regions the caller does not want indexed. Compiled with `s` because the intended use is
150
+ // spanning a marked block (`<!-- SECRET -->…<!-- /SECRET -->`) and JS has no inline (?s) flag —
151
+ // without it every such pattern would silently match nothing.
152
+ // A malformed pattern throws rather than degrading to "no exclusion": indexing is a disclosure
153
+ // path, so believing you excluded something you did not is worse than a failed sync.
154
+ function applyExcludePatterns(text, pattern) {
155
+ if (pattern === undefined)
156
+ return text;
157
+ const patterns = Array.isArray(pattern) ? pattern : [pattern];
158
+ let out = text;
159
+ for (const p of patterns) {
160
+ let re;
161
+ try {
162
+ re = new RegExp(p, 'gs');
163
+ }
164
+ catch (e) {
165
+ throw new Error(`excludePattern is not a valid regular expression: ${JSON.stringify(p)} (${e.message})`);
166
+ }
167
+ out = out.replace(re, '');
168
+ }
169
+ return out;
170
+ }
119
171
  // Enhanced RAG-enabled Knowledge Graph Manager
120
172
  export class RAGKnowledgeGraphManager {
121
173
  db = null;
@@ -170,6 +222,11 @@ export class RAGKnowledgeGraphManager {
170
222
  this.encoding = get_encoding("cl100k_base");
171
223
  await this.runMigrations();
172
224
  this.currentProfileId = this.ensureCurrentProfile();
225
+ // v14 (spec §7.2): 런타임이 기본 chunker 의 SSOT — 마이그레이션의 리터럴은 동결된
226
+ // 역사이고, 기본값이 진화하면(c2 등) 이 upsert 가 부팅마다 현재값을 기록한다.
227
+ this.db.prepare(`INSERT INTO server_meta (key, value) VALUES ('current_default_chunker', ?)
228
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
229
+ .run(effectiveSignature(DEFAULT_MAX_TOKENS));
173
230
  this.embeddingsMode = opts.skipModel
174
231
  ? 'off'
175
232
  : (process.env.RAG_MEMORY_EMBEDDINGS || 'lazy');
@@ -1843,21 +1900,32 @@ export class RAGKnowledgeGraphManager {
1843
1900
  // CJK), so the function maintains parallel UTF-16 and codepoint cursors and
1844
1901
  // reports codepoint offsets. On a coincidental indexOf miss the char offsets
1845
1902
  // are NULL.
1846
- chunkText(text, maxTokens = 800, overlap = 160) {
1903
+ chunkStructured(text, maxTokens = DEFAULT_MAX_TOKENS) {
1847
1904
  if (!this.encoding)
1848
1905
  throw new Error('Tokenizer not initialized');
1849
- const segments = splitTextIntoChunks(text, this.encoding, maxTokens, overlap);
1850
- return segments.map((seg, idx) => ({
1906
+ return chunkStructuredText(text, this.encoding, maxTokens).map((seg, idx) => ({
1851
1907
  id: '',
1852
1908
  document_id: '',
1853
1909
  chunk_index: idx,
1854
1910
  text: seg.text,
1855
1911
  start_pos: seg.start_pos,
1856
1912
  end_pos: seg.end_pos,
1857
- start_token: seg.start_token,
1858
- end_token: seg.end_token
1913
+ // c1 has no token-space offsets (spec §4.3, r4 D4). Legacy rows keep theirs.
1914
+ start_token: null,
1915
+ end_token: null
1859
1916
  }));
1860
1917
  }
1918
+ // spec §7.1 (r4·r5-8): overlap is rejected on BOTH public paths, BEFORE any
1919
+ // content/dedup judgment — silently accepting it on unchanged content would
1920
+ // void the contract. maxTokens must be a positive integer.
1921
+ validateChunkParams(params) {
1922
+ const { maxTokens = DEFAULT_MAX_TOKENS, overlap = 0 } = params || {};
1923
+ if (!Number.isInteger(maxTokens) || maxTokens <= 0)
1924
+ throw new Error(`chunkParams.maxTokens must be a positive integer (got ${maxTokens})`);
1925
+ if (overlap !== 0)
1926
+ throw new Error(`chunkParams.overlap is no longer supported (chunker c1 has no overlap); omit it or pass 0 (got ${overlap})`);
1927
+ return { maxTokens };
1928
+ }
1861
1929
  // Generate embeddings using sentence transformers
1862
1930
  // isQuery: true for search queries (adds instruction prefix), false for documents/entities
1863
1931
  async generateEmbedding(text, dimensions = 1024, isQuery = false, priority = 'interactive') {
@@ -1882,31 +1950,36 @@ export class RAGKnowledgeGraphManager {
1882
1950
  async syncDocumentFromFile(filePath, documentId, options = {}) {
1883
1951
  if (!this.db)
1884
1952
  throw new Error('Database not initialized');
1885
- // 1. Resolve content: raw file verbatim (default) or explicit override.
1886
- // Content is read on the server and never routed through the model context.
1887
- const content = options.content !== undefined
1888
- ? options.content
1889
- : fsSync.readFileSync(filePath, 'utf-8');
1890
- const bytes = Buffer.byteLength(content, 'utf-8');
1891
- // 2. Metadata: default source=path, updated=today, content_hash; caller can override.
1892
- const today = new Date().toISOString().slice(0, 10);
1893
- const contentHash = createHash('sha256').update(content).digest('hex');
1894
- const metadata = { source: filePath, updated: today, content_hash: contentHash, ...(options.metadata || {}) };
1895
- // 2b. Dedup gate: skip the full delete/store/chunk/embed pipeline when the
1896
- // file is unchanged AND the existing document is fully embedded. The
1897
- // completeness check avoids wrongly skipping a partial/failed prior sync.
1898
- const existingDoc = this.db.prepare(`SELECT metadata FROM documents WHERE id = ?`).get(documentId);
1899
- if (existingDoc) {
1953
+ // spec §7.1 + r5-8: 검증은 content 해석·dedup 판정보다 (첫 실행문).
1954
+ const { maxTokens } = this.validateChunkParams(options.chunkParams);
1955
+ const signature = effectiveSignature(maxTokens);
1956
+ const shaHex = (t) => createHash('sha256').update(t).digest('hex');
1957
+ const zero = { reusedChunks: 0, newlyEmbeddedChunks: 0, queuedChunks: 0, deletedChunks: 0, chunkerTransitioned: false };
1958
+ for (let attempt = 1; attempt <= 3; attempt++) {
1959
+ // r6-3: CAS 재시작 = 처음부터 파일 읽기·hash·metadata 도 attempt 안에서 재계산한다.
1960
+ // Strip excluded regions before anything else looks at the text. Everything downstream —
1961
+ // content_hash, bytes, chunking — then describes what was actually indexed, so changing the
1962
+ // pattern alone still invalidates the dedup gate below. Hashing the raw file instead would
1963
+ // report `unchanged` for a different exclusion, which is the silent-wrong case.
1964
+ const content = applyExcludePatterns(options.content !== undefined ? options.content : fsSync.readFileSync(filePath, 'utf-8'), options.excludePattern);
1965
+ const bytes = Buffer.byteLength(content, 'utf-8');
1966
+ const today = new Date().toISOString().slice(0, 10);
1967
+ const contentHash = shaHex(content);
1968
+ // spec §5.1: content_hash 는 system-owned — user metadata 뒤에 쓴다 (r1: spread 가 덮어쓸 수 있었다).
1969
+ const metadata = { source: filePath, updated: today, ...(options.metadata || {}), content_hash: contentHash };
1970
+ const snap = this.db.prepare(`SELECT content, metadata, chunking_signature FROM documents WHERE id = ?`)
1971
+ .get(documentId);
1900
1972
  let existingHash;
1901
- try {
1902
- existingHash = JSON.parse(existingDoc.metadata)?.content_hash;
1973
+ if (snap) {
1974
+ try {
1975
+ existingHash = JSON.parse(snap.metadata)?.content_hash;
1976
+ }
1977
+ catch { /* hash 없으면 full 경로 */ }
1903
1978
  }
1904
- catch { /* ignore */ }
1905
- if (existingHash === contentHash) {
1979
+ // dedup gate spec §5.1 그대로: "content_hash 동일" 만 (r6-8: content=== 확장 금지.
1980
+ // hash 없거나 낡은 문서는 full 경로로 가서 hash 가 복구된다). signature 무관.
1981
+ if (snap && existingHash === contentHash) {
1906
1982
  const cmCount = this.db.prepare(`SELECT count(*) AS n FROM chunk_metadata WHERE document_id = ?`).get(documentId).n;
1907
- // "Embedded" for dedup completeness = vector exists AND its profile is
1908
- // current (or legacy-NULL awaiting grandfather). Raw vector counts
1909
- // would misjudge old-profile rows as complete (beta 1R supplement).
1910
1983
  const embCount = this.db.prepare(`
1911
1984
  SELECT count(*) AS n FROM chunks c JOIN chunk_metadata m ON c.rowid = m.rowid
1912
1985
  WHERE m.document_id = ? AND (m.provenance_state IS NULL OR m.profile_id = ?)
@@ -1917,114 +1990,158 @@ export class RAGKnowledgeGraphManager {
1917
1990
  `).get(documentId).n;
1918
1991
  if (cmCount > 0 && cmCount === embCount) {
1919
1992
  console.error(`⏭️ syncDocumentFromFile: ${documentId} unchanged (hash match, ${cmCount} chunks embedded) — skipped`);
1920
- return { documentId, bytes, chunks: cmCount, embeddedChunks: embCount, linkedEntities: linked, skipped: true, reason: 'unchanged' };
1993
+ return { documentId, bytes, chunks: cmCount, embeddedChunks: embCount, linkedEntities: linked,
1994
+ skipped: true, reason: 'unchanged', ...zero };
1921
1995
  }
1922
1996
  if (cmCount > 0 && embCount < cmCount) {
1923
- // v3.6 (spec §5b M12): identical content with incomplete/stale vectors
1924
- // keeps the document, chunks, rowids and entity links — only the
1925
- // missing vectors are re-queued via the coordinator. Full re-chunking
1926
- // here would churn rowids and links for no content change.
1997
+ // v3.6 (spec §5b M12): identical content with incomplete/stale vectors keeps the
1998
+ // document, chunks, rowids and entity links — only missing vectors are re-queued.
1927
1999
  console.error(`♻️ syncDocumentFromFile: ${documentId} unchanged but ${cmCount - embCount} vectors missing — re-queued (chunks preserved)`);
1928
2000
  this.coordinator?.kick();
1929
- return { documentId, bytes, chunks: cmCount, embeddedChunks: embCount, linkedEntities: linked, skipped: true, reason: 'unchanged-revectorizing', embedding_status: this.gate.isDisabled ? 'disabled' : 'queued' };
2001
+ return { documentId, bytes, chunks: cmCount, embeddedChunks: embCount, linkedEntities: linked,
2002
+ skipped: true, reason: 'unchanged-revectorizing',
2003
+ embedding_status: this.gate.isDisabled ? 'disabled' : 'queued',
2004
+ ...zero, queuedChunks: cmCount - embCount };
1930
2005
  }
2006
+ // cmCount === 0 이면 아래 full 경로로 계속 (최초 생성).
2007
+ }
2008
+ console.error(`🔄 syncDocumentFromFile: ${documentId} <- ${filePath} (${bytes} bytes)`);
2009
+ const segments = this.chunkStructured(content, maxTokens);
2010
+ // spec §5.2-2: 옛 행을 트랜잭션 밖에서 읽는다 (벡터 재사용 후보).
2011
+ const oldRows = this.db.prepare(`
2012
+ SELECT m.rowid, m.text, m.input_hash, m.profile_id, m.provenance_state, c.embedding
2013
+ FROM chunk_metadata m LEFT JOIN chunks c ON c.rowid = m.rowid
2014
+ WHERE m.document_id = ?`).all(documentId);
2015
+ const oldRowids = oldRows.map(r => r.rowid);
2016
+ const byHash = new Map();
2017
+ for (const r of oldRows) {
2018
+ if (!r.input_hash)
2019
+ continue;
2020
+ const arr = byHash.get(r.input_hash);
2021
+ if (arr)
2022
+ arr.push(r);
2023
+ else
2024
+ byHash.set(r.input_hash, [r]);
2025
+ }
2026
+ // 임베딩/재사용 — 트랜잭션 밖, ready 경로 한정 (N2: not-ready 계약 불변).
2027
+ const lazySync = !this.gate.isReady;
2028
+ const slots = [];
2029
+ let reusedChunks = 0, newlyEmbeddedChunks = 0;
2030
+ if (lazySync) {
2031
+ for (const seg of segments)
2032
+ slots.push({ seg, vec: null, provenance: null });
1931
2033
  }
1932
- }
1933
- console.error(`🔄 syncDocumentFromFile: ${documentId} <- ${filePath} (${bytes} bytes)`);
1934
- // 3. Two contracts (spec §5b):
1935
- // ready — pre-compute ALL embeddings BEFORE any DB mutation; if
1936
- // inference throws mid-way the old document stays intact
1937
- // (v3.5.0 atomicity, unchanged).
1938
- // not-ready — intentional lazy sync: store document + chunks + FTS in
1939
- // one transaction with NO vectors (embedding_status:
1940
- // queued); the backfill coordinator recovers them.
1941
- const { maxTokens = 800, overlap = 160 } = options.chunkParams || {};
1942
- const segments = this.chunkText(content, maxTokens, overlap);
1943
- const lazySync = !this.gate.isReady;
1944
- const embedded = [];
1945
- if (lazySync) {
1946
- for (const seg of segments)
1947
- embedded.push({ seg, embedding: null });
1948
- }
1949
- else {
1950
- for (const seg of segments) {
1951
- const embedding = await this.generateEmbedding(seg.text, 1024, false, 'bulk');
1952
- embedded.push({ seg, embedding });
1953
- }
1954
- }
1955
- // 4. Atomic swap: delete old -> insert doc -> insert chunks (+ embeddings
1956
- // with verified provenance when ready), one synchronous transaction.
1957
- const applyTx = this.db.transaction(() => {
1958
- const db = this.db;
1959
- // 4a. cleanup old doc (inlined sync version of cleanupDocument).
1960
- const existing = db.prepare(`SELECT rowid FROM chunk_metadata WHERE document_id = ?`).all(documentId);
1961
- for (const ch of existing) {
1962
- db.prepare(`DELETE FROM chunk_entities WHERE chunk_rowid = ?`).run(ch.rowid);
1963
- db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(ch.rowid)}`);
1964
- }
1965
- db.prepare(`DELETE FROM chunk_metadata WHERE document_id = ?`).run(documentId);
1966
- db.prepare(`DELETE FROM documents WHERE id = ?`).run(documentId);
1967
- // 4b. insert document.
1968
- db.prepare(`INSERT INTO documents (id, content, metadata) VALUES (?, ?, ?)`)
1969
- .run(documentId, content, JSON.stringify(metadata));
1970
- // 4c. insert chunk_metadata (FTS5 chunks_fts auto-filled by trigger);
1971
- // vectors + provenance only on the ready path (§6a-2).
1972
- for (const { seg, embedding } of embedded) {
1973
- const chunkId = `${documentId}_chunk_${seg.chunk_index}`;
1974
- const info = db.prepare(`
1975
- INSERT INTO chunk_metadata (chunk_id, document_id, chunk_index, text, start_pos, end_pos, start_token, end_token)
1976
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1977
- `).run(chunkId, documentId, seg.chunk_index, seg.text, seg.start_pos, seg.end_pos, seg.start_token, seg.end_token);
1978
- const rowid = Number(info.lastInsertRowid);
1979
- if (embedding) {
1980
- db.prepare(`INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)`).run(Buffer.from(embedding.buffer));
1981
- db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'verified' WHERE rowid = ?`)
1982
- .run(createHash('sha256').update(seg.text).digest('hex'), this.currentProfileId, rowid);
2034
+ else {
2035
+ for (const seg of segments) {
2036
+ const hit = selectReusableVector(byHash.get(shaHex(seg.text)) ?? [], seg.text, this.currentProfileId, shaHex);
2037
+ if (hit) {
2038
+ slots.push({ seg, vec: hit.vec, provenance: hit.provenance });
2039
+ reusedChunks++;
2040
+ }
2041
+ else {
2042
+ const embedding = await this.generateEmbedding(seg.text, 1024, false, 'bulk');
2043
+ slots.push({ seg, vec: Buffer.from(embedding.buffer), provenance: 'verified' });
2044
+ newlyEmbeddedChunks++;
2045
+ }
1983
2046
  }
1984
2047
  }
1985
- });
1986
- applyTx();
1987
- this.coordinator?.invalidateCoverage();
1988
- if (lazySync)
1989
- this.coordinator?.kick();
1990
- const embeddedChunks = lazySync ? 0 : embedded.length;
1991
- // 5. Entity linking AFTER commit. Non-destructive + idempotent (INSERT OR
1992
- // IGNORE), so a linking failure cannot corrupt the doc/embeddings.
1993
- const linkedEntities = await this.autoLinkEntities(documentId);
1994
- let explicitlyLinked;
1995
- if (options.entityNames && options.entityNames.length > 0) {
1996
- const linkResult = await this.linkEntitiesToDocument(documentId, options.entityNames);
1997
- explicitlyLinked = linkResult.linkedEntities;
1998
- }
1999
- // 6. Terse summary only (no chunk text / content echo) to keep caller context flat.
2000
- const result = {
2001
- documentId,
2002
- bytes,
2003
- chunks: segments.length,
2004
- embeddedChunks,
2005
- linkedEntities,
2006
- embedding_status: lazySync ? (this.gate.isDisabled ? 'disabled' : 'queued') : 'embedded',
2007
- ...(explicitlyLinked !== undefined ? { explicitlyLinked } : {}),
2008
- };
2009
- if (linkedEntities === 0 && explicitlyLinked === undefined) {
2010
- result.warning = 'linkedEntities=0: ensure the file content contains entity-name literals (e.g. a wiki anchor line "RAG entity: ...") so term-matching can link entities.';
2048
+ __syncFaultHook?.('pre-transaction');
2049
+ // 한 트랜잭션: CAS 첫 문장 -> full delete/insert -> failure 정리 (spec §5.2-4·5).
2050
+ const applyTx = this.db.transaction(() => {
2051
+ const db = this.db;
2052
+ const now = db.prepare(`SELECT content, metadata, chunking_signature FROM documents WHERE id = ?`)
2053
+ .get(documentId);
2054
+ const same = (snap === undefined && now === undefined) ||
2055
+ (snap !== undefined && now !== undefined && now.content === snap.content &&
2056
+ now.metadata === snap.metadata && now.chunking_signature === snap.chunking_signature);
2057
+ if (!same)
2058
+ throw new SyncCasConflictError(documentId);
2059
+ const existing = db.prepare(`SELECT rowid FROM chunk_metadata WHERE document_id = ?`).all(documentId);
2060
+ for (const ch of existing) {
2061
+ db.prepare(`DELETE FROM chunk_entities WHERE chunk_rowid = ?`).run(ch.rowid);
2062
+ db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(ch.rowid)}`);
2063
+ }
2064
+ db.prepare(`DELETE FROM chunk_metadata WHERE document_id = ?`).run(documentId);
2065
+ db.prepare(`DELETE FROM documents WHERE id = ?`).run(documentId);
2066
+ db.prepare(`INSERT INTO documents (id, content, metadata, chunking_signature) VALUES (?, ?, ?, ?)`)
2067
+ .run(documentId, content, JSON.stringify(metadata), signature);
2068
+ for (const { seg, vec, provenance } of slots) {
2069
+ const chunkId = `${documentId}_chunk_${seg.chunk_index}`;
2070
+ const info = db.prepare(`
2071
+ INSERT INTO chunk_metadata (chunk_id, document_id, chunk_index, text, start_pos, end_pos, start_token, end_token)
2072
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2073
+ `).run(chunkId, documentId, seg.chunk_index, seg.text, seg.start_pos, seg.end_pos, seg.start_token, seg.end_token);
2074
+ const rowid = Number(info.lastInsertRowid);
2075
+ if (vec) {
2076
+ db.prepare(`INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)`).run(vec);
2077
+ db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = ? WHERE rowid = ?`)
2078
+ .run(shaHex(seg.text), this.currentProfileId, provenance, rowid);
2079
+ }
2080
+ }
2081
+ if (oldRowids.length > 0) {
2082
+ // r5-9: 키는 (kind, target_id) — kind 조건 없이 지우면 같은 숫자 ID 의 entity failure 까지 지운다.
2083
+ const ph = oldRowids.map(() => '?').join(',');
2084
+ db.prepare(`DELETE FROM embedding_backfill_failures WHERE kind = 'chunk' AND target_id IN (${ph})`)
2085
+ .run(...oldRowids.map(String));
2086
+ }
2087
+ });
2088
+ try {
2089
+ applyTx();
2090
+ }
2091
+ catch (e) {
2092
+ if (e instanceof SyncCasConflictError) {
2093
+ console.error(`↻ sync CAS conflict on ${documentId} (attempt ${attempt}/3) — restarting from file read`);
2094
+ if (attempt === 3)
2095
+ throw e;
2096
+ continue;
2097
+ }
2098
+ throw e;
2099
+ }
2100
+ this.coordinator?.invalidateCoverage();
2101
+ if (lazySync)
2102
+ this.coordinator?.kick();
2103
+ // Entity linking AFTER commit. Non-destructive + idempotent (INSERT OR IGNORE).
2104
+ const linkedEntities = await this.autoLinkEntities(documentId);
2105
+ let explicitlyLinked;
2106
+ if (options.entityNames && options.entityNames.length > 0) {
2107
+ const linkResult = await this.linkEntitiesToDocument(documentId, options.entityNames);
2108
+ explicitlyLinked = linkResult.linkedEntities;
2109
+ }
2110
+ const result = {
2111
+ documentId, bytes, chunks: segments.length,
2112
+ embeddedChunks: reusedChunks + newlyEmbeddedChunks, // spec §5.3
2113
+ linkedEntities,
2114
+ embedding_status: lazySync ? (this.gate.isDisabled ? 'disabled' : 'queued') : 'embedded',
2115
+ reusedChunks, newlyEmbeddedChunks,
2116
+ queuedChunks: lazySync ? segments.length : 0,
2117
+ deletedChunks: oldRowids.length,
2118
+ chunkerTransitioned: snap !== undefined && snap.chunking_signature !== signature,
2119
+ ...(explicitlyLinked !== undefined ? { explicitlyLinked } : {}),
2120
+ };
2121
+ if (linkedEntities === 0 && explicitlyLinked === undefined) {
2122
+ result.warning = 'linkedEntities=0: ensure the file content contains entity-name literals (e.g. a wiki anchor line "RAG entity: ...") so term-matching can link entities.';
2123
+ }
2124
+ console.error(`✅ syncDocumentFromFile done: ${documentId} (${result.chunks} chunks, reused ${reusedChunks}, embedded ${newlyEmbeddedChunks})`);
2125
+ return result;
2011
2126
  }
2012
- console.error(`✅ syncDocumentFromFile done: ${documentId} (${result.chunks} chunks, ${result.embeddedChunks} embedded, ${linkedEntities} linked)`);
2013
- return result;
2127
+ throw new Error('unreachable');
2014
2128
  }
2015
2129
  async storeDocument(id, content, metadata = {}) {
2016
2130
  if (!this.db)
2017
2131
  throw new Error('Database not initialized');
2018
2132
  console.error(`📄 Storing document: ${id}`);
2133
+ // Decide `replaced` from the document row, not from the chunk count: a document stored but
2134
+ // never chunked still gets overwritten here, and reporting that as a fresh write would be a lie.
2135
+ const existed = this.db.prepare(`SELECT 1 FROM documents WHERE id = ?`).get(id) !== undefined;
2019
2136
  // Clean up existing document
2020
- await this.cleanupDocument(id);
2137
+ const cleaned = await this.cleanupDocument(id);
2021
2138
  // Store document
2022
2139
  this.db.prepare(`
2023
2140
  INSERT OR REPLACE INTO documents (id, content, metadata)
2024
2141
  VALUES (?, ?, ?)
2025
2142
  `).run(id, content, JSON.stringify(metadata));
2026
2143
  console.error(`✅ Document stored: ${id}`);
2027
- return { id, stored: true };
2144
+ return { id, stored: true, replaced: existed, deletedChunks: cleaned.deletedChunks };
2028
2145
  }
2029
2146
  async chunkDocument(documentId, options = {}) {
2030
2147
  if (!this.db)
@@ -2036,12 +2153,12 @@ export class RAGKnowledgeGraphManager {
2036
2153
  if (!document) {
2037
2154
  throw new Error(`Document with ID ${documentId} not found`);
2038
2155
  }
2039
- const { maxTokens = 800, overlap = 160 } = options;
2040
- console.error(`🔪 Chunking document: ${documentId} (maxTokens: ${maxTokens}, overlap: ${overlap})`);
2156
+ const { maxTokens } = this.validateChunkParams(options);
2157
+ console.error(`🔪 Chunking document: ${documentId} (maxTokens: ${maxTokens}, chunker: c1)`);
2041
2158
  // Clean up existing chunks
2042
2159
  await this.cleanupDocument(documentId);
2043
2160
  // Create chunks
2044
- const chunks = this.chunkText(document.content, maxTokens, overlap);
2161
+ const chunks = this.chunkStructured(document.content, maxTokens);
2045
2162
  const resultChunks = [];
2046
2163
  for (const chunk of chunks) {
2047
2164
  const chunkId = `${documentId}_chunk_${chunk.chunk_index}`;
@@ -2061,6 +2178,9 @@ export class RAGKnowledgeGraphManager {
2061
2178
  });
2062
2179
  }
2063
2180
  console.error(`✅ Document chunked: ${chunks.length} chunks created`);
2181
+ // spec §7.1: 두 번째 chunk 생성 경로 — 스탬프를 안 박으면 §5.1 관측이 조용히 샌다.
2182
+ this.db.prepare(`UPDATE documents SET chunking_signature = ? WHERE id = ?`)
2183
+ .run(effectiveSignature(maxTokens), documentId);
2064
2184
  // Indirect missing-row producer (spec §5): freshly chunked rows have no
2065
2185
  // vectors yet — let the coordinator recover them without a restart.
2066
2186
  this.coordinator?.invalidateCoverage();
@@ -2113,6 +2233,73 @@ export class RAGKnowledgeGraphManager {
2113
2233
  hasCJK(text) {
2114
2234
  return /[\u3000-\u9fff\uac00-\ud7af\uff00-\uffef]/.test(text);
2115
2235
  }
2236
+ // spec §5.4 (r7-2·r8-1·r9): primary name 의 본문 occurrence range [sCp, eCp).
2237
+ // 의미 = buildEntityMatcher 와 동일 (CJK substring / Latin word-boundary) — 여기서
2238
+ // 어긋나면 'Data' 가 'Database' 에 새로 링크되는 식으로 의미가 확장된다.
2239
+ buildEntityRangeFinder(content) {
2240
+ // 원문 UTF-16 -> codepoint 표. Latin 경로는 folded 가 아니라 **원문**에 regex 를 건다
2241
+ // (r9-1: folded 에 걸면 fooİ -> fooi̇ 로 접힌 뒤 매치돼 현행 matcher 의미가 확장된다).
2242
+ const origU16ToCp = [];
2243
+ let origTotalCp = 0;
2244
+ for (let u = 0; u < content.length;) {
2245
+ const c = content.codePointAt(u);
2246
+ origU16ToCp.push(origTotalCp);
2247
+ if (c > 0xffff) {
2248
+ origU16ToCp.push(origTotalCp);
2249
+ u += 2;
2250
+ }
2251
+ else
2252
+ u += 1;
2253
+ origTotalCp++;
2254
+ }
2255
+ const origCpAt = (u16) => (u16 < origU16ToCp.length ? origU16ToCp[u16] : origTotalCp);
2256
+ // folded 표 (CJK substring / fallback 경로 전용). unit -> 유래한 원문 cp.
2257
+ let folded = '';
2258
+ const u16ToCp = [];
2259
+ let cp = 0;
2260
+ for (const ch of content) { // for..of = codepoint 순회
2261
+ const f = ch.toLowerCase(); // 다단위 fold 가능 (İ -> 'i̇')
2262
+ for (let i = 0; i < f.length; i++)
2263
+ u16ToCp.push(cp);
2264
+ folded += f;
2265
+ cp++;
2266
+ }
2267
+ // r9-1: exclusive end = "마지막으로 소비한 unit 의 원문 cp + 1".
2268
+ // 경계 unit 을 읽으면 매치가 fold 전개 중간에서 끝날 때 1 모자란다 (漢İ/漢i 실측 [0,1)).
2269
+ const endCp = (u16) => (u16 === 0 ? 0 : u16ToCp[Math.min(u16, u16ToCp.length) - 1] + 1);
2270
+ return (name, isCjk) => {
2271
+ const out = [];
2272
+ const lower = name.toLowerCase();
2273
+ const pushAllSubstr = () => {
2274
+ let from = 0;
2275
+ while (true) {
2276
+ const u = folded.indexOf(lower, from);
2277
+ if (u < 0)
2278
+ break;
2279
+ out.push({ s: u16ToCp[u], e: endCp(u + lower.length) });
2280
+ from = u + 1; // r9-2: 중첩 occurrence 보존
2281
+ }
2282
+ };
2283
+ if (isCjk) {
2284
+ pushAllSubstr();
2285
+ return out;
2286
+ }
2287
+ try {
2288
+ const escaped = lower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2289
+ const re = new RegExp(`\\b${escaped}\\b`, 'gi'); // buildEntityMatcher 와 동일 규칙,
2290
+ let m; // 단 원문에 실행 (의미 확장 방지)
2291
+ while ((m = re.exec(content)) !== null) {
2292
+ out.push({ s: origCpAt(m.index), e: origCpAt(m.index + m[0].length) });
2293
+ if (re.lastIndex === m.index)
2294
+ re.lastIndex++;
2295
+ }
2296
+ }
2297
+ catch {
2298
+ pushAllSubstr();
2299
+ } // matcher 의 fallback 과 동일
2300
+ return out;
2301
+ };
2302
+ }
2116
2303
  // Build a match pattern for an entity name — word-boundary for Latin, substring for CJK
2117
2304
  buildEntityMatcher(name) {
2118
2305
  const lower = name.toLowerCase();
@@ -2136,9 +2323,12 @@ export class RAGKnowledgeGraphManager {
2136
2323
  return 0;
2137
2324
  try {
2138
2325
  // Get all chunk text for this document
2139
- const chunks = this.db.prepare(`SELECT rowid, text FROM chunk_metadata WHERE document_id = ?`).all(documentId);
2326
+ const chunks = this.db.prepare(`SELECT rowid, text, start_pos, end_pos FROM chunk_metadata WHERE document_id = ?`).all(documentId);
2140
2327
  if (chunks.length === 0)
2141
2328
  return 0;
2329
+ // spec §5.4: range 링킹용 — 문서 본문과 finder 를 1회 준비
2330
+ const docRow = this.db.prepare(`SELECT content FROM documents WHERE id = ?`).get(documentId);
2331
+ const findRanges = docRow ? this.buildEntityRangeFinder(docRow.content) : null;
2142
2332
  // Get all entities with observations for richer matching
2143
2333
  const entities = this.db.prepare(`SELECT id, name, entityType, observations FROM entities`).all();
2144
2334
  // Minimum name length: 2 for CJK (e.g. "할랄"), 4 for Latin (avoid "API", "Bug")
@@ -2184,6 +2374,19 @@ export class RAGKnowledgeGraphManager {
2184
2374
  entityLinked = true;
2185
2375
  }
2186
2376
  }
2377
+ // spec §5.4 (r7-2): chunk 단위 매칭은 경계에 잘린 이름을 영원히 놓친다 — c1 은
2378
+ // overlap 이 없어 흡수도 안 된다. primary name 의 본문 occurrence range 와
2379
+ // 교차하는 chunk 에 링크한다 (aliases 는 predicate 라 chunk 단위 유지).
2380
+ if (findRanges) {
2381
+ for (const { s, e } of findRanges(entity.name, this.hasCJK(entity.name))) {
2382
+ for (const chunk of chunks) {
2383
+ if (chunk.start_pos !== null && chunk.end_pos !== null && chunk.start_pos < e && chunk.end_pos > s) {
2384
+ insertStmt.run(chunk.rowid, entity.id); // INSERT OR IGNORE — 중복 무해
2385
+ entityLinked = true;
2386
+ }
2387
+ }
2388
+ }
2389
+ }
2187
2390
  if (entityLinked)
2188
2391
  linkedCount++;
2189
2392
  }
@@ -2256,9 +2459,13 @@ export class RAGKnowledgeGraphManager {
2256
2459
  console.error(`✅ Entities linked: ${linkedCount} entities linked to document`);
2257
2460
  return { documentId, linkedEntities: linkedCount };
2258
2461
  }
2462
+ // Report what was destroyed. The counts were already computed here and thrown away, so a caller
2463
+ // that replaces a document could not tell from the return value that anything was deleted
2464
+ // (2026-08-05 field report from a deployed project: "{stored:true} came back and I did not know
2465
+ // what I had just wiped"). Silent destruction is the defect; the numbers are free.
2259
2466
  async cleanupDocument(documentId) {
2260
2467
  if (!this.db)
2261
- return;
2468
+ return { deletedChunks: 0, deletedAssociations: 0, deletedVectors: 0 };
2262
2469
  console.error(`🧹 Cleaning up document: ${documentId}`);
2263
2470
  // Get existing chunks
2264
2471
  const existingChunks = this.db.prepare(`
@@ -2286,6 +2493,7 @@ export class RAGKnowledgeGraphManager {
2286
2493
  console.error(` ├─ Deleted ${deletedVectors} vector embeddings`);
2287
2494
  console.error(` └─ Deleted ${metadata.changes} chunk metadata records`);
2288
2495
  }
2496
+ return { deletedChunks: existingChunks.length, deletedAssociations, deletedVectors };
2289
2497
  }
2290
2498
  async deleteDocument(documentId) {
2291
2499
  if (!this.db)
@@ -3031,8 +3239,12 @@ export class RAGKnowledgeGraphManager {
3031
3239
  graphBoost += Math.min(entityBoost, 0.4);
3032
3240
  }
3033
3241
  // Generate semantic summary (skip when degraded — no embeddings available).
3242
+ // RAG_MEMORY_SEARCH_SUMMARIES=off: diagnostic escape hatch (v5) — the summary
3243
+ // path embeds EVERY sentence of EVERY candidate (~100+ inferences per search,
3244
+ // measured 90-120s cold). Off = preview slices + relevanceScore 0; ranking
3245
+ // then rests on vectorSimilarity + boosts. Default unchanged.
3034
3246
  let summary, keyHighlight, relevanceScore;
3035
- if (vectorDegraded || !primaryQueryEmbedding) {
3247
+ if (vectorDegraded || !primaryQueryEmbedding || process.env.RAG_MEMORY_SEARCH_SUMMARIES === 'off') {
3036
3248
  keyHighlight = result.text.slice(0, 150);
3037
3249
  summary = result.text.slice(0, 300);
3038
3250
  relevanceScore = 0;
@@ -3210,6 +3422,20 @@ export class RAGKnowledgeGraphManager {
3210
3422
  // reads version, model/reconciliation state, and provenance coverage here.
3211
3423
  const gs = this.gate.status;
3212
3424
  const cov = this.coordinator?.coverage();
3425
+ // v14 (spec §7.2): document 기준 chunking 전환 상태 — 상호배타, 합 = documents.
3426
+ // regex 분류는 SQL 밖(JS)에서: current = 런타임이 인식하는 c1 형식(강한 파서),
3427
+ // legacy = 'legacy-unknown', unknown = 그 외 전부.
3428
+ const sigRows = this.db.prepare(`SELECT chunking_signature AS s, count(*) AS n FROM documents GROUP BY chunking_signature`)
3429
+ .all();
3430
+ let sigCur = 0, sigLeg = 0, sigUnk = 0;
3431
+ for (const r of sigRows) {
3432
+ if (r.s === LEGACY_SIGNATURE)
3433
+ sigLeg += r.n;
3434
+ else if (isCurrentFormatSignature(r.s))
3435
+ sigCur += r.n;
3436
+ else
3437
+ sigUnk += r.n;
3438
+ }
3213
3439
  return {
3214
3440
  entities: {
3215
3441
  total: entityStats.reduce((sum, stat) => sum + stat.count, 0),
@@ -3221,6 +3447,8 @@ export class RAGKnowledgeGraphManager {
3221
3447
  },
3222
3448
  documents: documentCount.count,
3223
3449
  chunks: chunkCount.count,
3450
+ chunking: { current: sigCur, legacy: sigLeg, unknown: sigUnk,
3451
+ default_signature: effectiveSignature(DEFAULT_MAX_TOKENS) },
3224
3452
  server: {
3225
3453
  version: PKG_VERSION,
3226
3454
  node: process.versions.node,
@@ -3517,7 +3745,7 @@ export class RAGKnowledgeGraphManager {
3517
3745
  .filter(m => m.version > targetVersion && m.version <= currentVersion)
3518
3746
  .sort((a, b) => b.version - a.version);
3519
3747
  migrationManager.rollback(targetVersion);
3520
- return {
3748
+ const result = {
3521
3749
  rolledBack: migrationsToRollback.length,
3522
3750
  currentVersion: migrationManager.getCurrentVersion(),
3523
3751
  rolledBackMigrations: migrationsToRollback.map(m => ({
@@ -3525,6 +3753,18 @@ export class RAGKnowledgeGraphManager {
3525
3753
  description: m.description
3526
3754
  }))
3527
3755
  };
3756
+ // v14 rollback is a compatibility rollback ONLY (spec §6.3): dropping the
3757
+ // chunking_signature column does not restore old chunk boundaries — c1 rows
3758
+ // read fine on v13 code. Say so in the RESPONSE, not just the tool
3759
+ // description, so a caller who rolled back sees the limit (advisor r5-10).
3760
+ if (result.rolledBackMigrations.some(m => m.version === 14)) {
3761
+ return {
3762
+ ...result,
3763
+ semanticRollback: false,
3764
+ warning: 'v14 rollback removes the chunking_signature column only; chunk boundaries produced by chunker c1 are NOT restored (compatibility rollback). Data restore path = pre-migration backup snapshot.'
3765
+ };
3766
+ }
3767
+ return result;
3528
3768
  }
3529
3769
  }
3530
3770
  // Initialize the manager
@@ -3633,6 +3873,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3633
3873
  return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.syncDocumentFromFile(validatedArgs.path, validatedArgs.documentId, {
3634
3874
  metadata: validatedArgs.metadata,
3635
3875
  content: validatedArgs.content,
3876
+ excludePattern: validatedArgs.excludePattern,
3636
3877
  entityNames: validatedArgs.entityNames,
3637
3878
  chunkParams: validatedArgs.chunkParams,
3638
3879
  }), null, 2) }] };
@@ -0,0 +1,18 @@
1
+ import type { Tiktoken } from 'tiktoken';
2
+ export interface CSegment {
3
+ text: string;
4
+ start_pos: number;
5
+ end_pos: number;
6
+ }
7
+ export declare const DEFAULT_MAX_TOKENS = 800;
8
+ export declare const LEGACY_SIGNATURE = "legacy-unknown";
9
+ export declare function mergeMinTokens(maxTokens: number): number;
10
+ export declare function effectiveSignature(maxTokens: number): string;
11
+ export declare function isCurrentFormatSignature(sig: string): boolean;
12
+ type Block = {
13
+ lines: string[];
14
+ heading: boolean;
15
+ };
16
+ export declare function buildBlocks(lines: string[]): Block[];
17
+ export declare function chunkStructured(text: string, enc: Tiktoken, maxTokens?: number): CSegment[];
18
+ export {};
@@ -0,0 +1,210 @@
1
+ export const DEFAULT_MAX_TOKENS = 800;
2
+ export const LEGACY_SIGNATURE = 'legacy-unknown';
3
+ // r11: heading is a preferred cut, not a mandatory one. A cut happens only when
4
+ // the accumulated section group AND the next section are both >= this floor —
5
+ // short adjacent sections merge, so cross-section queries still land in one
6
+ // chunk (gate regression k05) and short log sections stop forming over-sharp
7
+ // competitor chunks (k09). Derived, not independent: one knob (maxTokens).
8
+ export function mergeMinTokens(maxTokens) {
9
+ return Math.floor(maxTokens / 2); // NOT >>1 — bitshift wraps at 2^31 (r12)
10
+ }
11
+ export function effectiveSignature(maxTokens) {
12
+ return `c1:enc=cl100k_base:max=${maxTokens}:overlap=0:fence=on:merge=${mergeMinTokens(maxTokens)}:fallback=cp-exact-${maxTokens}`;
13
+ }
14
+ // Strict parse (r5-15): the regex alone classified impossible signatures
15
+ // (max=0, max/fallback mismatch) as current. Cross-check the components.
16
+ export function isCurrentFormatSignature(sig) {
17
+ const m = /^c1:enc=cl100k_base:max=(\d+):overlap=0:fence=on:merge=(\d+):fallback=cp-exact-(\d+)$/.exec(sig);
18
+ if (!m)
19
+ return false;
20
+ const max = Number(m[1]);
21
+ return Number.isInteger(max) && max > 0 && m[1] === m[3] && Number(m[2]) === mergeMinTokens(max);
22
+ }
23
+ function splitLines(text) {
24
+ const lines = [];
25
+ let start = 0;
26
+ for (let i = 0; i < text.length; i++) {
27
+ if (text[i] === '\n') {
28
+ lines.push(text.slice(start, i + 1));
29
+ start = i + 1;
30
+ }
31
+ }
32
+ if (start < text.length)
33
+ lines.push(text.slice(start));
34
+ return lines;
35
+ }
36
+ const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/;
37
+ const HEADING_RE = /^#{1,4}[ \t]/;
38
+ const BULLET_RE = /^(\s*)([-*+]|\d+[.)])[ \t]/;
39
+ const BLANK_RE = /^\s*$/;
40
+ export function buildBlocks(lines) {
41
+ const blocks = [];
42
+ let i = 0;
43
+ const attachTrailingBlanks = (blk) => {
44
+ while (i < lines.length && BLANK_RE.test(lines[i]))
45
+ blk.lines.push(lines[i++]);
46
+ };
47
+ while (i < lines.length) {
48
+ const line = lines[i];
49
+ const fm = line.match(FENCE_RE);
50
+ if (fm) {
51
+ const marker = fm[1][0];
52
+ const openLen = fm[1].length;
53
+ const blk = { lines: [lines[i++]], heading: false };
54
+ const closeRe = new RegExp(`^\\s{0,3}\\${marker}{${openLen},}\\s*$`);
55
+ while (i < lines.length) {
56
+ const l = lines[i];
57
+ blk.lines.push(l);
58
+ i++;
59
+ if (closeRe.test(l.replace(/\r?\n$/, '')))
60
+ break; // unclosed -> EOF
61
+ }
62
+ attachTrailingBlanks(blk);
63
+ blocks.push(blk);
64
+ }
65
+ else if (HEADING_RE.test(line)) {
66
+ const blk = { lines: [lines[i++]], heading: true };
67
+ attachTrailingBlanks(blk);
68
+ blocks.push(blk);
69
+ }
70
+ else if (BULLET_RE.test(line)) {
71
+ const indent = line.match(BULLET_RE)[1].length;
72
+ const blk = { lines: [lines[i++]], heading: false };
73
+ while (i < lines.length) {
74
+ const l = lines[i];
75
+ if (BLANK_RE.test(l) || HEADING_RE.test(l) || FENCE_RE.test(l))
76
+ break;
77
+ const bm = l.match(BULLET_RE);
78
+ if (bm && bm[1].length <= indent)
79
+ break; // sibling/outer bullet
80
+ const li = l.match(/^(\s*)/)[1].length;
81
+ if (!bm && li <= indent)
82
+ break; // dedented prose
83
+ blk.lines.push(l);
84
+ i++;
85
+ }
86
+ attachTrailingBlanks(blk);
87
+ blocks.push(blk);
88
+ }
89
+ else if (BLANK_RE.test(line)) {
90
+ const blk = { lines: [], heading: false };
91
+ while (i < lines.length && BLANK_RE.test(lines[i]))
92
+ blk.lines.push(lines[i++]);
93
+ blocks.push(blk);
94
+ }
95
+ else {
96
+ const blk = { lines: [lines[i++]], heading: false };
97
+ while (i < lines.length && !BLANK_RE.test(lines[i]) && !HEADING_RE.test(lines[i])
98
+ && !FENCE_RE.test(lines[i]) && !BULLET_RE.test(lines[i])) {
99
+ blk.lines.push(lines[i++]);
100
+ }
101
+ attachTrailingBlanks(blk);
102
+ blocks.push(blk);
103
+ }
104
+ }
105
+ return blocks;
106
+ }
107
+ // cp-exact oversize split, non-monotonicity-safe (spec §4.3-6, r5-5).
108
+ // BPE prefix token counts are NOT monotonic ('/sdkX' -> 1,1,2,1,2), so binary
109
+ // search is invalid. Scan prefixes linearly, remember the last fitting one, and
110
+ // keep probing LOOKAHEAD candidates past a miss to recover dips. If not even
111
+ // one codepoint fits, throw — never emit an over-budget chunk.
112
+ const LOOKAHEAD = 16;
113
+ function splitOversize(block, enc, maxTokens) {
114
+ const cps = [...block];
115
+ const out = [];
116
+ let start = 0;
117
+ while (start < cps.length) {
118
+ let lastFit = 0;
119
+ let missesSinceFit = 0;
120
+ for (let probe = start + 1; probe <= cps.length && missesSinceFit < LOOKAHEAD; probe++) {
121
+ const t = enc.encode(cps.slice(start, probe).join('')).length;
122
+ if (t <= maxTokens) {
123
+ lastFit = probe - start;
124
+ missesSinceFit = 0;
125
+ }
126
+ else
127
+ missesSinceFit++;
128
+ }
129
+ if (lastFit === 0) {
130
+ throw new Error(`chunker c1: codepoint at offset ${start} exceeds maxTokens=${maxTokens} on its own — cannot honor the token budget`);
131
+ }
132
+ out.push(cps.slice(start, start + lastFit).join(''));
133
+ start += lastFit;
134
+ }
135
+ return out;
136
+ }
137
+ export function chunkStructured(text, enc, maxTokens = DEFAULT_MAX_TOKENS) {
138
+ if (text.length === 0)
139
+ return [];
140
+ const blocks = buildBlocks(splitLines(text));
141
+ // r11 section pass: a section = one heading block through the next heading
142
+ // (preamble = blocks before the first heading). Cut decisions are precomputed
143
+ // per section so they depend only on section sizes, not packing state.
144
+ const minSection = mergeMinTokens(maxTokens);
145
+ const sectionOfBlock = new Array(blocks.length);
146
+ const sectionTexts = [];
147
+ let sec = -1;
148
+ blocks.forEach((b, bi) => {
149
+ if (b.heading || sec === -1) {
150
+ sec++;
151
+ sectionTexts[sec] = '';
152
+ }
153
+ sectionOfBlock[bi] = sec;
154
+ sectionTexts[sec] += b.lines.join('');
155
+ });
156
+ const secTokens = sectionTexts.map(t => enc.encode(t).length);
157
+ // Cut at section s iff the group accumulated since the last cut and section s
158
+ // are BOTH >= minSection. Group size = sum of section token counts (token
159
+ // counts are not additive across joins; the sum is a deterministic threshold
160
+ // proxy, never used as a budget).
161
+ const cutAtSection = new Array(secTokens.length).fill(false);
162
+ {
163
+ let groupTokens = 0;
164
+ for (let s = 0; s < secTokens.length; s++) {
165
+ if (s > 0 && groupTokens >= minSection && secTokens[s] >= minSection) {
166
+ cutAtSection[s] = true;
167
+ groupTokens = 0;
168
+ }
169
+ groupTokens += secTokens[s];
170
+ }
171
+ }
172
+ const pieces = [];
173
+ let acc = '';
174
+ const flush = () => { if (acc.length > 0) {
175
+ pieces.push(acc);
176
+ acc = '';
177
+ } };
178
+ for (let bi = 0; bi < blocks.length; bi++) {
179
+ const b = blocks[bi];
180
+ const btext = b.lines.join('');
181
+ if (btext.length === 0)
182
+ continue;
183
+ if (b.heading) {
184
+ const s = sectionOfBlock[bi];
185
+ // Preferred cut: honor the precomputed section cut. Merged headings still
186
+ // flush when their whole section cannot join the open chunk — splitting
187
+ // at the heading beats orphaning the heading line at a budget flush.
188
+ if (cutAtSection[s])
189
+ flush();
190
+ else if (acc.length > 0 && enc.encode(acc + sectionTexts[s]).length > maxTokens)
191
+ flush();
192
+ }
193
+ if (acc.length > 0 && enc.encode(acc + btext).length > maxTokens)
194
+ flush();
195
+ if (acc.length === 0 && enc.encode(btext).length > maxTokens) {
196
+ pieces.push(...splitOversize(btext, enc, maxTokens)); // mega-block fallback
197
+ continue;
198
+ }
199
+ acc += btext;
200
+ }
201
+ flush();
202
+ const segments = [];
203
+ let cursor = 0;
204
+ for (const p of pieces) {
205
+ const len = [...p].length;
206
+ segments.push({ text: p, start_pos: cursor, end_pos: cursor + len });
207
+ cursor += len;
208
+ }
209
+ return segments;
210
+ }
@@ -751,5 +751,34 @@ export const migrations = [
751
751
  db.exec(`DROP TABLE IF EXISTS entity_observations`);
752
752
  db.exec(`DROP TABLE IF EXISTS observation_roots`);
753
753
  }
754
+ },
755
+ {
756
+ version: 14,
757
+ description: 'Chunking signature: documents.chunking_signature (schema-only; backfill legacy-unknown)',
758
+ up: (db) => {
759
+ // spec §6.1: schema-only. 전환은 sync 의 content 변경 시에만 (spec §5.1, r4 D3).
760
+ // 백필값은 'legacy-unknown', NOT 'bpe-800-160': custom chunkDocument 파라미터가
761
+ // 기록된 적 없어 단정하면 거짓 표기가 된다 (advisor r1).
762
+ const before = db.prepare(`SELECT count(*) AS n FROM documents`).get().n;
763
+ db.exec(`ALTER TABLE documents ADD COLUMN chunking_signature TEXT NOT NULL DEFAULT 'legacy-unknown'`);
764
+ // 리터럴 고정 — 마이그레이션은 동결된 역사다. 런타임 기본값 진화는 boot upsert 소관.
765
+ db.prepare(`INSERT INTO server_meta (key, value) VALUES ('current_default_chunker', ?)
766
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
767
+ .run('c1:enc=cl100k_base:max=800:overlap=0:fence=on:fallback=cp-exact-800');
768
+ const after = db.prepare(`SELECT count(*) AS n FROM documents`).get().n;
769
+ if (after !== before)
770
+ throw new Error(`v14 gate: documents rows changed ${before} -> ${after}`);
771
+ const cols = db.prepare(`PRAGMA table_info(documents)`).all();
772
+ if (!cols.some(c => c.name === 'chunking_signature'))
773
+ throw new Error('v14 gate: column missing after ALTER');
774
+ const fk = db.prepare(`PRAGMA foreign_key_check`).all();
775
+ if (fk.length > 0)
776
+ throw new Error(`v14 gate: foreign_key_check reported ${fk.length} violations`);
777
+ },
778
+ down: (db) => {
779
+ // 호환성 rollback 뿐 (spec §6.3): chunk 경계는 복원하지 않는다. c1 행은 v13 코드가 읽는다.
780
+ db.exec(`ALTER TABLE documents DROP COLUMN chunking_signature`);
781
+ db.prepare(`DELETE FROM server_meta WHERE key = 'current_default_chunker'`).run();
782
+ }
754
783
  }
755
784
  ];
@@ -110,7 +110,7 @@ Applies all pending database migrations to bring the schema up to the latest ver
110
110
  },
111
111
  rollbackMigration: {
112
112
  capability: {
113
- description: 'Rollback database to a specific migration version',
113
+ description: 'Rollback database to a specific migration version. v14+: rolling back the chunking_signature column does NOT restore old chunk boundaries (compatibility rollback only — the response carries semanticRollback:false and a warning).',
114
114
  parameters: {
115
115
  type: 'object',
116
116
  properties: {
@@ -75,12 +75,12 @@ const chunkDocumentCapability = {
75
75
  },
76
76
  maxTokens: {
77
77
  type: 'number',
78
- description: 'Maximum tokens per chunk (default: 200)',
78
+ description: 'Maximum tokens per chunk (default: 800, positive integer)',
79
79
  optional: true
80
80
  },
81
81
  overlap: {
82
82
  type: 'number',
83
- description: 'Number of overlapping tokens between chunks (default: 20)',
83
+ description: 'Deprecated since v5.0.0: chunker c1 has no overlap. Omit or pass exactly 0 — any other value is rejected.',
84
84
  optional: true
85
85
  }
86
86
  },
@@ -94,7 +94,7 @@ Create text chunks from a stored document with configurable chunking parameters.
94
94
 
95
95
  <importantNotes>
96
96
  - (!important!) **Document must be stored first** using storeDocument
97
- - (!important!) **Configurable chunking** - adjust maxTokens and overlap as needed
97
+ - (!important!) **Configurable chunking** - adjust maxTokens as needed. overlap is deprecated since v5.0.0 (chunker c1 has no overlap): omit it or pass exactly 0 — any other value is rejected
98
98
  - (!important!) **Replaces existing chunks** for the document if any exist
99
99
  </importantNotes>
100
100
 
@@ -108,19 +108,19 @@ Create text chunks from a stored document with configurable chunking parameters.
108
108
  <bestPractices>
109
109
  - Smaller chunks (100-150 tokens) for precise retrieval
110
110
  - Larger chunks (300-500 tokens) for context preservation
111
- - Use overlap (10-30 tokens) to maintain continuity
111
+ - Chunker c1 anchors boundaries to markdown structure and merges adjacent sections shorter than floor(maxTokens/2), so continuity needs no overlap
112
112
  - Consider document type when choosing chunk size
113
113
  </bestPractices>
114
114
 
115
115
  <examples>
116
116
  - Default chunking: {"documentId": "doc1"}
117
- - Custom size: {"documentId": "doc1", "maxTokens": 150, "overlap": 30}
118
- - Large context: {"documentId": "legal_doc", "maxTokens": 400, "overlap": 50}
117
+ - Custom size: {"documentId": "doc1", "maxTokens": 150}
118
+ - Large context: {"documentId": "legal_doc", "maxTokens": 400}
119
119
  </examples>`;
120
120
  const chunkDocumentSchema = {
121
121
  documentId: z.string().describe('ID of the stored document to chunk'),
122
- maxTokens: z.number().default(200).optional().describe('Maximum tokens per chunk'),
123
- overlap: z.number().default(20).optional().describe('Number of overlapping tokens'),
122
+ maxTokens: z.number().optional().default(800).describe('Maximum tokens per chunk (positive integer)'),
123
+ overlap: z.number().optional().default(0).describe('Deprecated: omit or 0 only (rejected otherwise since v5.0.0)'),
124
124
  };
125
125
  export const chunkDocumentTool = {
126
126
  capability: chunkDocumentCapability,
@@ -549,7 +549,7 @@ const syncDocumentFromFileCapability = {
549
549
  },
550
550
  chunkParams: {
551
551
  type: 'object',
552
- description: 'Optional chunking parameters { maxTokens, overlap }',
552
+ description: 'Optional chunking parameters { maxTokens }. overlap: omit or 0 only (rejected otherwise since v5.0.0 — chunker c1 has no overlap)',
553
553
  additionalProperties: true,
554
554
  optional: true
555
555
  }
@@ -594,8 +594,9 @@ const syncDocumentFromFileSchema = {
594
594
  documentId: z.string().describe('RAG document ID to (re)create from the file'),
595
595
  metadata: z.record(z.any()).optional().describe('Metadata merged into the stored document'),
596
596
  content: z.string().optional().describe('Optional content override; stored instead of reading the file'),
597
+ excludePattern: z.union([z.string(), z.array(z.string())]).optional().describe('Regular expression(s) whose matches are stripped before indexing. Applied to the file (or to `content`) first, so hash, byte count and chunking all describe what was actually indexed. Compiled with the dotAll flag, so a pattern may span lines to drop a marked block. An invalid expression fails the call rather than indexing the whole file.'),
597
598
  entityNames: z.array(z.string()).optional().describe('Optional entities to explicitly link'),
598
- chunkParams: z.record(z.any()).optional().describe('Optional chunking parameters { maxTokens, overlap }'),
599
+ chunkParams: z.record(z.any()).optional().describe('Optional chunking parameters { maxTokens }. overlap: omit or 0 only (rejected otherwise since v5.0.0)'),
599
600
  };
600
601
  export const syncDocumentFromFileTool = {
601
602
  capability: syncDocumentFromFileCapability,
package/docs/UPDATING.md CHANGED
@@ -108,6 +108,84 @@ 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
+ ## v5.1.0 (schema v14, unchanged): destructive-replace reporting + `excludePattern`
112
+
113
+ **What changes on upgrade**: nothing you have to do. No migration, no schema
114
+ change, no re-embedding. Both changes are additive — existing calls keep their
115
+ arguments and keep working, and the new response fields are extra keys.
116
+
117
+ **`storeDocument` now says what it destroyed.** It has always deleted the
118
+ previous document's chunks, vectors and entity links before writing, but the
119
+ response was `{ id, stored: true }`, so a caller replacing a document could not
120
+ tell from the return value that anything was removed. It now returns
121
+ `{ id, stored, replaced, deletedChunks }`, matching what `syncDocumentFromFile`
122
+ already reported. `replaced` is decided by the document row, not the chunk
123
+ count — a document that was stored but never chunked still gets overwritten,
124
+ and reporting that as a fresh write would be wrong.
125
+
126
+ **`syncDocumentFromFile` accepts `excludePattern`** (string or array of
127
+ strings): regions matching these regular expressions are stripped before
128
+ indexing. Previously the only way to leave part of a file out was to read it
129
+ yourself and pass the whole edited text through `content`, which defeats the
130
+ point of a tool that reads server-side to keep content off the model context.
131
+
132
+ Three properties worth knowing:
133
+
134
+ 1. **The exclusion happens first**, before hashing and chunking, so
135
+ `content_hash`, the reported `bytes` and the chunk boundaries all describe
136
+ what was actually indexed. Changing only the pattern therefore invalidates
137
+ the dedup gate and re-indexes; it does not silently return `unchanged`.
138
+ 2. **Patterns are compiled with the dotAll flag**, so one pattern can span
139
+ lines to drop a marked block (`<!-- SECRET -->[\s\S]*?<!-- /SECRET -->`).
140
+ JavaScript has no inline `(?s)`, so without this every such pattern would
141
+ quietly match nothing.
142
+ 3. **An invalid expression fails the call.** Degrading to "no exclusion" would
143
+ index the whole file while the caller believes it was filtered, and an index
144
+ is a disclosure path — a failed sync is the safer error.
145
+
146
+ ## v5.0.0 (schema v14): chunker c1 + vector reuse
147
+
148
+ **Breaking**: `chunkParams.overlap` is rejected on BOTH public paths
149
+ (`syncDocumentFromFile.chunkParams` and `chunkDocument`) unless omitted or
150
+ exactly 0 — chunker c1 has no overlap. `maxTokens` must be a positive integer.
151
+ Validation runs before the dedup gate, so invalid params fail even on
152
+ unchanged content.
153
+
154
+ **What changes on upgrade**: nothing, immediately. v14 is schema-only —
155
+ `documents.chunking_signature` is added with DEFAULT `legacy-unknown` and no
156
+ data row changes, so there is no coverage cliff and no re-embedding storm.
157
+ A document transitions to c1 only when its CONTENT changes at sync time
158
+ (signature mismatch alone is an observed state, not a trigger). The first
159
+ sync of an edited document pays a cold transition (old BPE chunk texts rarely
160
+ match c1 boundaries); every later sync reuses vectors for unchanged text.
161
+
162
+ **Observability**: `getKnowledgeGraphStats().chunking = { current, legacy,
163
+ unknown, default_signature }` — mutually exclusive, sums to `documents`. The
164
+ framework's /start Step 5a reads this response.
165
+
166
+ **Rollback caveat (v14)**: `rollbackMigration` drops the column and returns
167
+ `semanticRollback: false` plus a warning — chunk boundaries produced by c1
168
+ are NOT restored (they read fine on v13 code). Data restore path = the
169
+ pre-migration backup snapshot.
170
+
171
+ **Manual links**: full replacement re-derives `chunk_entities`; a link made
172
+ via `linkEntitiesToDocument` that is not reproducible from body literals or
173
+ `entityNames` is not preserved (true before v5 too). New in v5: primary
174
+ entity names are also linked by document-level occurrence ranges, so a name
175
+ cut across a chunk boundary still links to the intersecting chunks
176
+ (overlap used to absorb this; c1 has none).
177
+
178
+ **Fleet prerequisite before releasing/upgrading**: audit every deployment's
179
+ `schema_migrations` for occupied slots `>= 14` — pending migrations are
180
+ selected by MAX(version) arithmetic, so an experimental slot silently skips
181
+ the real v14 (this is exactly how code-v8 never ran in production).
182
+
183
+ **Diagnostic env (v5)**: `RAG_MEMORY_SEARCH_SUMMARIES=off` disables the per-result
184
+ sentence-similarity summaries in `hybridSearch` (which embed every sentence of every
185
+ candidate — 100+ inferences, 90-120s cold per search, measured). Off = preview-slice
186
+ summaries, `relevance_score` 0, ranking rests on vector similarity + boosts. Default
187
+ unchanged. The 3-arm release harness sets this uniformly across all arms.
188
+
111
189
  ## v3.6 breaking response changes
112
190
 
113
191
  1. `hybridSearch` returns an envelope: `{results, search_mode, model_state,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rag-memory-epf-mcp",
3
- "version": "4.0.0",
3
+ "version": "5.1.0",
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/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",
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/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/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",
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
  },