rag-memory-epf-mcp 3.3.5 → 3.4.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,8 @@ 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
- - **27 MCP tools** — entity/relation CRUD, document pipeline, multi-hop graph traversal, export/import, temporal queries
16
+ - **30 MCP tools** — knowledge graph CRUD, document pipeline, hybrid search, multi-hop traversal, graph analytics (centrality / community detection / structure), export/import, temporal queries
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.
17
18
  - **SQLite optimized** — WAL mode, 32MB cache, 256MB mmap, FTS5 triggers, 7 indexes
18
19
  - **MCP SDK 1.27.1** — Tool Annotations (readOnly/destructive/idempotent), latest protocol 2025-11-25
19
20
 
@@ -35,7 +36,7 @@ A **project-local RAG memory** MCP server — knowledge graph + multilingual vec
35
36
 
36
37
  Place this `.mcp.json` in each project folder with its own `DB_FILE_PATH`. Each project maintains completely isolated memory.
37
38
 
38
- ## Tools (27)
39
+ ## Tools (30)
39
40
 
40
41
  ### Knowledge Graph (7)
41
42
  | Tool | Description | Annotation |
@@ -80,6 +81,13 @@ Place this `.mcp.json` in each project folder with its own `DB_FILE_PATH`. Each
80
81
  | `runMigrations` | Apply pending migrations | idempotent |
81
82
  | `rollbackMigration` | Revert to a previous schema version | destructive |
82
83
 
84
+ ### Graph Analytics (3)
85
+ | Tool | Description | Annotation |
86
+ |------|------------|------------|
87
+ | `getGraphMetrics` | Per-entity centrality (degree, betweenness, closeness, pagerank) | readOnly |
88
+ | `detectCommunities` | Louvain community detection + modularity score | readOnly |
89
+ | `analyzeGraphStructure` | Density, connected components, clustering coefficient | readOnly |
90
+
83
91
  ## Document Processing Pipeline
84
92
 
85
93
  ```
@@ -112,7 +120,7 @@ storeDocument(id, content, metadata)
112
120
  │ │ ├── chunks (sqlite-vec, 1024-dim) │ │
113
121
  │ │ ├── entity_embeddings (sqlite-vec) │ │
114
122
  │ │ ├── entities_fts + chunks_fts (FTS5) │ │
115
- │ │ └── 7 migrations (auto-applied) │ │
123
+ │ │ └── 11 migrations (auto-applied) │ │
116
124
  │ └────────────────────────────────────────┘ │
117
125
  │ │
118
126
  │ bge-m3 (ONNX, 100+ langs) │
@@ -128,6 +136,24 @@ storeDocument(id, content, metadata)
128
136
 
129
137
  ## Changelog
130
138
 
139
+ ### v3.3.6
140
+ - **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
+ - **`chunkText` extracted to `src/chunkText.ts`** — algorithm now testable in isolation. The class method is a thin wrapper. No user-facing API change.
142
+ - **README accuracy** — tool count corrected to 30, migration count to 11, Graph Analytics tools surfaced.
143
+
144
+ ### v3.3.5
145
+ - **Fix: chunk offsets stored as JS UTF-16 units instead of Unicode codepoints** — Korean/CJK/emoji documents had `start_pos`/`end_pos` that disagreed with SQL `substr` and Python slicing for any chunk crossing a supplementary character. `chunkText` now maintains parallel UTF-16 + codepoint cursors and reports codepoint offsets.
146
+ - **Migration v11** — converts existing `chunk_metadata.start_pos`/`end_pos` from UTF-16 units to codepoints by re-locating each chunk via `indexOf` and counting codepoints.
147
+
148
+ ### v3.3.4
149
+ - **Migration version 9 → 10 jump + `ALTER TABLE` idempotency guards** — some databases from early v3.x experiments (Ollama dimension swap) had recorded a migration at version 9, causing the new v9 migration to silently no-op. Bumped to version 10 and added `PRAGMA table_info` guards so the column-add is safe to re-run.
150
+
151
+ ### v3.3.3
152
+ - **Separate token-space and char-space offsets in `chunk_metadata`** — added `start_token`/`end_token` columns. Existing `start_pos`/`end_pos` are reinterpreted as character offsets into `documents.content`. Backfill migration recomputes char offsets via `indexOf` with a per-document cursor; misses leave NULL so callers can re-chunk to repair.
153
+
154
+ ### v3.3.0
155
+ - **Graph analytics (graphology)** — three new MCP tools: `getGraphMetrics` (degree / betweenness / closeness / pagerank), `detectCommunities` (Louvain + modularity), `analyzeGraphStructure` (density / components / clustering). 27 → 30 tools.
156
+
131
157
  ### v3.2.1
132
158
  - **Fix: `autoLinkEntities` silent failure** — was JOINing a non-existent `observations` table (observations are stored as JSON array column in `entities`). Changed to direct column select + `JSON.parse()`.
133
159
 
@@ -148,11 +174,11 @@ storeDocument(id, content, metadata)
148
174
  - **Multi-hop graph traversal** — `getNeighbors` tool with `WITH RECURSIVE` CTE, depth 1-5, cycle detection, bidirectional
149
175
  - **Embedding LRU cache** — 500-entry in-memory cache, skips redundant re-computation
150
176
  - **Configurable model** — `EMBEDDING_MODEL` env var to use alternative embedding models
151
- - 27 tools total
177
+ - 27 tools total at this version (30 as of v3.3.0+)
152
178
 
153
179
  ### v1.8.0
154
180
  - **MCP SDK 1.27.1** — protocol 2025-11-25, security fix GHSA-345p-7cg4-v4c7 (CVSS 7.1)
155
- - **Tool Annotations** — all 27 tools annotated (readOnlyHint, destructiveHint, idempotentHint)
181
+ - **Tool Annotations** — all tools annotated (readOnlyHint, destructiveHint, idempotentHint)
156
182
  - **SIGTERM graceful shutdown** — clean exit without ONNX mutex crash
157
183
 
158
184
  ### v1.7.0
@@ -188,8 +214,12 @@ git clone https://github.com/bripin123/rag-memory-epf-mcp.git
188
214
  cd rag-memory-epf-mcp
189
215
  npm install
190
216
  npm run build
217
+ npm test # build + invariant verification
218
+ npm run verify:invariants # standalone invariant test (assumes dist/ built)
191
219
  ```
