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 +35 -5
- package/dist/index.js +61 -82
- package/dist/src/chunkText.d.ts +10 -0
- package/dist/src/chunkText.js +104 -0
- package/dist/src/tools/rag-tools.d.ts +2 -0
- package/dist/src/tools/rag-tools.js +88 -0
- package/dist/src/tools/tool-registry.d.ts +1 -0
- package/package.json +24 -3
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
|
-
- **
|
|
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 (
|
|
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
|
-
│ │ └──
|
|
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
|
|
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
|
-
|
|
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
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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",
|