rag-memory-epf-mcp 3.3.6 → 3.5.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
@@ -13,7 +13,7 @@ A **project-local RAG memory** MCP server — knowledge graph + multilingual vec
13
13
  - **3-signal hybrid search** — vector similarity (bge-m3, 1024-dim) + FTS5 BM25 keyword matching + knowledge graph re-ranking, combined via Reciprocal Rank Fusion
14
14
  - **100+ languages** — Korean, Chinese, Japanese, Arabic, and more. Cross-lingual search works out of the box.
15
15
  - **Graph-aware scoring** — per-entity geometric decay (0.5^i) with hard cap prevents any single document from dominating results
16
- - **30 MCP tools** — knowledge graph CRUD, document pipeline, hybrid search, multi-hop traversal, graph analytics (centrality / community detection / structure), export/import, temporal queries
16
+ - **31 MCP tools** — knowledge graph CRUD, document pipeline, hybrid search, multi-hop traversal, graph analytics (centrality / community detection / structure), export/import, temporal queries
17
17
  - **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
18
  - **SQLite optimized** — WAL mode, 32MB cache, 256MB mmap, FTS5 triggers, 7 indexes
19
19
  - **MCP SDK 1.27.1** — Tool Annotations (readOnly/destructive/idempotent), latest protocol 2025-11-25
@@ -36,7 +36,7 @@ A **project-local RAG memory** MCP server — knowledge graph + multilingual vec
36
36
 
37
37
  Place this `.mcp.json` in each project folder with its own `DB_FILE_PATH`. Each project maintains completely isolated memory.
38
38
 
39
- ## Tools (30)
39
+ ## Tools (31)
40
40
 
41
41
  ### Knowledge Graph (7)
42
42
  | Tool | Description | Annotation |
@@ -49,7 +49,7 @@ Place this `.mcp.json` in each project folder with its own `DB_FILE_PATH`. Each
49
49
  | `deleteRelations` | Remove specific relationships | destructive |
50
50
  | `deleteObservations` | Remove specific observations | destructive |
51
51
 
52
- ### Document Pipeline (8)
52
+ ### Document Pipeline (9)
53
53
  | Tool | Description | Annotation |
54
54
  |------|------------|------------|
55
55
  | `storeDocument` | Store documents with metadata | idempotent |
@@ -60,11 +60,12 @@ Place this `.mcp.json` in each project folder with its own `DB_FILE_PATH`. Each
60
60
  | `linkEntitiesToDocument` | Link entities to chunks where they actually appear (text-matched) | idempotent |
61
61
  | `deleteDocuments` | Remove documents and associated data | destructive |
62
62
  | `listDocuments` | View all stored documents | readOnly |