192
220
 
221
+ `npm publish` automatically runs `prepublishOnly` (`build` + `verify:invariants`); a chunk-offset regression blocks the publish at the source.
222
+
193
223
  ## License
194
224
 
195
225
  MIT License. See [LICENSE](LICENSE).
package/dist/index.js CHANGED
@@ -21,6 +21,8 @@ import modularity from 'graphology-metrics/graph/modularity.js';
21
21
  import { getAllMCPTools, validateToolArgs, getSystemInfo } from './src/tools/tool-registry.js';
22
22
  // Import migration system
23
23
  import { MigrationManager } from './src/migrations/migration-manager.js';
24
+ // Import chunk text algorithm (extracted for publish-time invariant testing)
25
+ import { chunkText as splitTextIntoChunks } from './src/chunkText.js';
24
26
  import { migrations } from './src/migrations/migrations.js';
25
27
  import { createHash } from 'crypto';
26
28
  import { createRequire } from 'module';
@@ -45,34 +47,7 @@ const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL || 'Xenova/bge-m3';
45
47
  // When a chunk is not at the document head/tail, any partial sequence at that
46
48
  // edge belongs to an adjacent chunk and must be removed so TextDecoder does
47
49
  // not emit U+FFFD. Pass trimHead/trimTail=false to preserve head/tail bytes.
