mcp-rag-memory 2.4.1 → 2.7.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.
Files changed (40) hide show
  1. package/.env.example +3 -1
  2. package/LICENSE +21 -0
  3. package/README.md +151 -16
  4. package/dist/cli.js +102 -34
  5. package/dist/db/database.d.ts +10 -2
  6. package/dist/db/database.d.ts.map +1 -1
  7. package/dist/db/database.js +119 -18
  8. package/dist/db/database.js.map +1 -1
  9. package/dist/env.js +32 -12
  10. package/dist/mcp/rag-server.js +140 -39
  11. package/dist/mcp/rag-server.js.map +1 -1
  12. package/dist/memory/memory.d.ts +5 -1
  13. package/dist/memory/memory.d.ts.map +1 -1
  14. package/dist/memory/memory.js +245 -101
  15. package/dist/memory/memory.js.map +1 -1
  16. package/dist/rag/chunker.d.ts +7 -0
  17. package/dist/rag/chunker.d.ts.map +1 -1
  18. package/dist/rag/chunker.js +119 -11
  19. package/dist/rag/chunker.js.map +1 -1
  20. package/dist/rag/embeddings.d.ts +25 -0
  21. package/dist/rag/embeddings.d.ts.map +1 -1
  22. package/dist/rag/embeddings.js +56 -4
  23. package/dist/rag/embeddings.js.map +1 -1
  24. package/dist/rag/pipeline.d.ts +19 -2
  25. package/dist/rag/pipeline.d.ts.map +1 -1
  26. package/dist/rag/pipeline.js +212 -39
  27. package/dist/rag/pipeline.js.map +1 -1
  28. package/dist/rag/reindex.d.ts +27 -0
  29. package/dist/rag/reindex.d.ts.map +1 -0
  30. package/dist/rag/reindex.js +138 -0
  31. package/dist/rag/reindex.js.map +1 -0
  32. package/dist/rag/vector-search.d.ts +26 -1
  33. package/dist/rag/vector-search.d.ts.map +1 -1
  34. package/dist/rag/vector-search.js +207 -65
  35. package/dist/rag/vector-search.js.map +1 -1
  36. package/dist/session/transcript.d.ts +15 -5
  37. package/dist/session/transcript.d.ts.map +1 -1
  38. package/dist/session/transcript.js +152 -25
  39. package/dist/session/transcript.js.map +1 -1
  40. package/package.json +6 -3
@@ -17,18 +17,59 @@ export function getDB() {
17
17
  return _db;
18
18
  ensureDir();
19
19
  const db = new DatabaseSync(DB_PATH);
20
- db.exec("PRAGMA journal_mode = WAL;");
21
- db.exec("PRAGMA synchronous = NORMAL;");
22
- db.exec("PRAGMA foreign_keys = ON;");
20
+ try {
21
+ // busy_timeout first: every later statement should inherit it.
22
+ db.exec("PRAGMA busy_timeout = 5000;");
23
+ db.exec("PRAGMA journal_mode = WAL;");
24
+ db.exec("PRAGMA synchronous = NORMAL;");
25
+ db.exec("PRAGMA foreign_keys = ON;");
26
+ // Migrate BEFORE caching the handle: a failed migration must surface and be
27
+ // retried on the next call, never leave a half-migrated connection cached.
28
+ migrate(db);
29
+ }
30
+ catch (e) {
31
+ // The handle is NOT cached on failure, so without this close every failed
32
+ // init leaked a file handle — reachable whenever a concurrent writer holds
33
+ // the lock, and it blocked later cleanup of the store directory on Windows.
34
+ try {
35
+ db.close();
36
+ }
37
+ catch {
38
+ /* already closed */
39
+ }
40
+ throw e;
41
+ }
23
42
  _db = db;
24
- migrate(db);
25
43
  return db;
26
44
  }
27
45
  function columnExists(db, table, column) {
46
+ // PRAGMA cannot take a bound parameter, so the identifier is interpolated.
47
+ // Only ever called with literals; assert it so a future caller cannot inject.
48
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table))
49
+ throw new Error(`Unsafe table name: ${table}`);
28
50
  const cols = db.prepare(`PRAGMA table_info(${table})`).all();
29
51
  return cols.some((c) => c.name === column);
30
52
  }