63
+ | `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 |
63
64
 
64
65
  ### Search & Retrieval (9)
65
66
  | Tool | Description | Annotation |
66
67
  |------|------------|------------|
67
- | `hybridSearch` | Vector + FTS5 BM25 + graph traversal (3-signal) | readOnly |
68
+ | `hybridSearch` | Vector + FTS5 BM25 + graph traversal (3-signal). Degrades to FTS5-only (`search_mode`) when the embedding model is down | readOnly |
68
69
  | `searchNodes` | Semantic entity search with `since`/`until` temporal filtering | readOnly |
69
70
  | `openNodes` | Retrieve specific entities by name | readOnly |
70
71
  | `readGraph` | Get complete knowledge graph | readOnly |
@@ -136,6 +137,14 @@ storeDocument(id, content, metadata)
136
137
 
137
138
  ## Changelog
138
139
 
140
+ ### v3.5.0
141
+ - **Atomic `syncDocumentFromFile`**: embeddings are computed before any DB write, then applied in a single synchronous transaction, so a failed embedding (e.g. model still loading) leaves the existing document fully intact instead of a half-deleted or partially-embedded state.
142
+ - **`content_hash` dedup**: unchanged files short-circuit the delete/chunk/embed pipeline (`skipped: true`), with a completeness gate that still re-processes a partially-embedded document.
143
+ - **FTS5-only graceful degradation**: when the embedding model is unavailable, `hybridSearch` returns BM25 (full-text) results tagged `search_mode: 'fts-only'` instead of failing the whole query.
144
+
145
+ ### v3.4.0
146
+ - **`syncDocumentFromFile`: one-call server-side document sync** - reads a file on the server and runs the full pipeline (`deleteDocuments` → `storeDocument` → `chunkDocument` → `embedChunks` → `linkEntitiesToDocument`) in a single call, returning only a terse summary `{ documentId, bytes, chunks, embeddedChunks, linkedEntities, warning? }`. File content is read server-side and never routed through the model context, collapsing the usual 5 tool calls per document into one and keeping conversation context flat (the dominant cost of large sync runs). 30 → 31 tools.
147
+
139
148
  ### v3.3.6
140
149
  - **Publish-time invariant test** — `npm run verify:invariants` (wired as `prepublishOnly`) catches `chunkText` offset regressions before they ship. Tests ASCII / Korean / emoji-heavy / mixed CJK + supplementary plane / pure supplementary inputs against the codepoint-slice contract.
141
150
  - **`chunkText` extracted to `src/chunkText.ts`** — algorithm now testable in isolation. The class method is a thin wrapper. No user-facing API change.
package/dist/index.d.ts CHANGED
@@ -1,2 +1,295 @@
1
1
  #!/usr/bin/env node
2
+ interface Entity {
3
+ name: string;
4
+ entityType: string;
5
+ observations: string[];
6
+ }
7
+ interface Relation {
8
+ from: string;
9
+ to: string;
10
+ relationType: string;
11
+ }
12
+ interface KnowledgeGraph {
13
+ entities: Entity[];
14
+ relations: Relation[];
15
+ }
16
+ interface EnhancedSearchResult {
17
+ relevance_score: number;
18
+ key_highlight: string;
19
+ content_summary: string;
20
+ chunk_id: string;
21
+ document_title: string;
22
+ entities: string[];
23
+ vector_similarity: number;
24
+ graph_boost?: number;
25
+ fts_boost?: number;
26
+ full_context_available: boolean;
27
+ chunk_type: 'document' | 'entity' | 'relationship';
28
+ source_id?: string;
29
+ search_mode?: 'hybrid' | 'fts-only';
30
+ }
31
+ interface DetailedContext {
32
+ chunk_id: string;
33
+ document_id: string;
34
+ full_text: string;
35
+ document_title: string;
36
+ surrounding_chunks?: Array<{
37
+ chunk_id: string;
38
+ text: string;
39
+ position: 'before' | 'after';
40
+ }>;
41
+ entities: string[];
42
+ metadata: Record<string, any>;
43
+ }
44
+ export declare class RAGKnowledgeGraphManager {
45
+ private db;
46
+ private encoding;
47
+ private embeddingModel;
48
+ private modelInitialized;
49
+ private embeddingCache;
50
+ private readonly EMBEDDING_CACHE_MAX;
51
+ private dictionaryCache;
52
+ initialize(opts?: {
53
+ skipModel?: boolean;
54
+ }): Promise<void>;
55
+ private initializeEmbeddingModel;
56
+ runMigrations(): Promise<{
57
+ applied: number;
58
+ currentVersion: number;
59
+ appliedMigrations: Array<{
60
+ version: number;
61
+ description: string;
62
+ }>;
63
+ }>;
64
+ cleanup(): void;
65
+ private _timestampObservation;
66
+ createEntities(entities: Entity[]): Promise<Entity[]>;
67
+ createRelations(relations: Relation[]): Promise<Relation[]>;
68
+ addObservations(observations: {
69
+ entityName: string;
70
+ contents: string[];
71
+ }[]): Promise<{
72
+ entityName: string;
73
+ addedObservations: string[];
74
+ }[]>;
75
+ deleteEntities(entityNames: string[]): Promise<void>;
76
+ deleteObservations(deletions: {
77
+ entityName: string;
78
+ observations: string[];
79
+ }[]): Promise<void>;
80
+ deleteRelations(relations: Relation[]): Promise<void>;
81
+ updateRelations(updates: {
82
+ from: string;
83
+ to: string;
84
+ relationType: string;
85
+ confidence?: number;
86
+ metadata?: Record<string, any>;
87
+ }[]): Promise<{
88
+ updated: number;
89
+ notFound: number;
90
+ }>;
91
+ readGraph(): Promise<KnowledgeGraph>;
92
+ getNeighbors(entityNames: string[], depth?: number, relationType?: string): Promise<{
93
+ entities: Array<{
94
+ name: string;
95
+ entityType: string;
96
+ observations: string[];
97
+ depth: number;
98
+ }>;
99
+ relations: Array<{
100
+ from: string;
101
+ to: string;
102
+ relationType: string;
103
+ depth: number;
104
+ }>;
105
+ paths: Array<{
106
+ from: string;
107
+ to: string;
108
+ path: string[];
109
+ }>;
110
+ }>;
111
+ searchNodes(query: string, limit?: number, since?: string, until?: string): Promise<KnowledgeGraph>;
112
+ openNodes(names: string[]): Promise<KnowledgeGraph>;
113
+ private generateEntityEmbeddingText;
114
+ private splitIntoSentences;
115
+ private calculateSentenceSimilarities;
116
+ private cosineSimilarity;
117
+ private enhanceSimilarityWithContext;
118
+ private generateContentSummary;
119
+ private embedEntity;
120
+ embedAllEntities(): Promise<{
121
+ totalEntities: number;
122
+ embeddedEntities: number;
123
+ }>;
124
+ generateKnowledgeGraphChunks(): Promise<{
125
+ entityChunks: number;
126
+ relationshipChunks: number;
127
+ }>;
128
+ embedKnowledgeGraphChunks(): Promise<{
129
+ embeddedChunks: number;
130
+ totalChunks: number;
131
+ errors?: string[];
132
+ }>;
133
+ private generateEntityChunkText;
134
+ private generateRelationshipChunkText;
135
+ private cleanupKnowledgeGraphChunks;
136
+ private loadDictionary;
137
+ private hasKorean;
138
+ private isLikelyEnglish;
139
+ private normalizeQueryText;
140
+ private buildCrossLingualDictionary;
141
+ private translateQueryWithMap;
142
+ private buildCrossLingualVariants;
143
+ private extractTermsFromText;
144
+ private chunkText;
145
+ private generateEmbedding;
146
+ syncDocumentFromFile(filePath: string, documentId: string, options?: {
147
+ metadata?: Record<string, any>;
148
+ content?: string;
149
+ entityNames?: string[];
150
+ chunkParams?: {
151
+ maxTokens?: number;
152
+ overlap?: number;
153
+ };
154
+ }): Promise<{
155
+ documentId: string;
156
+ bytes: number;
157
+ chunks: number;
158
+ embeddedChunks: number;
159
+ linkedEntities: number;
160
+ explicitlyLinked?: number;
161
+ warning?: string;
162
+ skipped?: boolean;
163
+ reason?: string;
164
+ }>;
165
+ storeDocument(id: string, content: string, metadata?: Record<string, any>): Promise<{
166
+ id: string;
167
+ stored: boolean;
168
+ }>;
169
+ chunkDocument(documentId: string, options?: {
170
+ maxTokens?: number;
171
+ overlap?: number;
172
+ }): Promise<{
173
+ documentId: string;
174
+ chunks: Array<{
175
+ id: string;
176
+ text: string;
177
+ startPos: number | null;
178
+ endPos: number | null;
179
+ startToken: number | null;
180
+ endToken: number | null;
181
+ }>;
182
+ }>;
183
+ embedChunks(documentId: string): Promise<{
184
+ documentId: string;
185
+ embeddedChunks: number;
186
+ totalChunks: number;
187
+ linkedEntities?: number;
188
+ errors?: string[];
189
+ }>;
190
+ private hasCJK;
191
+ private buildEntityMatcher;
192
+ private autoLinkEntities;
193
+ extractTerms(documentId: string, options?: {
194
+ minLength?: number;
195
+ includeCapitalized?: boolean;
196
+ customPatterns?: string[];
197
+ }): Promise<{
198
+ documentId: string;
199
+ terms: string[];
200
+ }>;
201
+ linkEntitiesToDocument(documentId: string, entityNames: string[]): Promise<{
202
+ documentId: string;
203
+ linkedEntities: number;
204
+ }>;
205
+ private cleanupDocument;
206
+ deleteDocument(documentId: string): Promise<{
207
+ documentId: string;
208
+ deleted: boolean;
209
+ }>;
210
+ deleteMultipleDocuments(documentIds: string[]): Promise<{
211
+ results: Array<{
212
+ documentId: string;
213
+ deleted: boolean;
214
+ }>;
215
+ summary: {
216
+ deleted: number;
217
+ failed: number;
218
+ total: number;
219
+ };
220
+ }>;
221
+ deleteDocuments(documentIds: string | string[]): Promise<{
222
+ results: Array<{
223
+ documentId: string;
224
+ deleted: boolean;
225
+ }>;
226
+ summary: {
227
+ deleted: number;
228
+ failed: number;
229
+ total: number;
230
+ };
231
+ }>;
232
+ listDocuments(includeMetadata?: boolean): Promise<{
233
+ documents: Array<{
234
+ id: string;
235
+ metadata?: any;
236
+ created_at: string;
237
+ }>;
238
+ }>;
239
+ exportGraph(): Promise<{
240
+ entities: any[];
241
+ relations: any[];
242
+ documents: any[];
243
+ metadata: {
244
+ exportedAt: string;
245
+ version: string;
246
+ entityCount: number;
247
+ relationCount: number;
248
+ documentCount: number;
249
+ };
250
+ }>;
251
+ importGraph(data: {
252
+ entities?: any[];
253
+ relations?: any[];
254
+ documents?: any[];
255
+ }, options?: {
256
+ merge?: boolean;
257
+ }): Promise<{
258
+ imported: {
259
+ entities: number;
260
+ relations: number;
261
+ documents: number;
262
+ };
263
+ skipped: {
264
+ entities: number;
265
+ relations: number;
266
+ documents: number;
267
+ };
268
+ }>;
269
+ hybridSearch(query: string, limit?: number, useGraph?: boolean): Promise<EnhancedSearchResult[]>;
270
+ getDetailedContext(chunkId: string, includeSurrounding?: boolean): Promise<DetailedContext>;
271
+ getKnowledgeGraphStats(): Promise<any>;
272
+ private _buildGraphologyGraph;
273
+ getGraphMetrics(entityNames?: string[], metrics?: string[], limit?: number): Promise<any>;
274
+ detectCommunities(resolution?: number): Promise<any>;
275
+ analyzeGraphStructure(): Promise<any>;
276
+ getMigrationStatus(): Promise<{
277
+ currentVersion: number;
278
+ migrations: Array<{
279
+ version: number;
280
+ description: string;
281
+ applied: boolean;
282
+ applied_at?: string;
283
+ }>;
284
+ pendingCount: number;
285
+ }>;
286
+ rollbackMigration(targetVersion: number): Promise<{
287
+ rolledBack: number;
288
+ currentVersion: number;
289
+ rolledBackMigrations: Array<{
290
+ version: number;
291
+ description: string;
292
+ }>;
293
+ }>;
294
+ }
2
295
  export {};
package/dist/index.js CHANGED
@@ -56,7 +56,7 @@ function safeRowid(value) {
56
56
  return n;
57
57
  }
58
58
  // Enhanced RAG-enabled Knowledge Graph Manager
59
- class RAGKnowledgeGraphManager {
59
+ export class RAGKnowledgeGraphManager {
60
60
  db = null;
61
61
  encoding = null;
62
62
  embeddingModel = null;
@@ -64,7 +64,7 @@ class RAGKnowledgeGraphManager {
64
64
  embeddingCache = new Map();
65
65
  EMBEDDING_CACHE_MAX = 500;
66
66
  dictionaryCache = null;
67
- async initialize() {
67
+ async initialize(opts = {}) {
68
68
  console.error('🚀 Initializing RAG Knowledge Graph MCP Server...');
69
69
  // Initialize database
70
70
  this.db = new Database(DB_FILE_PATH);
@@ -80,8 +80,13 @@ class RAGKnowledgeGraphManager {
80
80
  this.db.pragma('foreign_keys = ON');
81
81
  // Initialize tiktoken
82
82
  this.encoding = get_encoding("cl100k_base");
83
- // Initialize embedding model
84
- await this.initializeEmbeddingModel();
83
+ // Initialize embedding model (skippable for tests / FTS-only environments)
84
+ if (!opts.skipModel) {
85
+ await this.initializeEmbeddingModel();
86
+ }
87
+ else {
88
+ console.error('⏭️ Skipping embedding model load (skipModel=true)');
89
+ }
85
90
  // Run database migrations
86
91
  await this.runMigrations();
87
92
  console.error('✅ RAG-enabled knowledge graph initialized');
@@ -1170,6 +1175,105 @@ class RAGKnowledgeGraphManager {
1170
1175
  throw new Error('Embedding model not initialized. The server may still be loading the model — retry in a few seconds.');
1171
1176
  }
1172
1177
  // === NEW SEPARATE TOOLS ===
1178
+ async syncDocumentFromFile(filePath, documentId, options = {}) {
1179
+ if (!this.db)
1180
+ throw new Error('Database not initialized');
1181
+ // 1. Resolve content: raw file verbatim (default) or explicit override.
1182
+ // Content is read on the server and never routed through the model context.
1183
+ const content = options.content !== undefined
1184
+ ? options.content
1185
+ : fsSync.readFileSync(filePath, 'utf-8');
1186
+ const bytes = Buffer.byteLength(content, 'utf-8');
1187
+ // 2. Metadata: default source=path, updated=today, content_hash; caller can override.
1188
+ const today = new Date().toISOString().slice(0, 10);
1189
+ const contentHash = createHash('sha256').update(content).digest('hex');
1190
+ const metadata = { source: filePath, updated: today, content_hash: contentHash, ...(options.metadata || {}) };
1191
+ // 2b. Dedup gate: skip the full delete/store/chunk/embed pipeline when the
1192
+ // file is unchanged AND the existing document is fully embedded. The
1193
+ // completeness check avoids wrongly skipping a partial/failed prior sync.
1194
+ const existingDoc = this.db.prepare(`SELECT metadata FROM documents WHERE id = ?`).get(documentId);
1195
+ if (existingDoc) {
1196
+ let existingHash;
1197
+ try {
1198
+ existingHash = JSON.parse(existingDoc.metadata)?.content_hash;
1199
+ }
1200
+ catch { /* ignore */ }
1201
+ if (existingHash === contentHash) {
1202
+ const cmCount = this.db.prepare(`SELECT count(*) AS n FROM chunk_metadata WHERE document_id = ?`).get(documentId).n;
1203
+ const embCount = this.db.prepare(`
1204
+ SELECT count(*) AS n FROM chunks c JOIN chunk_metadata m ON c.rowid = m.rowid WHERE m.document_id = ?
1205
+ `).get(documentId).n;
1206
+ if (cmCount > 0 && cmCount === embCount) {
1207
+ const linked = this.db.prepare(`
1208
+ SELECT count(DISTINCT ce.entity_id) AS n FROM chunk_entities ce
1209
+ JOIN chunk_metadata m ON ce.chunk_rowid = m.rowid WHERE m.document_id = ?
1210
+ `).get(documentId).n;
1211
+ console.error(`⏭️ syncDocumentFromFile: ${documentId} unchanged (hash match, ${cmCount} chunks embedded) — skipped`);
1212
+ return { documentId, bytes, chunks: cmCount, embeddedChunks: embCount, linkedEntities: linked, skipped: true, reason: 'unchanged' };
1213
+ }
1214
+ }
1215
+ }
1216
+ console.error(`🔄 syncDocumentFromFile: ${documentId} <- ${filePath} (${bytes} bytes)`);
1217
+ // 3. Pre-compute chunks + embeddings BEFORE any DB mutation. If embedding
1218
+ // throws (model down), the existing document is left completely intact.
1219
+ const { maxTokens = 800, overlap = 160 } = options.chunkParams || {};
1220
+ const segments = this.chunkText(content, maxTokens, overlap);
1221
+ const embedded = [];
1222
+ for (const seg of segments) {
1223
+ const embedding = await this.generateEmbedding(seg.text);
1224
+ embedded.push({ seg, embedding });
1225
+ }
1226
+ // 4. Atomic swap: delete old -> insert doc -> insert chunks + embeddings,
1227
+ // all in a single synchronous better-sqlite3 transaction (all-or-nothing).
1228
+ const applyTx = this.db.transaction(() => {
1229
+ const db = this.db;
1230
+ // 4a. cleanup old doc (inlined sync version of cleanupDocument).
1231
+ const existing = db.prepare(`SELECT rowid FROM chunk_metadata WHERE document_id = ?`).all(documentId);
1232
+ for (const ch of existing) {
1233
+ db.prepare(`DELETE FROM chunk_entities WHERE chunk_rowid = ?`).run(ch.rowid);
1234
+ db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(ch.rowid)}`);
1235
+ }
1236
+ db.prepare(`DELETE FROM chunk_metadata WHERE document_id = ?`).run(documentId);
1237
+ db.prepare(`DELETE FROM documents WHERE id = ?`).run(documentId);
1238
+ // 4b. insert document.
1239
+ db.prepare(`INSERT INTO documents (id, content, metadata) VALUES (?, ?, ?)`)
1240
+ .run(documentId, content, JSON.stringify(metadata));
1241
+ // 4c. insert chunk_metadata (FTS5 chunks_fts auto-filled by trigger) + embeddings.
1242
+ for (const { seg, embedding } of embedded) {
1243
+ const chunkId = `${documentId}_chunk_${seg.chunk_index}`;
1244
+ const info = db.prepare(`
1245
+ INSERT INTO chunk_metadata (chunk_id, document_id, chunk_index, text, start_pos, end_pos, start_token, end_token)
1246
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1247
+ `).run(chunkId, documentId, seg.chunk_index, seg.text, seg.start_pos, seg.end_pos, seg.start_token, seg.end_token);
1248
+ const rowid = Number(info.lastInsertRowid);
1249
+ db.prepare(`INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)`).run(Buffer.from(embedding.buffer));
1250
+ }
1251
+ });
1252
+ applyTx();
1253
+ const embeddedChunks = embedded.length;
1254
+ // 5. Entity linking AFTER commit. Non-destructive + idempotent (INSERT OR
1255
+ // IGNORE), so a linking failure cannot corrupt the doc/embeddings.
1256
+ const linkedEntities = await this.autoLinkEntities(documentId);
1257
+ let explicitlyLinked;
1258
+ if (options.entityNames && options.entityNames.length > 0) {
1259
+ const linkResult = await this.linkEntitiesToDocument(documentId, options.entityNames);
1260
+ explicitlyLinked = linkResult.linkedEntities;
1261
+ }
1262
+ // 6. Terse summary only (no chunk text / content echo) to keep caller context flat.
1263
+ const result = {
1264
+ documentId,
1265
+ bytes,
1266
+ chunks: segments.length,
1267
+ embeddedChunks,
1268
+ linkedEntities,
1269
+ ...(explicitlyLinked !== undefined ? { explicitlyLinked } : {}),
1270
+ };
1271
+ if (linkedEntities === 0 && explicitlyLinked === undefined) {
1272
+ 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.';
1273
+ }
1274
+ console.error(`✅ syncDocumentFromFile done: ${documentId} (${result.chunks} chunks, ${result.embeddedChunks} embedded, ${linkedEntities} linked)`);
1275
+ return result;
1276
+ }
1173
1277
  async storeDocument(id, content, metadata = {}) {
1174
1278
  if (!this.db)
1175
1279
  throw new Error('Database not initialized');
@@ -1676,7 +1780,8 @@ class RAGKnowledgeGraphManager {
1676
1780
  if (queryVariants.length > 1) {
1677
1781
  console.error(`🌐 Cross-lingual variants: ${queryVariants.slice(1).join(' | ')}`);
1678
1782
  }
1679
- const primaryQueryEmbedding = await this.generateEmbedding(queryVariants[0], 1024, true);
1783
+ let vectorDegraded = false;
1784
+ let primaryQueryEmbedding = null;
1680
1785
  // Vector search helper
1681
1786
  const searchChunks = (embedding, k) => {
1682
1787
  return this.db.prepare(`
@@ -1706,16 +1811,23 @@ class RAGKnowledgeGraphManager {
1706
1811
  };
1707
1812
  // Search original query plus cross-lingual expansions and keep best match per chunk.
1708
1813
  const resultMap = new Map();
1709
- for (const variant of queryVariants) {
1710
- const embedding = await this.generateEmbedding(variant, 1024, true);
1711
- const variantResults = searchChunks(embedding, limit * 3);
1712
- for (const r of variantResults) {
1713
- const existing = resultMap.get(r.chunk_id);
1714
- if (!existing || r.distance < existing.distance) {
1715
- resultMap.set(r.chunk_id, r);
1814
+ try {
1815
+ primaryQueryEmbedding = await this.generateEmbedding(queryVariants[0], 1024, true);
1816
+ for (const variant of queryVariants) {
1817
+ const embedding = await this.generateEmbedding(variant, 1024, true);
1818
+ const variantResults = searchChunks(embedding, limit * 3);
1819
+ for (const r of variantResults) {
1820
+ const existing = resultMap.get(r.chunk_id);
1821
+ if (!existing || r.distance < existing.distance) {
1822
+ resultMap.set(r.chunk_id, r);
1823
+ }
1716
1824
  }
1717
1825
  }
1718
1826
  }
1827
+ catch (embErr) {
1828
+ vectorDegraded = true;
1829
+ console.error(`⚠️ Vector search unavailable (embedding model down) — degrading to FTS5-only:`, embErr instanceof Error ? embErr.message : embErr);
1830
+ }
1719
1831
  const vectorResults = Array.from(resultMap.values()).sort((a, b) => a.distance - b.distance);
1720
1832
  // FTS5 full-text search as additional signal (Reciprocal Rank Fusion)
1721
1833
  const ftsBoostMap = new Map();
@@ -1803,7 +1915,7 @@ class RAGKnowledgeGraphManager {
1803
1915
  // Get entity information for graph enhancement via vector similarity
1804
1916
  let connectedEntities = new Set();
1805
1917
  let queryMatchedEntities = new Set();
1806
- if (useGraph) {
1918
+ if (useGraph && !vectorDegraded) {
1807
1919
  // Vector search: find entities semantically similar to the query (dual search)
1808
1920
  try {
1809
1921
  const searchEntities = (embedding) => {
@@ -1909,7 +2021,7 @@ class RAGKnowledgeGraphManager {
1909
2021
  }
1910
2022
  // Enhanced graph boost calculation with decay + cap
1911
2023
  let graphBoost = 0;
1912
- if (useGraph) {
2024
+ if (useGraph && !vectorDegraded) {
1913
2025
  const queryEntities = this.extractTermsFromText(query);
1914
2026
  // Base boost for knowledge graph chunks
1915
2027
  if (result.chunk_type === 'entity') {
@@ -1956,9 +2068,17 @@ class RAGKnowledgeGraphManager {
1956
2068
  // Hard cap to prevent graph domination
1957
2069
  graphBoost += Math.min(entityBoost, 0.4);
1958
2070
  }
1959
- // Generate semantic summary
1960
- const { summary, keyHighlight, relevanceScore } = await this.generateContentSummary(result.text, primaryQueryEmbedding, chunkEntities, result.chunk_type === 'relationship' ? 1 : 2 // Shorter summary for relationships
1961
- );
2071
+ // Generate semantic summary (skip when degraded — no embeddings available).
2072
+ let summary, keyHighlight, relevanceScore;
2073
+ if (vectorDegraded || !primaryQueryEmbedding) {
2074
+ keyHighlight = result.text.slice(0, 150);
2075
+ summary = result.text.slice(0, 300);
2076
+ relevanceScore = 0;
2077
+ }
2078
+ else {
2079
+ ({ summary, keyHighlight, relevanceScore } = await this.generateContentSummary(result.text, primaryQueryEmbedding, chunkEntities, result.chunk_type === 'relationship' ? 1 : 2 // Shorter summary for relationships
2080
+ ));
2081
+ }
1962
2082
  const vectorSimilarity = Math.max(0, 1 - result.distance / 2);
1963
2083
  const ftsBoost = ftsBoostMap.get(result.chunk_id) || 0;
1964
2084
  const finalScore = Math.max(vectorSimilarity, relevanceScore) + graphBoost + ftsBoost;
@@ -1990,11 +2110,12 @@ class RAGKnowledgeGraphManager {
1990
2110
  document_title: documentTitle,
1991
2111
  entities: chunkEntities,
1992
2112
  vector_similarity: vectorSimilarity,
1993
- graph_boost: useGraph ? graphBoost : undefined,
2113
+ graph_boost: (useGraph && !vectorDegraded) ? graphBoost : undefined,
1994
2114
  fts_boost: ftsBoost > 0 ? ftsBoost : undefined,
1995
2115
  full_context_available: true,
1996
2116
  chunk_type: result.chunk_type,
1997
- source_id: sourceId
2117
+ source_id: sourceId,
2118
+ search_mode: vectorDegraded ? 'fts-only' : 'hybrid'
1998
2119
  });
1999
2120
  }
2000
2121
  // Sort by relevance and return top results
@@ -2474,6 +2595,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2474
2595
  return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.deleteDocuments(validatedArgs.documentIds), null, 2) }] };
2475
2596
  case "listDocuments":
2476
2597
  return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.listDocuments(validatedArgs.includeMetadata !== false), null, 2) }] };
2598
+ case "syncDocumentFromFile":
2599
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.syncDocumentFromFile(validatedArgs.path, validatedArgs.documentId, {
2600
+ metadata: validatedArgs.metadata,
2601
+ content: validatedArgs.content,
2602
+ entityNames: validatedArgs.entityNames,
2603
+ chunkParams: validatedArgs.chunkParams,
2604
+ }), null, 2) }] };
2477
2605
  // NEW: Entity embedding tools
2478
2606
  case "embedAllEntities":
2479
2607
  return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.embedAllEntities(), null, 2) }] };
@@ -2532,8 +2660,12 @@ async function main() {
2532
2660
  process.exit(1);
2533
2661
  }
2534
2662
  }
2535
- main().catch((error) => {
2536
- console.error("Fatal error in main():", error);
2537
- ragKgManager.cleanup();
2538
- process.exit(1);
2539
- });
2663
+ // Only boot the server when run as the entry point — not when imported (tests).
2664
+ const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url);
2665
+ if (isDirectRun) {
2666
+ main().catch((error) => {
2667
+ console.error("Fatal error in main():", error);
2668
+ ragKgManager.cleanup();
2669
+ process.exit(1);
2670
+ });
2671
+ }
@@ -7,6 +7,7 @@ export declare const linkEntitiesToDocumentTool: ToolDefinition;
7
7
  export declare const getKnowledgeGraphStatsTool: ToolDefinition;
8
8
  export declare const deleteDocumentsTool: ToolDefinition;
9
9
  export declare const listDocumentsTool: ToolDefinition;
10
+ export declare const syncDocumentFromFileTool: ToolDefinition;
10
11
  export declare const ragTools: {
11
12
  storeDocument: ToolDefinition;
12
13
  chunkDocument: ToolDefinition;
@@ -16,4 +17,5 @@ export declare const ragTools: {
16
17
  getKnowledgeGraphStats: ToolDefinition;
17
18
  deleteDocuments: ToolDefinition;
18
19
  listDocuments: ToolDefinition;
20
+ syncDocumentFromFile: ToolDefinition;
19
21
  };
@@ -516,6 +516,93 @@ export const listDocumentsTool = {
516
516
  schema: listDocumentsSchema,
517
517
  annotations: { readOnlyHint: true },
518
518
  };
519
+ // === SYNC DOCUMENT FROM FILE TOOL ===
520
+ const syncDocumentFromFileCapability = {
521
+ description: 'Read a file server-side and run the full document sync pipeline (delete, store, chunk, embed, link) in one call, returning only a terse summary',
522
+ parameters: {
523
+ type: 'object',
524
+ properties: {
525
+ path: {
526
+ type: 'string',
527
+ description: 'Absolute path of the file to sync. Content is read server-side and never routed through the model context.'
528
+ },
529
+ documentId: {
530
+ type: 'string',
531
+ description: 'RAG document ID to (re)create from the file'
532
+ },
533
+ metadata: {
534
+ type: 'object',
535
+ description: 'Metadata merged into the stored document (source defaults to path, updated defaults to today)',
536
+ additionalProperties: true,
537
+ optional: true
538
+ },
539
+ content: {
540
+ type: 'string',
541
+ description: 'Optional content override; if provided, this is stored instead of reading the file',
542
+ optional: true
543
+ },
544
+ entityNames: {
545
+ type: 'array',
546
+ description: 'Optional entities to explicitly link in addition to automatic term-matching',
547
+ items: { type: 'string' },
548
+ optional: true
549
+ },
550
+ chunkParams: {
551
+ type: 'object',
552
+ description: 'Optional chunking parameters { maxTokens, overlap }',
553
+ additionalProperties: true,
554
+ optional: true
555
+ }
556
+ },
557
+ required: ['path', 'documentId'],
558
+ },
559
+ };
560
+ const syncDocumentFromFileDescription = () => `<description>
561
+ Read a file on the server and perform the entire document sync pipeline in a single call:
562
+ deleteDocuments -> storeDocument -> chunkDocument -> embedChunks -> (optional) linkEntitiesToDocument.
563
+ **The file content is read server-side and is NOT routed through the model context**, and only a
564
+ terse summary is returned (no chunk text). This collapses the usual 5 tool calls per document into
565
+ one and keeps the conversation context flat, which is the dominant cost of large /sync runs.
566
+ </description>
567
+
568
+ <importantNotes>
569
+ - (!important!) **Content is read from \`path\` on the server** - it does not pass through the model context
570
+ - (!important!) **Stores the raw file verbatim** by default (higher search fidelity); pass \`content\` to override
571
+ - (!important!) **Idempotent** - the target documentId is fully re-synced (delete + recreate) each call
572
+ - (!important!) **Returns terse counts only** - { documentId, bytes, chunks, embeddedChunks, linkedEntities }
573
+ - (!important!) **Entity linking** happens automatically via term-matching during embedding; \`entityNames\` adds explicit links
574
+ </importantNotes>
575
+
576
+ <whenToUseThisTool>
577
+ - During /sync to persist a Markdown/SSOT file into the RAG store without paying the context cost of reading it into the model
578
+ - When re-syncing decision logs, current-focus, wiki pages, or any file-backed document
579
+ - As a one-call replacement for the manual storeDocument + chunkDocument + embedChunks + linkEntitiesToDocument sequence
580
+ </whenToUseThisTool>
581
+
582
+ <bestPractices>
583
+ - Use the document's canonical ID (e.g. \`current-focus\`, \`wiki-project-context\`)
584
+ - Keep an entity-name literal (e.g. wiki anchor line "RAG entity: ...") in the file so term-matching can link entities; a warning is returned if linkedEntities is 0
585
+ - Pass \`metadata.updated\` (YYYY-MM-DD) for change tracking
586
+ </bestPractices>
587
+
588
+ <examples>
589
+ - Sync current-focus: {"path": "/abs/decisions/current-focus.md", "documentId": "current-focus", "metadata": {"updated": "2026-05-23"}}
590
+ - With explicit links: {"path": "/abs/wiki/project-context.md", "documentId": "wiki-project-context", "entityNames": ["Ultimate AI Personal Assistant"]}
591
+ </examples>`;
592
+ const syncDocumentFromFileSchema = {
593
+ path: z.string().describe('Absolute path of the file to sync (read server-side)'),
594
+ documentId: z.string().describe('RAG document ID to (re)create from the file'),
595
+ metadata: z.record(z.any()).optional().describe('Metadata merged into the stored document'),
596
+ content: z.string().optional().describe('Optional content override; stored instead of reading the file'),
597
+ 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
+ };
600
+ export const syncDocumentFromFileTool = {
601
+ capability: syncDocumentFromFileCapability,
602
+ description: syncDocumentFromFileDescription,
603
+ schema: syncDocumentFromFileSchema,
604
+ annotations: { idempotentHint: true },
605
+ };
519
606
  // Export all RAG tools
520
607
  export const ragTools = {
521
608
  storeDocument: storeDocumentTool,
@@ -526,4 +613,5 @@ export const ragTools = {
526
613
  getKnowledgeGraphStats: getKnowledgeGraphStatsTool,
527
614
  deleteDocuments: deleteDocumentsTool,
528
615
  listDocuments: listDocumentsTool,
616
+ syncDocumentFromFile: syncDocumentFromFileTool,
529
617
  };
@@ -20,6 +20,7 @@ export declare const allTools: {
20
20
  getKnowledgeGraphStats: ToolDefinition;
21
21
  deleteDocuments: ToolDefinition;
22
22
  listDocuments: ToolDefinition;
23
+ syncDocumentFromFile: ToolDefinition;
23
24
  createEntities: ToolDefinition;
24
25
  createRelations: ToolDefinition;
25
26
  addObservations: ToolDefinition;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rag-memory-epf-mcp",
3
- "version": "3.3.6",
3
+ "version": "3.5.0",
4
4
  "description": "Project-local RAG memory MCP server — knowledge graph + multilingual vector + FTS5 in a single SQLite file. Per-project isolation, 30 MCP tools, codepoint-safe chunking (Korean/CJK/emoji).",
5
5
  "keywords": [
6
6
  "mcp",
@@ -41,8 +41,9 @@
41
41
  "prepare": "npm run build",
42
42
  "watch": "tsc --watch",
43
43
  "verify:invariants": "node test/chunk-invariants.test.mjs",
44
- "test": "npm run build && npm run verify:invariants",
45
- "prepublishOnly": "npm run build && npm run verify:invariants"
44
+ "verify:engine": "node test/engine-smoke.test.mjs && node test/sync-atomicity.test.mjs && node test/dedup.test.mjs && node test/search-degradation.test.mjs",
45
+ "test": "npm run build && npm run verify:invariants && npm run verify:engine",
46
+ "prepublishOnly": "npm run build && npm run verify:invariants && npm run verify:engine"
46
47
  },
47
48
  "dependencies": {
48
49
  "@huggingface/transformers": "^3.5.1",
Binary file
Binary file
Binary file