48
- function trimIncompleteUtf8(bytes, trimHead, trimTail) {
49
- let start = 0;
50
- let end = bytes.length;
51
- if (trimHead) {
52
- while (start < end && (bytes[start] & 0xC0) === 0x80)
53
- start++;
54
- }
55
- if (trimTail) {
56
- let i = end - 1;
57
- while (i >= start && (bytes[i] & 0xC0) === 0x80)
58
- i--;
59
- if (i >= start) {
60
- const lead = bytes[i];
61
- let needed = 1;
62
- if ((lead & 0x80) === 0)
63
- needed = 1;
64
- else if ((lead & 0xE0) === 0xC0)
65
- needed = 2;
66
- else if ((lead & 0xF0) === 0xE0)
67
- needed = 3;
68
- else if ((lead & 0xF8) === 0xF0)
69
- needed = 4;
70
- if (end - i < needed)
71
- end = i;
72
- }
73
- }
74
- return bytes.subarray(start, end);
75
- }
50
+ // (Implementation moved to src/chunkText.ts for testability.)
76
51
  function safeRowid(value) {
77
52
  const n = Number(value);
78
53
  if (!Number.isInteger(n) || n < 0) {
@@ -1148,60 +1123,17 @@ class RAGKnowledgeGraphManager {
1148
1123
  chunkText(text, maxTokens = 800, overlap = 160) {
1149
1124
  if (!this.encoding)
1150
1125
  throw new Error('Tokenizer not initialized');
1151
- const tokens = this.encoding.encode(text);
1152
- const chunks = [];
1153
- let utf16Cursor = 0;
1154
- let cpCursor = 0;
1155
- for (let i = 0; i < tokens.length; i += maxTokens - overlap) {
1156
- const chunkTokens = tokens.slice(i, i + maxTokens);
1157
- const decodedBytes = this.encoding.decode(chunkTokens);
1158
- const isFirst = i === 0;
1159
- const isLast = i + chunkTokens.length >= tokens.length;
1160
- const safeBytes = trimIncompleteUtf8(decodedBytes, !isFirst, !isLast);
1161
- const chunkText = new TextDecoder('utf-8').decode(safeBytes);
1162
- let startPos;
1163
- let endPos;
1164
- if (isFirst) {
1165
- startPos = 0;
1166
- endPos = [...chunkText].length;
1167
- utf16Cursor = 0;
1168
- cpCursor = 0;
1169
- }
1170
- else if (chunkText.length === 0) {
1171
- startPos = null;
1172
- endPos = null;
1173
- }
1174
- else {
1175
- const utfIdx = text.indexOf(chunkText, utf16Cursor);
1176
- if (utfIdx >= 0) {
1177
- // Advance cpCursor by codepoints between the previous cursor and the
1178
- // new chunk's start (handles overlap by anchoring at the previous
1179
- // chunk's start, not its end).
1180
- if (utfIdx > utf16Cursor) {
1181
- cpCursor += [...text.slice(utf16Cursor, utfIdx)].length;
1182
- utf16Cursor = utfIdx;
1183
- }
1184
- const cpLen = [...chunkText].length;
1185
- startPos = cpCursor;
1186
- endPos = cpCursor + cpLen;
1187
- }
1188
- else {
1189
- startPos = null;
1190
- endPos = null;
1191
- }
1192
- }
1193
- chunks.push({
1194
- id: '',
1195
- document_id: '',
1196
- chunk_index: chunks.length,
1197
- text: chunkText,
1198
- start_pos: startPos,
1199
- end_pos: endPos,
1200
- start_token: i,
1201
- end_token: i + chunkTokens.length
1202
- });
1203
- }
1204
- return chunks;
1126
+ const segments = splitTextIntoChunks(text, this.encoding, maxTokens, overlap);
1127
+ return segments.map((seg, idx) => ({
1128
+ id: '',
1129
+ document_id: '',
1130
+ chunk_index: idx,
1131
+ text: seg.text,
1132
+ start_pos: seg.start_pos,
1133
+ end_pos: seg.end_pos,
1134
+ start_token: seg.start_token,
1135
+ end_token: seg.end_token
1136
+ }));
1205
1137
  }
1206
1138
  // Generate embeddings using sentence transformers
1207
1139
  // isQuery: true for search queries (adds instruction prefix), false for documents/entities
@@ -1238,6 +1170,46 @@ class RAGKnowledgeGraphManager {
1238
1170
  throw new Error('Embedding model not initialized. The server may still be loading the model — retry in a few seconds.');
1239
1171
  }
1240
1172
  // === NEW SEPARATE TOOLS ===
1173
+ async syncDocumentFromFile(filePath, documentId, options = {}) {
1174
+ if (!this.db)
1175
+ throw new Error('Database not initialized');
1176
+ // 1. Resolve content: raw file verbatim (default) or explicit override.
1177
+ // Content is read on the server and never routed through the model context.
1178
+ const content = options.content !== undefined
1179
+ ? options.content
1180
+ : fsSync.readFileSync(filePath, 'utf-8');
1181
+ const bytes = Buffer.byteLength(content, 'utf-8');
1182
+ // 2. Metadata: default source=path, updated=today (YYYY-MM-DD); caller can override either.
1183
+ const today = new Date().toISOString().slice(0, 10);
1184
+ const metadata = { source: filePath, updated: today, ...(options.metadata || {}) };
1185
+ console.error(`🔄 syncDocumentFromFile: ${documentId} <- ${filePath} (${bytes} bytes)`);
1186
+ // 3. delete -> store -> chunk -> embed, reusing the existing pipeline methods.
1187
+ await this.deleteDocuments(documentId);
1188
+ await this.storeDocument(documentId, content, metadata);
1189
+ const chunkResult = await this.chunkDocument(documentId, options.chunkParams || {});
1190
+ const embedResult = await this.embedChunks(documentId);
1191
+ // 4. Optional explicit entity links (in addition to auto term-matching in embedChunks).
1192
+ let explicitlyLinked;
1193
+ if (options.entityNames && options.entityNames.length > 0) {
1194
+ const linkResult = await this.linkEntitiesToDocument(documentId, options.entityNames);
1195
+ explicitlyLinked = linkResult.linkedEntities;
1196
+ }
1197
+ const linkedEntities = embedResult.linkedEntities ?? 0;
1198
+ // 5. Terse summary only (no chunk text / content echo) to keep caller context flat.
1199
+ const result = {
1200
+ documentId,
1201
+ bytes,
1202
+ chunks: chunkResult.chunks.length,
1203
+ embeddedChunks: embedResult.embeddedChunks,
1204
+ linkedEntities,
1205
+ ...(explicitlyLinked !== undefined ? { explicitlyLinked } : {}),
1206
+ };
1207
+ if (linkedEntities === 0 && explicitlyLinked === undefined) {
1208
+ 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.';
1209
+ }
1210
+ console.error(`✅ syncDocumentFromFile done: ${documentId} (${result.chunks} chunks, ${result.embeddedChunks} embedded, ${linkedEntities} linked)`);
1211
+ return result;
1212
+ }
1241
1213
  async storeDocument(id, content, metadata = {}) {
1242
1214
  if (!this.db)
1243
1215
  throw new Error('Database not initialized');
@@ -2542,6 +2514,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2542
2514
  return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.deleteDocuments(validatedArgs.documentIds), null, 2) }] };