31
53
  function migrate(db) {
54
+ // One IMMEDIATE transaction for the whole schema step: two processes opening
55
+ // the same fresh store (CLI + MCP server + plugin) must not race and end up
56
+ // with half-applied DDL or a "duplicate column" failure.
57
+ db.exec("BEGIN IMMEDIATE;");
58
+ try {
59
+ createSchema(db);
60
+ db.exec("COMMIT;");
61
+ }
62
+ catch (e) {
63
+ try {
64
+ db.exec("ROLLBACK;");
65
+ }
66
+ catch {
67
+ /* transaction already closed */
68
+ }
69
+ throw e;
70
+ }
71
+ }
72
+ function createSchema(db) {
32
73
  db.exec(`
33
74
  CREATE TABLE IF NOT EXISTS documents (
34
75
  id TEXT PRIMARY KEY,
@@ -51,6 +92,10 @@ function migrate(db) {
51
92
  created_at INTEGER NOT NULL
52
93
  );
53
94
  CREATE INDEX IF NOT EXISTS idx_chunks_doc_id ON chunks(doc_id);
95
+ CREATE INDEX IF NOT EXISTS idx_documents_updated ON documents(updated_at DESC);
96
+ -- Filtered searches resolve doc ids via source/doc_id first; without this
97
+ -- every rag_search(source=...) scanned the whole documents table.
98
+ CREATE INDEX IF NOT EXISTS idx_documents_source ON documents(source);
54
99
 
55
100
  CREATE TABLE IF NOT EXISTS memories (
56
101
  id TEXT PRIMARY KEY,
@@ -66,6 +111,7 @@ function migrate(db) {
66
111
  );
67
112
  CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
68
113
  CREATE INDEX IF NOT EXISTS idx_memories_prune ON memories(importance, recall_count, created_at);
114
+ CREATE INDEX IF NOT EXISTS idx_memories_updated ON memories(updated_at DESC);
69
115
 
70
116
  CREATE TABLE IF NOT EXISTS meta (
71
117
  key TEXT PRIMARY KEY,
@@ -84,17 +130,24 @@ function migrate(db) {
84
130
  const ftsRow = db
85
131
  .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'chunks_fts'")
86
132
  .get();
87
- if (!ftsRow) {
88
- db.exec("CREATE VIRTUAL TABLE chunks_fts USING fts5(content, tokenize = 'trigram');");
89
- }
90
- else if (!ftsRow.sql.includes("trigram")) {
133
+ if (ftsRow) {
91
134
  // Existing store built with 'unicode61' — recreate with trigram and backfill.
92
- db.exec("DROP TABLE chunks_fts;");
93
- db.exec("CREATE VIRTUAL TABLE chunks_fts USING fts5(content, tokenize = 'trigram');");
94
- db.exec(`INSERT INTO chunks_fts(rowid, content)
95
- SELECT rowid, content FROM chunks`);
135
+ if (!(ftsRow.sql ?? "").includes("trigram"))
136
+ rebuildFts(db);
137
+ }
138
+ else {
139
+ // First run with this schema (or a store whose FTS table was dropped):
140
+ // create AND backfill, otherwise existing chunks silently lose keyword search
141
+ // forever — the next boot sees "trigram" and would skip the rebuild.
142
+ rebuildFts(db);
96
143
  }
97
144
  }
145
+ /** Runs inside the migrate() transaction. */
146
+ function rebuildFts(db) {
147
+ db.exec("DROP TABLE IF EXISTS chunks_fts;");
148
+ db.exec("CREATE VIRTUAL TABLE chunks_fts USING fts5(content, tokenize = 'trigram');");
149
+ db.exec("INSERT INTO chunks_fts(rowid, content) SELECT rowid, content FROM chunks;");
150
+ }
98
151
  /** SHA-256 hex of raw content — used for document dedup. */
99
152
  export function contentHash(content) {
100
153
  return createHash("sha256").update(content, "utf8").digest("hex");
@@ -102,13 +155,13 @@ export function contentHash(content) {
102
155
  export function newId() {
103
156
  return randomUUID();
104
157
  }
105
- /** Serialize a Float64Array embedding to a SQLite BLOB. */
158
+ /** Serialize a Float64Array embedding to a SQLite BLOB (view-safe). */
106
159
  export function packVector(v) {
107
- return new Uint8Array(v.buffer.slice(0));
160
+ return new Uint8Array(v.buffer.slice(v.byteOffset, v.byteOffset + v.byteLength));
108
161
  }
109
- /** Deserialize a BLOB back into a Float64Array. */
162
+ /** Deserialize a BLOB back into a Float64Array. Returns null on absent/partial data. */
110
163
  export function unpackVector(blob) {
111
- if (!blob || blob.byteLength === 0)
164
+ if (!blob || blob.byteLength === 0 || blob.byteLength % 8 !== 0)
112
165
  return null;
113
166
  return new Float64Array(blob.buffer.slice(blob.byteOffset, blob.byteOffset + blob.byteLength));
114
167
  }
@@ -118,24 +171,41 @@ export function nowMs() {
118
171
  export function closeDB() {
119
172
  _db?.close();
120
173
  _db = null;
174
+ cachedDim = null;
121
175
  }
122
176
  /* ------------------------------------------------------------------ */
123
177
  /* Embedding dimension metadata */
124
178
  /* ------------------------------------------------------------------ */
179
+ /** Cached embed_dim: locked on first write, so it is read once per process. */
180
+ let cachedDim = null;
125
181
  export function getEmbedDim() {
126
- const r = getDB().prepare("SELECT value FROM meta WHERE key = 'embed_dim'").get();
127
- return r ? Number(r.value) : 0;
182
+ if (cachedDim === null) {
183
+ const r = getDB().prepare("SELECT value FROM meta WHERE key = 'embed_dim'").get();
184
+ const n = Number(r?.value);
185
+ // A corrupt row must not cache NaN: it would wedge this process with a
186
+ // misleading "dimension mismatch" until restart.
187
+ cachedDim = Number.isInteger(n) && n > 0 ? n : 0;
188
+ }
189
+ return cachedDim;
128
190
  }
129
191
  export function setEmbedMeta(dim, model) {
130
192
  const db = getDB();
131
193
  db.prepare("INSERT INTO meta(key, value) VALUES ('embed_dim', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(String(dim));
132
194
  db.prepare("INSERT INTO meta(key, value) VALUES ('embed_model', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(model);
195
+ cachedDim = dim;
196
+ // A completed migration restores the store to a coherent state.
197
+ setEmbedState("ready");
133
198
  }
134
199
  /**
135
200
  * Lock the store to one embedding dimension. Call after producing a vector.
136
201
  * Prevents silently mixing vectors of different models/providers.
137
202
  */
138
203
  export function ensureDim(vec, model) {
204
+ // A half-migrated store mixes old and new vectors: cosine across two spaces
205
+ // is meaningless and every search silently returns noise. Blocking here stops
206
+ // it at the one chokepoint every search and ingest already passes through,
207
+ // rather than letting a partial reindex look like a working store.
208
+ assertEmbedStateReady();
139
209
  const current = getEmbedDim();
140
210
  if (current === 0) {
141
211
  setEmbedMeta(vec.length, model);
@@ -146,9 +216,40 @@ export function ensureDim(vec, model) {
146
216
  `new vectors are ${vec.length} (model: ${model}). ` +
147
217
  `Use a consistent EMBEDDING_PROVIDER or wipe ${STORAGE_DIR}.`);
148
218
  }
219
+ // Same-dimension model swaps are the dangerous case: blending two vector
220
+ // spaces degrades search to noise with no error anywhere, and the only clue
221
+ // was a stale model name inside a dimension-mismatch message that never fired.
222
+ const currentModel = getEmbedModel();
223
+ if (currentModel && currentModel !== model) {
224
+ throw new Error(`Embedding model mismatch: DB stores ${currentModel}, requested ${model} (both ${current}-dim). ` +
225
+ `Point RAG_DB_DIR at a fresh store or re-ingest with a consistent EMBEDDING_MODEL. ` +
226
+ `Current store: ${STORAGE_DIR}.`);
227
+ }
149
228
  }
150
229
  export function getEmbedModel() {
151
230
  const r = getDB().prepare("SELECT value FROM meta WHERE key = 'embed_model'").get();
152
231
  return r ? r.value : "";
153
232
  }
233
+ /**
234
+ * `embed_state` is '' (or 'ready') for a coherent store and 'reindexing' while a
235
+ * vector-space migration is in flight.
236
+ */
237
+ export function getEmbedState() {
238
+ const r = getDB().prepare("SELECT value FROM meta WHERE key = 'embed_state'").get();
239
+ return r ? r.value : "";
240
+ }
241
+ export function setEmbedState(state) {
242
+ getDB()
243
+ .prepare("INSERT INTO meta(key, value) VALUES('embed_state', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
244
+ .run(state);
245
+ }
246
+ /** Throws when the store is mid-reindex. Called from ensureDim, the shared gate. */
247
+ export function assertEmbedStateReady() {
248
+ const state = getEmbedState();
249
+ if (state !== "" && state !== "ready") {
250
+ throw new Error(`Store is mid-reindex (embed_state=${state}): vectors from two embedding models are ` +
251
+ `mixed and search results would be meaningless. Run 'reindex' again to finish the ` +
252
+ `migration. Current store: ${STORAGE_DIR}.`);
253
+ }
254
+ }
154
255
  //# sourceMappingURL=database.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"database.js","sourceRoot":"","sources":["../../src/db/database.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"database.js","sourceRoot":"","sources":["../../src/db/database.ts"],"names":[],"mappings":"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"}
package/dist/env.js CHANGED
@@ -6,38 +6,58 @@
6
6
  * config (MCP clients, CI, shell) always wins. Runs synchronously at import —
7
7
  * must be imported before any module that reads `process.env.*` at load time.
8
8
  * Supports `#` comments, `export KEY=` prefixes, and single/double-quoted values.
9
+ *
10
+ * Only this package's own knobs are loadable: an untrusted repo's `.env` must
11
+ * not be able to inject arbitrary process.env entries into the host session.
9
12
  */
10
13
  import * as fs from "fs";
11
14
  import * as path from "path";
15
+ /** Prefixes/names this server actually reads. */
16
+ const LOADABLE = /^(RAG_[A-Z0-9_]+|OPENCODE_[A-Z0-9_]+|EMBEDDING_[A-Z0-9_]+|SEARCH_MODE)$/;
17
+ const VALID_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
18
+ /** Strip one layer of matching quotes plus any trailing `# comment`. */
19
+ function unquote(value) {
20
+ const q = value[0];
21
+ if (q === '"' || q === "'") {
22
+ const close = value.indexOf(q, 1);
23
+ if (close > 0) {
24
+ const rest = value.slice(close + 1).trim();
25
+ if (rest === "" || rest.startsWith("#"))
26
+ return value.slice(1, close);
27
+ }
28
+ }
29
+ const hash = value.indexOf(" #");
30
+ return hash >= 0 ? value.slice(0, hash).trimEnd() : value;
31
+ }
12
32
  export function loadDotenv(file) {
13
33
  let raw;
14
34
  try {
15
35
  raw = fs.readFileSync(file, "utf8");
16
36
  }
17
- catch {
37
+ catch (e) {
38
+ if (process.env.RAG_ENV_FILE) {
39
+ process.stderr.write(`env: cannot read RAG_ENV_FILE ${file} (${e.message})\n`);
40
+ }
18
41
  return;
19
42
  }
20
43
  for (const line of raw.split(/\r?\n/)) {
21
44
  let t = line.trim();
22
45
  if (!t || t.startsWith("#"))
23
46
  continue;
24
- if (t.startsWith("export "))
47
+ if (t.startsWith("export ") || t.startsWith("export\t"))
25
48
  t = t.slice(7).trimStart();
26
49
  const eq = t.indexOf("=");
27
50
  if (eq <= 0)
28
51
  continue;
29
52
  const key = t.slice(0, eq).trim();
30
- let value = t.slice(eq + 1).trim();
31
- // Strip trailing inline comments for unquoted values (e.g. `KEY=val # note`).
32
- if (!value.startsWith('"') && !value.startsWith("'")) {
33
- const hash = value.indexOf(" #");
34
- if (hash >= 0)
35
- value = value.slice(0, hash).trimEnd();
36
- }
37
- if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
38
- value = value.slice(1, -1);
53
+ if (!VALID_KEY.test(key))
54
+ continue;
55
+ if (!LOADABLE.test(key)) {
56
+ process.stderr.write(`env: ignoring ${key} (not an mcp-rag-memory variable)\n`);
57
+ continue;
39
58
  }
40
- if (key && process.env[key] === undefined)
59
+ const value = unquote(t.slice(eq + 1).trim());
60
+ if (process.env[key] === undefined)
41
61
  process.env[key] = value;
42
62
  }
43
63
  }
@@ -4,13 +4,32 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { z } from "zod";
5
5
  import * as path from "path";
6
6
  import { createRequire } from "node:module";
7
- import { STORAGE_DIR } from "../db/database.js";
7
+ import { STORAGE_DIR, closeDB, getEmbedState } from "../db/database.js";
8
8
  import { embeddingInfo } from "../rag/embeddings.js";
9
- import { ingestText, ingestFile, ingestDirectory, searchDocs, retrieve, listDocuments, deleteDocument, documentStats, } from "../rag/pipeline.js";
9
+ import { reindex } from "../rag/reindex.js";
10
+ import { ingestText, ingestFile, ingestDirectory, searchDocs, retrieve, listDocuments, deleteDocument, documentStats, MAX_INGEST_CHARS, } from "../rag/pipeline.js";
10
11
  import { ingestSession as syncSession, ingestLatest as syncLatest, } from "../session/transcript.js";
11
12
  import { remember, recall, listMemories, getMemory, updateMemory, forget, consolidate, memoryStats, contextPrompt, MEMORY_TYPES, } from "../memory/memory.js";
12
13
  const require = createRequire(import.meta.url);
13
14
  const serverVersion = require("../../package.json").version;
15
+ /**
16
+ * Input bounds for every tool schema.
17
+ *
18
+ * These were all unbounded `z.string().min(1)`, which meant a single tool call
19
+ * could push a megabyte of text straight into the chunker and embedder (a
20
+ * synchronous CPU spin that blocks the whole stdio server), or a path long
21
+ * enough to be a denial of service. The caps sit above any realistic input and
22
+ * well below anything that would stall the process.
23
+ */
24
+ const MAX_ID = 200;
25
+ const MAX_QUERY = 2_000;
26
+ const MAX_PATH = 4_096;
27
+ const MAX_TITLE = 500;
28
+ const MAX_TAG = 64;
29
+ const MAX_TAGS = 64;
30
+ const MAX_TOPIC = 500;
31
+ /** A memory is a self-contained statement, not a document. */
32
+ const MAX_MEMORY_CONTENT = 20_000;
14
33
  const server = new McpServer({
15
34
  name: "rag-memory-server",
16
35
  version: serverVersion,
@@ -24,25 +43,65 @@ server.registerTool("system_stats", {
24
43
  }, async () => {
25
44
  const docs = documentStats();
26
45
  const mem = memoryStats();
46
+ // Surface the embed_state too: a store stuck mid-reindex refuses every search,
47
+ // and system_stats is the first thing anyone calls when a tool starts failing.
48
+ const state = getEmbedState();
27
49
  return {
28
50
  content: [
29
51
  {
30
52
  type: "text",
31
- text: JSON.stringify({ dbDir: STORAGE_DIR, embedding: embeddingInfo(), documents: docs, memories: mem }, null, 2),
53
+ text: JSON.stringify({
54
+ dbDir: STORAGE_DIR,
55
+ embedding: embeddingInfo(),
56
+ storeState: state === "" || state === "ready" ? "ready" : state,
57
+ ...(state !== "" && state !== "ready"
58
+ ? { remediation: "Run 'rag_reindex' to finish the interrupted migration." }
59
+ : {}),
60
+ documents: docs,
61
+ memories: mem,
62
+ }, null, 2),
32
63
  },
33
64
  ],
34
65
  };
35
66
  });
36
67
  /* ------------------------------------------------------------------ */
68
+ /* Maintenance */
69
+ /* ------------------------------------------------------------------ */
70
+ server.registerTool("rag_reindex", {
71
+ description: "Re-embed every stored vector for the currently configured EMBEDDING_PROVIDER/MODEL. " +
72
+ "Use after changing the embedding model or dimension to migrate the existing store in place " +
73
+ "instead of wiping and re-ingesting. Searches are blocked while it runs; re-run to resume " +
74
+ "after an interruption.",
75
+ inputSchema: {
76
+ batch_size: z
77
+ .number()
78
+ .int()
79
+ .min(1)
80
+ .max(200)
81
+ .optional()
82
+ .default(32)
83
+ .describe("Chunks embedded per transaction. Lower uses less memory, higher is faster."),
84
+ },
85
+ }, async (args) => {
86
+ const res = await reindex({
87
+ batchSize: args.batch_size ?? 32,
88
+ onProgress: (p) => {
89
+ if (p.total > 0)
90
+ process.stderr.write(`reindex ${p.phase}: ${p.done}/${p.total}\n`);
91
+ },
92
+ });
93
+ return { content: [{ type: "text", text: JSON.stringify(res, null, 2) }] };
94
+ });
95
+ /* ------------------------------------------------------------------ */
37
96
  /* RAG: ingest */
38
97
  /* ------------------------------------------------------------------ */
39
98
  server.registerTool("rag_ingest_text", {
40
99
  description: "Store raw text as a knowledge document (chunked + embedded for later retrieval)",
41
100
  inputSchema: {
42
- content: z.string().min(1),
43
- title: z.string().describe("Document title"),
44
- contentType: z.string().optional().describe("e.g. markdown, typescript, text, json"),
45
- metadata: z.record(z.string(), z.unknown()).optional(),
101
+ content: z.string().min(1).max(MAX_INGEST_CHARS),
102
+ title: z.string().max(MAX_TITLE).describe("Document title"),
103
+ contentType: z.string().max(64).optional().describe("e.g. markdown, typescript, text, json"),
104
+ metadata: z.record(z.string().max(MAX_TAG), z.unknown()).optional(),
46
105
  },
47
106
  }, async (args) => {
48
107
  const res = await ingestText(args.content, args.title, {
@@ -52,10 +111,10 @@ server.registerTool("rag_ingest_text", {
52
111
  return { content: [{ type: "text", text: JSON.stringify(res, null, 2) }] };
53
112
  });
54
113
  server.registerTool("rag_ingest_file", {
55
- description: "Read a file and store it as a knowledge document",
114
+ description: "Read a file and store it as a knowledge document. Honours RAG_ALLOWED_DIRS when set.",
56
115
  inputSchema: {
57
- path: z.string().describe("Absolute path to the file"),
58
- metadata: z.record(z.string(), z.unknown()).optional(),
116
+ path: z.string().min(1).max(MAX_PATH).describe("Absolute path to the file"),
117
+ metadata: z.record(z.string().max(MAX_TAG), z.unknown()).optional(),
59
118
  },
60
119
  }, async (args) => {
61
120
  const res = await ingestFile(args.path, { metadata: args.metadata });
@@ -64,9 +123,9 @@ server.registerTool("rag_ingest_file", {
64
123
  server.registerTool("rag_ingest_dir", {
65
124
  description: "Recursively ingest supported source files from a directory",
66
125
  inputSchema: {
67
- path: z.string().describe("Absolute path to the directory"),
126
+ path: z.string().min(1).max(MAX_PATH).describe("Absolute path to the directory"),
68
127
  recursive: z.boolean().optional().default(true),
69
- extensions: z.array(z.string()).optional().describe("File extensions to include, e.g. ['.ts', '.md']"),
128
+ extensions: z.array(z.string().min(1).max(16)).min(1).max(64).optional().describe("File extensions to include, e.g. ['.ts', '.md']"),
70
129
  },
71
130
  }, async (args) => {
72
131
  const res = await ingestDirectory(args.path, { recursive: args.recursive, extensions: args.extensions });
@@ -76,19 +135,20 @@ server.registerTool("rag_ingest_dir", {
76
135
  /* RAG: search / retrieve */
77
136
  /* ------------------------------------------------------------------ */
78
137
  server.registerTool("rag_search", {
79
- description: "Hybrid semantic + keyword search over stored documents. Returns chunks ranked by relevance with scores.",
138
+ description: "Hybrid semantic + keyword search over stored documents. Returns chunks ranked by relevance with scores. Set explain to see per-leg scores (vector cosine, keyword BM25, RRF contribution) and diagnose ranking.",
80
139
  inputSchema: {
81
- query: z.string().min(1),
140
+ query: z.string().min(1).max(MAX_QUERY),
82
141
  top_k: z.number().int().min(1).max(50).optional().default(10),
83
142
  min_score: z.number().min(0).max(1).optional().default(0.08),
84
- doc_id: z.string().optional().describe("Only search within this document"),
85
- source: z.string().optional().describe("Only search documents with this source (e.g. 'opencode-db', 'session-log')"),
143
+ doc_id: z.string().max(MAX_ID).optional().describe("Only search within this document"),
144
+ source: z.string().max(MAX_ID).optional().describe("Only search documents with this source (e.g. 'opencode-db', 'session-log')"),
145
+ explain: z.boolean().optional().default(false).describe("Include per-leg vector/keyword/RRF scores on every hit"),
86
146
  },
87
147
  }, async (args) => {
88
148
  const hits = await searchDocs(args.query, args.top_k ?? 10, args.min_score ?? 0.08, {
89
149
  docId: args.doc_id,
90
150
  source: args.source,
91
- });
151
+ }, args.explain ?? false);
92
152
  return {
93
153
  content: [{ type: "text", text: JSON.stringify(hits, null, 2) }],
94
154
  };
@@ -96,11 +156,11 @@ server.registerTool("rag_search", {
96
156
  server.registerTool("rag_retrieve", {
97
157
  description: "Retrieve relevant context chunks as a ready-to-inject context block for the LLM. Prefer this over rag_search when the result will be used as context.",
98
158
  inputSchema: {
99
- query: z.string().min(1),
159
+ query: z.string().min(1).max(MAX_QUERY),
100
160
  top_k: z.number().int().min(1).max(20).optional().default(6),
101
161
  min_score: z.number().min(0).max(1).optional().default(0.08),
102
- doc_id: z.string().optional().describe("Only retrieve within this document"),
103
- source: z.string().optional().describe("Only retrieve documents with this source"),
162
+ doc_id: z.string().max(MAX_ID).optional().describe("Only retrieve within this document"),
163
+ source: z.string().max(MAX_ID).optional().describe("Only retrieve documents with this source"),
104
164
  },
105
165
  }, async (args) => {
106
166
  const res = await retrieve(args.query, args.top_k ?? 6, args.min_score ?? 0.08, {
@@ -124,10 +184,13 @@ server.registerTool("rag_retrieve", {
124
184
  /* RAG: document management */
125
185
  /* ------------------------------------------------------------------ */
126
186
  server.registerTool("rag_list_documents", {
127
- description: "List all stored knowledge documents",
128
- inputSchema: {},
129
- }, async () => {
130
- const docs = listDocuments();
187
+ description: "List stored knowledge documents (newest first, paginated)",
188
+ inputSchema: {
189
+ limit: z.number().int().min(1).max(1000).optional().default(100).describe("Max documents to return"),
190
+ offset: z.number().int().min(0).optional().default(0).describe("Skip this many documents"),
191
+ },
192
+ }, async (args) => {
193
+ const docs = listDocuments({ limit: args.limit, offset: args.offset });
131
194
  return { content: [{ type: "text", text: JSON.stringify(docs, null, 2) }] };
132
195
  });
133
196
  server.registerTool("rag_document_stats", {
@@ -139,7 +202,7 @@ server.registerTool("rag_document_stats", {
139
202
  server.registerTool("rag_delete_document", {
140
203
  description: "Delete a document and all its chunks by ID",
141
204
  inputSchema: {
142
- doc_id: z.string().min(1),
205
+ doc_id: z.string().min(1).max(MAX_ID),
143
206
  },
144
207
  }, async (args) => {
145
208
  const res = deleteDocument(args.doc_id);
@@ -151,23 +214,23 @@ server.registerTool("rag_delete_document", {
151
214
  server.registerTool("rag_sync_session", {
152
215
  description: "Ingest an opencode session's user inputs (or the latest active session) into the RAG store as searchable documents. Use with the session id from opencode, or omit session_id and pass directory to auto-pick the most recent session in that workspace.",
153
216
  inputSchema: {
154
- session_id: z.string().optional().describe("opencode session id; omit to use the latest active session"),
155
- directory: z.string().optional().describe("Workspace path used to pick the latest session when session_id is omitted"),
156
- limit: z.number().int().min(1).optional().describe("Only ingest the first N user messages"),
217
+ session_id: z.string().min(1).max(MAX_ID).optional().describe("opencode session id; omit to use the latest active session"),
218
+ directory: z.string().min(1).max(MAX_PATH).optional().describe("Workspace path used to pick the latest session when session_id is omitted (defaults to the server's working directory)"),
219
+ limit: z.number().int().min(1).max(2000).optional().describe("Only ingest the first N user messages"),
157
220
  },
158
221
  }, async (args) => {
159
222
  const res = args.session_id
160
223
  ? await syncSession(args.session_id, { limit: args.limit })
161
- : await syncLatest({ limit: args.limit, directory: args.directory });
224
+ : await syncLatest({ limit: args.limit, directory: args.directory ?? process.cwd() });
162
225
  return { content: [{ type: "text", text: JSON.stringify(res, null, 2) }] };
163
226
  });
164
227
  server.registerTool("memory_remember", {
165
228
  description: "Store a piece of long-term memory (fact, decision, preference, instruction, task, insight, conversation). Use this to persist things that should be remembered across sessions.",
166
229
  inputSchema: {
167
- content: z.string().min(1).describe("The memory content, written as a self-contained statement"),
230
+ content: z.string().min(1).max(MAX_MEMORY_CONTENT).describe("The memory content, written as a self-contained statement"),
168
231
  type: z.enum(MEMORY_TYPES).optional().default("fact"),
169
232
  importance: z.number().min(0).max(1).optional().default(0.5).describe("0..1, higher = keep longer"),
170
- tags: z.array(z.string()).optional().describe("Tags for filtering"),
233
+ tags: z.array(z.string().max(MAX_TAG)).max(MAX_TAGS).optional().describe("Tags for filtering"),
171
234
  },
172
235
  }, async (args) => {
173
236
  const rec = await remember({
@@ -181,7 +244,7 @@ server.registerTool("memory_remember", {
181
244
  server.registerTool("memory_recall", {
182
245
  description: "Semantically search long-term memories relevant to a query. This is how you remember past context.",
183
246
  inputSchema: {
184
- query: z.string().min(1),
247
+ query: z.string().min(1).max(MAX_QUERY),
185
248
  top_k: z.number().int().min(1).max(30).optional().default(8),
186
249
  min_score: z.number().min(0).max(1).optional().default(0.1),
187
250
  },
@@ -193,7 +256,7 @@ server.registerTool("memory_list", {
193
256
  description: "List memories, optionally filtered by type, tag, or minimum importance",
194
257
  inputSchema: {
195
258
  type: z.enum(MEMORY_TYPES).optional(),
196
- tag: z.string().optional(),
259
+ tag: z.string().max(MAX_TAG).optional(),
197
260
  min_importance: z.number().min(0).max(1).optional(),
198
261
  limit: z.number().int().min(1).max(500).optional().default(100),
199
262
  },
@@ -209,7 +272,7 @@ server.registerTool("memory_list", {
209
272
  server.registerTool("memory_get", {
210
273
  description: "Get a single memory by ID",
211
274
  inputSchema: {
212
- id: z.string().min(1),
275
+ id: z.string().min(1).max(MAX_ID),
213
276
  },
214
277
  }, async (args) => {
215
278
  const rec = getMemory(args.id);
@@ -220,13 +283,18 @@ server.registerTool("memory_get", {
220
283
  server.registerTool("memory_update", {
221
284
  description: "Update content / type / importance / tags of an existing memory",
222
285
  inputSchema: {
223
- id: z.string().min(1),
224
- content: z.string().optional(),
286
+ id: z.string().min(1).max(MAX_ID),
287
+ content: z.string().max(MAX_MEMORY_CONTENT).optional(),
225
288
  type: z.enum(MEMORY_TYPES).optional(),
226
289
  importance: z.number().min(0).max(1).optional(),
227
- tags: z.array(z.string()).optional(),
290
+ tags: z.array(z.string().max(MAX_TAG)).max(MAX_TAGS).optional(),
228
291
  },
229
292
  }, async (args) => {
293
+ // An all-undefined patch used to re-embed the text and bump updated_at,
294
+ // silently reordering the store.
295
+ if (args.content === undefined && args.type === undefined && args.importance === undefined && args.tags === undefined) {
296
+ return { content: [{ type: "text", text: "Nothing to update: provide content, type, importance and/or tags." }] };
297
+ }
230
298
  const rec = await updateMemory(args.id, {
231
299
  content: args.content,
232
300
  type: args.type,
@@ -240,7 +308,7 @@ server.registerTool("memory_update", {
240
308
  server.registerTool("memory_forget", {
241
309
  description: "Delete a memory by ID",
242
310
  inputSchema: {
243
- id: z.string().min(1),
311
+ id: z.string().min(1).max(MAX_ID),
244
312
  },
245
313
  }, async (args) => {
246
314
  const res = forget(args.id);
@@ -262,7 +330,7 @@ server.registerTool("memory_stats", {
262
330
  server.registerTool("memory_context", {
263
331
  description: "Build a compact context block from the most relevant memories for a given topic. Best for injecting memory into prompts.",
264
332
  inputSchema: {
265
- topic: z.string().min(1),
333
+ topic: z.string().min(1).max(MAX_TOPIC),
266
334
  top_k: z.number().int().min(1).max(20).optional().default(6),
267
335
  },
268
336
  }, async (args) => {
@@ -282,4 +350,37 @@ server.registerTool("memory_context", {
282
350
  const transport = new StdioServerTransport();
283
351
  await server.connect(transport);
284
352
  console.error(`RAG + Context Management MCP server running (db: ${path.resolve(STORAGE_DIR)})`);
353
+ /**
354
+ * Close the SQLite handle before the process exits.
355
+ *
356
+ * SQLite in DELETE journal mode rolls back on an abrupt exit, so a hard kill is
357
+ * safe, but it leaves the `-journal` file behind and skips any WAL checkpoint.
358
+ * On a stdio MCP server the host closing the pipe is the normal way this
359
+ * process dies, so an explicit shutdown keeps the store tidy and lets a WAL
360
+ * database truncate instead of growing.
361
+ *
362
+ * Exit is forced after a grace period because the host may not respond to
363
+ * `server.close()`, and a server that refuses to die is worse than one that
364
+ * drops an unflushed log line.
365
+ */
366
+ let shuttingDown = false;
367
+ function shutdown(signal) {
368
+ if (shuttingDown)
369
+ return;
370
+ shuttingDown = true;
371
+ console.error(`RAG memory: ${signal} received, closing database.`);
372
+ try {
373
+ closeDB();
374
+ }
375
+ catch (err) {
376
+ console.error(`RAG memory: database close failed: ${String(err)}`);
377
+ }
378
+ void server.close().finally(() => {
379
+ process.exit(0);
380
+ });
381
+ setTimeout(() => process.exit(0), 2_000).unref();
382
+ }
383
+ process.on("SIGINT", () => shutdown("SIGINT"));
384
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
385
+ process.on("SIGHUP", () => shutdown("SIGHUP"));
285
386
  //# sourceMappingURL=rag-server.js.map