rag-memory-epf-mcp 3.3.4 → 3.3.6

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) {
@@ -1139,59 +1114,26 @@ class RAGKnowledgeGraphManager {
1139
1114
  //
1140
1115
  // Each chunk records both token-space offsets (start_token/end_token from the
1141
1116
  // BPE encoder loop) and char-space offsets (start_pos/end_pos into the original
1142
- // text). Char offsets are recovered with indexOf using a running cursor at the
1143
- // previous chunk's start, so substr(text, start_pos, end_pos - start_pos)
1144
- // equals chunk.text. On a coincidental indexOf miss the char offsets are NULL.
1117
+ // text). Char offsets are Unicode codepoint counts language-neutral, so SQL
1118
+ // substr, Python str slicing, and JS [...str] iteration all line up. JS's
1119
+ // native UTF-16 indexing differs for supplementary characters (emoji, rare
1120
+ // CJK), so the function maintains parallel UTF-16 and codepoint cursors and
1121
+ // reports codepoint offsets. On a coincidental indexOf miss the char offsets
1122
+ // are NULL.
1145
1123
  chunkText(text, maxTokens = 800, overlap = 160) {
1146
1124
  if (!this.encoding)
1147
1125
  throw new Error('Tokenizer not initialized');
1148
- const tokens = this.encoding.encode(text);
1149
- const chunks = [];
1150
- let charCursor = 0;
1151
- for (let i = 0; i < tokens.length; i += maxTokens - overlap) {
1152
- const chunkTokens = tokens.slice(i, i + maxTokens);
1153
- const decodedBytes = this.encoding.decode(chunkTokens);
1154
- const isFirst = i === 0;
1155
- const isLast = i + chunkTokens.length >= tokens.length;
1156
- const safeBytes = trimIncompleteUtf8(decodedBytes, !isFirst, !isLast);
1157
- const chunkText = new TextDecoder('utf-8').decode(safeBytes);
1158
- // Recover char-space offsets. First chunk is anchored at 0 because
1159
- // trimIncompleteUtf8 leaves the leading bytes intact when isFirst is true.
1160
- let startPos;
1161
- let endPos;
1162
- if (isFirst) {
1163
- startPos = 0;
1164
- endPos = chunkText.length;
1165
- charCursor = 0;
1166
- }
1167
- else if (chunkText.length === 0) {
1168
- startPos = null;
1169
- endPos = null;
1170
- }
1171
- else {
1172
- const idx = text.indexOf(chunkText, charCursor);
1173
- if (idx >= 0) {
1174
- startPos = idx;
1175
- endPos = idx + chunkText.length;
1176
- charCursor = idx;
1177
- }
1178
- else {
1179
- startPos = null;
1180
- endPos = null;
1181
- }
1182
- }
1183
- chunks.push({
1184
- id: '',
1185
- document_id: '',
1186
- chunk_index: chunks.length,
1187
- text: chunkText,
1188
- start_pos: startPos,
1189
- end_pos: endPos,
1190
- start_token: i,
1191
- end_token: i + chunkTokens.length
1192
- });
1193
- }
1194
- 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
+ }));
1195
1137
  }
1196
1138
  // Generate embeddings using sentence transformers
1197
1139
  // isQuery: true for search queries (adds instruction prefix), false for documents/entities
@@ -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
+ }
@@ -520,5 +520,55 @@ export const migrations = [
520
520
  // start_token/end_token columns remain (no DROP COLUMN); they will be
521
521
  // ignored by older code.
522
522
  }
523
+ },
524
+ // Migration 11: Convert chunk_metadata.start_pos/end_pos from JS UTF-16 code
525
+ // unit indices to Unicode codepoint indices. v3.3.4 stored offsets in JS's
526
+ // native UTF-16 unit space, which mismatches SQL substr/length and Python
527
+ // string indexing for any document containing supplementary characters
528
+ // (emoji, rare CJK). Codepoints are language-neutral. Walks each document's
529
+ // chunks in chunk_index order, locating each chunk in the source via UTF-16
530
+ // indexOf and counting codepoints between cursor positions to derive the
531
+ // codepoint offsets. Idempotent — chunks where indexOf misses keep their
532
+ // existing values.
533
+ {
534
+ version: 11,
535
+ description: 'Convert chunk_metadata.start_pos/end_pos to Unicode codepoint indices',
536
+ up: (db) => {
537
+ const docRows = db.prepare(`SELECT DISTINCT document_id FROM chunk_metadata
538
+ WHERE chunk_type='document' AND document_id IS NOT NULL
539
+ AND start_pos IS NOT NULL AND end_pos IS NOT NULL`).all();
540
+ const getContent = db.prepare(`SELECT content FROM documents WHERE id = ?`);
541
+ const getChunks = db.prepare(`SELECT rowid, text FROM chunk_metadata
542
+ WHERE document_id=? AND chunk_type='document'
543
+ AND start_pos IS NOT NULL AND end_pos IS NOT NULL
544
+ ORDER BY chunk_index ASC`);
545
+ const upd = db.prepare(`UPDATE chunk_metadata SET start_pos=?, end_pos=? WHERE rowid=?`);
546
+ for (const { document_id } of docRows) {
547
+ const doc = getContent.get(document_id);
548
+ if (!doc)
549
+ continue;
550
+ const content = doc.content;
551
+ const chunks = getChunks.all(document_id);
552
+ let utf16Cursor = 0;
553
+ let cpCursor = 0;
554
+ for (const c of chunks) {
555
+ if (!c.text)
556
+ continue;
557
+ const utfIdx = content.indexOf(c.text, utf16Cursor);
558
+ if (utfIdx < 0)
559
+ continue; // miss: leave existing
560
+ if (utfIdx > utf16Cursor) {
561
+ cpCursor += [...content.slice(utf16Cursor, utfIdx)].length;
562
+ utf16Cursor = utfIdx;
563
+ }
564
+ const cpLen = [...c.text].length;
565
+ upd.run(cpCursor, cpCursor + cpLen, c.rowid);
566
+ }
567
+ }
568
+ },
569
+ down: (_db) => {
570
+ // No clean reversal: would need the original document content to recompute
571
+ // UTF-16 indices, and we never recorded which chunks were touched. No-op.
572
+ }
523
573
  }
524
574
  ];
package/package.json CHANGED
@@ -1,7 +1,25 @@
1
1
  {
2
2
  "name": "rag-memory-epf-mcp",
3
- "version": "3.3.4",
4
- "description": "MCP server for project-local RAG memory with knowledge graph and multilingual vector search",
3
+ "version": "3.3.6",
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",