2543
2515
  case "listDocuments":
2544
2516
  return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.listDocuments(validatedArgs.includeMetadata !== false), null, 2) }] };
2517
+ case "syncDocumentFromFile":
2518
+ return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.syncDocumentFromFile(validatedArgs.path, validatedArgs.documentId, {
2519
+ metadata: validatedArgs.metadata,
2520
+ content: validatedArgs.content,
2521
+ entityNames: validatedArgs.entityNames,
2522
+ chunkParams: validatedArgs.chunkParams,
2523
+ }), null, 2) }] };
2545
2524
  // NEW: Entity embedding tools
2546
2525
  case "embedAllEntities":
2547
2526
  return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.embedAllEntities(), null, 2) }] };
@@ -0,0 +1,10 @@
1
+ import type { Tiktoken } from 'tiktoken';
2
+ export interface ChunkSegment {
3
+ text: string;
4
+ start_pos: number | null;
5
+ end_pos: number | null;
6
+ start_token: number;
7
+ end_token: number;
8
+ }
9
+ export declare function trimIncompleteUtf8(bytes: Uint8Array, trimHead: boolean, trimTail: boolean): Uint8Array;
10
+ export declare function chunkText(text: string, encoding: Tiktoken, maxTokens?: number, overlap?: number): ChunkSegment[];
@@ -0,0 +1,104 @@
1
+ // Tokenize and chunk text using a BPE encoder while reporting both token-space
2
+ // and char-space (Unicode codepoint) offsets back into the original string.
3
+ //
4
+ // BPE tokenizers (cl100k_base) split multi-byte UTF-8 sequences across tokens.
5
+ // Slicing token arrays at arbitrary boundaries can leave incomplete UTF-8
6
+ // prefix/suffix bytes, which TextDecoder replaces with U+FFFD (�). We trim the
7
+ // incomplete sequences at chunk boundaries; overlap covers the removed bytes.
8
+ //
9
+ // Each chunk records both token-space offsets (start_token/end_token from the
10
+ // BPE encoder loop) and char-space offsets (start_pos/end_pos into the original
11
+ // text). Char offsets are Unicode codepoint counts — language-neutral, so SQL
12
+ // substr, Python str slicing, and JS [...str] iteration all line up. JS's
13
+ // native UTF-16 indexing differs for supplementary characters (emoji, rare CJK),
14
+ // so the function maintains parallel UTF-16 and codepoint cursors and reports
15
+ // codepoint offsets. On a coincidental indexOf miss the char offsets are NULL.
16
+ //
17
+ // Extracted to a standalone module so publish-time invariant tests can exercise
18
+ // the algorithm directly without booting the full RAG-Memory stack.
19
+ // trimIncompleteUtf8: strip incomplete UTF-8 sequences from the head/tail of a
20
+ // byte buffer produced by decoding an arbitrary token slice. A multi-byte
21
+ // codepoint that begins or ends on the cut edge belongs to an adjacent chunk
22
+ // and must be removed so TextDecoder does not emit U+FFFD. Pass
23
+ // trimHead/trimTail=false to preserve head/tail bytes (first/last chunks).
24
+ export function trimIncompleteUtf8(bytes, trimHead, trimTail) {
25
+ let start = 0;
26
+ let end = bytes.length;
27
+ if (trimHead) {
28
+ while (start < end && (bytes[start] & 0xC0) === 0x80)
29
+ start++;
30
+ }
31
+ if (trimTail) {
32
+ let i = end - 1;
33
+ while (i >= start && (bytes[i] & 0xC0) === 0x80)
34
+ i--;
35
+ if (i >= start) {
36
+ const lead = bytes[i];
37
+ let needed = 1;
38
+ if ((lead & 0x80) === 0)
39
+ needed = 1;
40
+ else if ((lead & 0xE0) === 0xC0)
41
+ needed = 2;
42
+ else if ((lead & 0xF0) === 0xE0)
43
+ needed = 3;
44
+ else if ((lead & 0xF8) === 0xF0)
45
+ needed = 4;
46
+ if (end - i < needed)
47
+ end = i;
48
+ }
49
+ }
50
+ return bytes.subarray(start, end);
51
+ }
52
+ export function chunkText(text, encoding, maxTokens = 800, overlap = 160) {
53
+ const tokens = encoding.encode(text);
54
+ const segments = [];
55
+ let utf16Cursor = 0;
56
+ let cpCursor = 0;
57
+ for (let i = 0; i < tokens.length; i += maxTokens - overlap) {
58
+ const chunkTokens = tokens.slice(i, i + maxTokens);
59
+ const decodedBytes = encoding.decode(chunkTokens);
60
+ const isFirst = i === 0;
61
+ const isLast = i + chunkTokens.length >= tokens.length;
62
+ const safeBytes = trimIncompleteUtf8(decodedBytes, !isFirst, !isLast);
63
+ const chunkTextStr = new TextDecoder('utf-8').decode(safeBytes);
64
+ let startPos;
65
+ let endPos;
66
+ if (isFirst) {
67
+ startPos = 0;
68
+ endPos = [...chunkTextStr].length;
69
+ utf16Cursor = 0;
70
+ cpCursor = 0;
71
+ }
72
+ else if (chunkTextStr.length === 0) {
73
+ startPos = null;
74
+ endPos = null;
75
+ }
76
+ else {
77
+ const utfIdx = text.indexOf(chunkTextStr, utf16Cursor);
78
+ if (utfIdx >= 0) {
79
+ // Advance cpCursor by codepoints between the previous cursor and the
80
+ // new chunk's start (handles overlap by anchoring at the previous
81
+ // chunk's start, not its end).
82
+ if (utfIdx > utf16Cursor) {
83
+ cpCursor += [...text.slice(utf16Cursor, utfIdx)].length;
84
+ utf16Cursor = utfIdx;
85
+ }
86
+ const cpLen = [...chunkTextStr].length;
87
+ startPos = cpCursor;
88
+ endPos = cpCursor + cpLen;
89
+ }
90
+ else {
91
+ startPos = null;
92
+ endPos = null;
93
+ }
94
+ }
95
+ segments.push({
96
+ text: chunkTextStr,
97
+ start_pos: startPos,
98
+ end_pos: endPos,
99
+ start_token: i,
100
+ end_token: i + chunkTokens.length
101
+ });
102
+ }
103
+ return segments;
104
+ }
@@ -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,7 +1,25 @@
1
1
  {
2
2
  "name": "rag-memory-epf-mcp",
3
- "version": "3.3.5",
4
- "description": "MCP server for project-local RAG memory with knowledge graph and multilingual vector search",
3
+ "version": "3.4.0",
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
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "rag",
9
+ "knowledge-graph",
10
+ "vector-search",
11
+ "fts5",
12
+ "sqlite",
13
+ "embeddings",
14
+ "bge-m3",
15
+ "multilingual",
16
+ "korean",
17
+ "claude-code",
18
+ "gemini-cli",
19
+ "codex-cli",
20
+ "memory",
21
+ "agent-memory"
22
+ ],
5
23
  "license": "MIT",
6
24
  "author": "bripin123",
7
25
  "homepage": "https://github.com/bripin123/rag-memory-epf-mcp",
@@ -21,7 +39,10 @@
21
39
  "scripts": {
22
40
  "build": "tsc && shx chmod +x dist/*.js",
23
41
  "prepare": "npm run build",
24
- "watch": "tsc --watch"
42
+ "watch": "tsc --watch",
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"
25
46
  },
26
47
  "dependencies": {
27
48
  "@huggingface/transformers": "^3.5.1",