@lotargo/memory_plugin 1.5.3 → 1.6.1

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 (35) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +406 -352
  3. package/mcp-server/admin/auth.js +13 -4
  4. package/mcp-server/admin/snapshot.js +24 -7
  5. package/mcp-server/boot.js +43 -0
  6. package/mcp-server/cli/direct_commands.js +334 -313
  7. package/mcp-server/cli/handlers/engine_actions.js +41 -0
  8. package/mcp-server/cli/handlers/storage_actions.js +58 -0
  9. package/mcp-server/cli/secret_input.js +44 -0
  10. package/mcp-server/cli/ui.js +564 -565
  11. package/mcp-server/cli.js +356 -324
  12. package/mcp-server/cli_boot.js +37 -0
  13. package/mcp-server/config/auth_store.js +74 -16
  14. package/mcp-server/config/config_manager.js +4 -0
  15. package/mcp-server/db/database.js +33 -14
  16. package/mcp-server/db/sync_queue.js +9 -19
  17. package/mcp-server/index.js +112 -42
  18. package/mcp-server/ingest/normalizer.js +116 -29
  19. package/mcp-server/ingest/pipeline.js +94 -6
  20. package/mcp-server/logger.js +49 -0
  21. package/mcp-server/memory.js +6 -9
  22. package/mcp-server/ml/gpu_monitor.js +169 -166
  23. package/mcp-server/ml/model_manager.js +17 -4
  24. package/mcp-server/preinstall.js +23 -2
  25. package/mcp-server/retrieval/retriever.js +35 -15
  26. package/mcp-server/security/path_guard.js +67 -0
  27. package/mcp-server/setup.js +10 -2
  28. package/mcp-server/storage/blob_store.js +15 -2
  29. package/mcp-server/tools/core/memory_core.js +393 -0
  30. package/mcp-server/tools/helpers.js +59 -39
  31. package/mcp-server/tools/memory_tools.js +123 -516
  32. package/mcp-server/tools/rag_tools.js +49 -1
  33. package/opencode-plugin/index.js +94 -397
  34. package/package.json +13 -4
  35. package/skills/using-memory/SKILL.md +7 -2
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ── CLI Boot Guard ──────────────────────────────────────────────────────────
4
+ // Same version check as boot.js — see that file for the rationale.
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+
7
+ const MIN_MAJOR = 22;
8
+ const MIN_MINOR = 5;
9
+
10
+ const [major, minor] = process.versions.node.split(".").map(Number);
11
+
12
+ if (major < MIN_MAJOR || (major === MIN_MAJOR && minor < MIN_MINOR)) {
13
+ process.stderr.write(
14
+ `\n` +
15
+ ` ╔══════════════════════════════════════════════════════════════════╗\n` +
16
+ ` ║ @lotargo/memory_plugin requires Node.js >= 22.5.0 ║\n` +
17
+ ` ║ ║\n` +
18
+ ` ║ Detected: Node.js ${process.versions.node.padEnd(44)}║\n` +
19
+ ` ║ ║\n` +
20
+ ` ║ The built-in node:sqlite module used by this plugin was ║\n` +
21
+ ` ║ introduced in Node.js 22.5.0. Please upgrade your ║\n` +
22
+ ` ║ Node.js installation: ║\n` +
23
+ ` ║ ║\n` +
24
+ ` ║ nvm install 22 # or: brew install node@22 ║\n` +
25
+ ` ║ ║\n` +
26
+ ` ╚══════════════════════════════════════════════════════════════════╝\n` +
27
+ `\n`
28
+ );
29
+ process.exit(1);
30
+ }
31
+
32
+ // Version is OK — hand off to the real CLI.
33
+ import("./cli.js").then(m => {
34
+ if (process.argv[1] && process.argv[1].includes("cli_boot.js")) {
35
+ m.runCli().catch((err) => console.error("CLI error:", err));
36
+ }
37
+ });
@@ -57,14 +57,17 @@ function getMachineId() {
57
57
  return _cachedMachineId;
58
58
  }
59
59
 
60
- // Generate a deterministic hardware + system fingerprint (stable across reboots,
61
- // network changes and user sessions on the same machine).
60
+ // OWASP 2023 recommendation for PBKDF2-HMAC-SHA256.
61
+ const PBKDF2_ITERATIONS = 600000;
62
+ const LEGACY_PBKDF2_ITERATIONS = 10000;
63
+
64
+ // Generate a deterministic hardware fingerprint. Only STABLE components are used:
65
+ // hostname and username are volatile (renaming the machine or the account would
66
+ // permanently lock the user out of their own secrets), so they are excluded.
62
67
  function getSystemFingerprint() {
63
68
  if (_cachedFingerprint !== null && _cachedFingerprint !== undefined) return _cachedFingerprint;
64
69
  const parts = [
65
70
  getMachineId() || "no-machine-id",
66
- os.hostname() || "localhost",
67
- os.userInfo()?.username || "default_user",
68
71
  os.platform() || "unknown",
69
72
  os.arch() || "unknown",
70
73
  ];
@@ -72,16 +75,38 @@ function getSystemFingerprint() {
72
75
  return _cachedFingerprint;
73
76
  }
74
77
 
78
+ // Fingerprint used by <= 1.5.3 (included volatile hostname/username).
79
+ function getLegacyFingerprint() {
80
+ return [
81
+ getMachineId() || "no-machine-id",
82
+ os.hostname() || "localhost",
83
+ os.userInfo()?.username || "default_user",
84
+ os.platform() || "unknown",
85
+ os.arch() || "unknown",
86
+ ].join("|");
87
+ }
88
+
89
+ function deriveKey(fingerprint, iterations) {
90
+ const salt = crypto.createHash("sha256").update(fingerprint).digest();
91
+ return crypto.pbkdf2Sync(fingerprint, salt, iterations, 32, "sha256");
92
+ }
93
+
75
94
  // Derive a 256-bit (32 bytes) key using PBKDF2 with salt derived from fingerprint
76
95
  function deriveEncryptionKey() {
77
96
  if (_cachedEncryptionKey) return _cachedEncryptionKey;
78
- const fingerprint = getSystemFingerprint();
79
- const salt = crypto.createHash("sha256").update(fingerprint).digest();
80
- // PBKDF2 with 10,000 iterations to derive a secure 32-byte key
81
- _cachedEncryptionKey = crypto.pbkdf2Sync(fingerprint, salt, 10000, 32, "sha256");
97
+ _cachedEncryptionKey = deriveKey(getSystemFingerprint(), PBKDF2_ITERATIONS);
82
98
  return _cachedEncryptionKey;
83
99
  }
84
100
 
101
+ // Keys accepted for DECRYPTION only, so secrets written by older versions keep
102
+ // working; they are transparently re-encrypted with the current key on read.
103
+ function legacyDecryptionKeys() {
104
+ return [
105
+ deriveKey(getLegacyFingerprint(), LEGACY_PBKDF2_ITERATIONS),
106
+ deriveKey(getSystemFingerprint(), LEGACY_PBKDF2_ITERATIONS),
107
+ ];
108
+ }
109
+
85
110
  // Encrypt data using AES-256-GCM
86
111
  export function encryptData(plainText) {
87
112
  const key = deriveEncryptionKey();
@@ -105,19 +130,35 @@ export function decryptData(encryptedStr) {
105
130
  }
106
131
 
107
132
  const [ivHex, authTagHex, encryptedHex] = parts;
108
- const key = deriveEncryptionKey();
109
133
  const iv = Buffer.from(ivHex, "hex");
110
134
  const authTag = Buffer.from(authTagHex, "hex");
111
135
 
112
- const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
113
- decipher.setAuthTag(authTag);
114
-
115
- let decrypted = decipher.update(encryptedHex, "hex", "utf8");
116
- decrypted += decipher.final("utf8");
136
+ const tryKey = (key) => {
137
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
138
+ decipher.setAuthTag(authTag);
139
+ let decrypted = decipher.update(encryptedHex, "hex", "utf8");
140
+ decrypted += decipher.final("utf8");
141
+ return decrypted;
142
+ };
117
143
 
118
- return decrypted;
144
+ try {
145
+ return tryKey(deriveEncryptionKey());
146
+ } catch (err) {
147
+ for (const legacyKey of legacyDecryptionKeys()) {
148
+ try {
149
+ const plain = tryKey(legacyKey);
150
+ _needsReEncrypt = true;
151
+ return plain;
152
+ } catch {}
153
+ }
154
+ throw err;
155
+ }
119
156
  }
120
157
 
158
+ // Set when a secret was decrypted with a legacy key so it can be rewritten
159
+ // under the current derivation parameters.
160
+ let _needsReEncrypt = false;
161
+
121
162
  // Load a KEY=VALUE .env file from MEMORY_DIR (global environment override for
122
163
  // headless / Docker / CI deployments). Values may optionally be quoted.
123
164
  function loadEnvFile() {
@@ -194,7 +235,15 @@ function readStoredSecrets() {
194
235
  try {
195
236
  const encrypted = fs.readFileSync(SECRETS_FILE, "utf-8").trim();
196
237
  if (!encrypted) { _cachedSecrets = null; return null; }
238
+ _needsReEncrypt = false;
197
239
  _cachedSecrets = JSON.parse(decryptData(encrypted));
240
+ if (_needsReEncrypt) {
241
+ _needsReEncrypt = false;
242
+ try {
243
+ writeSecretsFile(encryptData(JSON.stringify(_cachedSecrets)));
244
+ _cachedSecretsMtime = fs.statSync(SECRETS_FILE).mtimeMs;
245
+ } catch {}
246
+ }
198
247
  return _cachedSecrets;
199
248
  } catch (err) {
200
249
  console.error(
@@ -221,11 +270,20 @@ export function getSecretsSource() {
221
270
  }
222
271
 
223
272
  // Save secrets securely
273
+ // Write the secrets file with owner-only permissions (0600). On Linux/macOS a
274
+ // default 0644 would let any other local user read auth_secrets.enc.
275
+ function writeSecretsFile(encrypted) {
276
+ fs.writeFileSync(SECRETS_FILE, encrypted, { encoding: "utf-8", mode: 0o600 });
277
+ try {
278
+ fs.chmodSync(SECRETS_FILE, 0o600);
279
+ } catch {}
280
+ }
281
+
224
282
  export function saveSecrets(secrets) {
225
283
  ensureDirSync();
226
284
  const plainText = JSON.stringify(secrets);
227
285
  const encrypted = encryptData(plainText);
228
- fs.writeFileSync(SECRETS_FILE, encrypted, "utf-8");
286
+ writeSecretsFile(encrypted);
229
287
  _cachedSecrets = undefined; // invalidate so readStoredSecrets re-reads
230
288
  _cachedSecretsMtime = 0;
231
289
  if (typeof _onSecretsChanged === "function") _onSecretsChanged();
@@ -8,9 +8,11 @@ export const DEFAULT_CONFIG = {
8
8
  fusionAlgorithm: "rsf", // "rsf" | "rrf" | "semantic_only" | "lexical_only"
9
9
  alpha: 0.5, // Weight for vector similarity in RSF [0.0 - 1.0] (50/50 balance)
10
10
  embeddingModel: "Xenova/multilingual-e5-small",
11
+ vectorDimension: 0, // Fixed embedding dimension (0 = auto-detect from model output)
11
12
  rerankerModel: "none", // "none" | "Xenova/bge-reranker-base" | custom HF model
12
13
  rerankerEnabled: false,
13
14
  batchSize: 12, // Ingestion vector batch size [1 - 256] (default 12)
15
+ vectorScanLimit: 50000, // Max micro-chunks scanned per vector query (0 = unlimited)
14
16
  gpuAttentionBudget: 2000000, // GPU micro-batch attention budget [1M - 16M] (default 2.0M ~1.5GB VRAM)
15
17
  onnxThreads: 0, // ONNX WASM threads: 0 = auto-detect CPU cores, or 1-16
16
18
  executionDevice: "cpu", // "cpu" | "webgpu"
@@ -21,6 +23,8 @@ export const DEFAULT_CONFIG = {
21
23
  failoverUrl: "", // Failover connection endpoint URL (Fly.io + LiteFS)
22
24
  authorized: false, // True once the user completed cloud login (token stored encrypted)
23
25
  username: "", // Account username from the Turso OAuth profile
26
+ ingestAllowedPaths: [], // Extra directories ingest_document(type:"file") may read from
27
+ ingestAllowAnyPath: false, // Escape hatch: allow reading ANY path from disk (unsafe)
24
28
  };
25
29
 
26
30
  let cachedConfig = null;
@@ -34,19 +34,19 @@ class DatabaseWrapper {
34
34
 
35
35
  while (attempts < maxAttempts) {
36
36
  attempts++;
37
- const controller = new AbortController();
38
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
37
+ // A plain timer is enough here; an AbortController per attempt leaked its
38
+ // "abort" listener because it was never removed.
39
+ let timeoutId = null;
40
+ const timeoutPromise = new Promise((_, reject) => {
41
+ timeoutId = setTimeout(
42
+ () => reject(new Error(`Database operation timed out after ${timeoutMs / 1000} seconds`)),
43
+ timeoutMs
44
+ );
45
+ });
39
46
 
40
47
  try {
41
48
  const client = (this.usingFailover && this.failoverClient) ? this.failoverClient : this.cloudClient;
42
- const result = await Promise.race([
43
- fn(client),
44
- new Promise((_, reject) => {
45
- controller.signal.addEventListener("abort", () => {
46
- reject(new Error("Database operation timed out after 10 seconds"));
47
- });
48
- })
49
- ]);
49
+ const result = await Promise.race([fn(client), timeoutPromise]);
50
50
  clearTimeout(timeoutId);
51
51
  // Successful operation, reset consecutive failures
52
52
  this.consecutiveFailures = 0;
@@ -156,6 +156,9 @@ async function openDatabase(customPath, mode) {
156
156
  localDb = new DatabaseSync(dbPath);
157
157
  localDb.exec("PRAGMA foreign_keys = ON;");
158
158
  localDb.exec("PRAGMA journal_mode = WAL;");
159
+ // Default busy_timeout is 0 => an immediate SQLITE_BUSY ("database is
160
+ // locked") whenever background sync, ingestion and MCP calls overlap.
161
+ localDb.exec("PRAGMA busy_timeout = 5000;");
159
162
  }
160
163
 
161
164
  let cloudClient = null;
@@ -179,10 +182,16 @@ async function openDatabase(customPath, mode) {
179
182
  authToken: token || undefined,
180
183
  });
181
184
  }
182
- // In hybrid-sync mode, ensure remote schema is also fully migrated and up to date
185
+ // In hybrid-sync mode, ensure remote schema is also fully migrated and up to date.
186
+ // Use a dedicated short-lived client so closing it doesn't kill the shared one.
183
187
  if (mode === "hybrid-sync") {
184
- const cloudDbWrapper = new DatabaseWrapper(null, cloudClient, "only-cloud", failoverClient);
188
+ const remoteClient = createClient({
189
+ url: tursoUrl,
190
+ authToken: token || undefined,
191
+ });
192
+ const cloudDbWrapper = new DatabaseWrapper(null, remoteClient, "only-cloud", null);
185
193
  await runMigrations(cloudDbWrapper);
194
+ cloudDbWrapper.close();
186
195
  }
187
196
  } else if (mode === "only-cloud") {
188
197
  throw new Error("Turso URL is required for only-cloud mode. Please login first.");
@@ -195,6 +204,13 @@ async function openDatabase(customPath, mode) {
195
204
  await runMigrations(wrappedDb);
196
205
 
197
206
  if (!customPath) {
207
+ // Closing the previous instance before replacing it: otherwise a mode switch
208
+ // leaked the old DatabaseSync handle and Turso client.
209
+ if (dbInstance && dbInstance !== wrappedDb) {
210
+ try {
211
+ dbInstance.close();
212
+ } catch {}
213
+ }
198
214
  dbInstance = wrappedDb;
199
215
  }
200
216
 
@@ -209,8 +225,11 @@ export async function getDatabase(customPath = null, forceMode = null) {
209
225
  if (dbInstance && dbInstance.mode === mode) {
210
226
  return dbInstance;
211
227
  }
212
- // After a failed init, wait before retrying to avoid hammering cloud auth
213
- if (!dbInitPromise && dbLastFailAt && (Date.now() - dbLastFailAt) < DB_FAIL_COOLDOWN_MS) {
228
+ // After a failed init, wait before retrying to avoid hammering cloud auth.
229
+ // Only cloud modes are throttled reopening a local SQLite file is cheap
230
+ // and must never be blocked by an unrelated cloud failure.
231
+ const isCloudMode = mode === "only-cloud" || mode === "hybrid-sync";
232
+ if (isCloudMode && !dbInitPromise && dbLastFailAt && (Date.now() - dbLastFailAt) < DB_FAIL_COOLDOWN_MS) {
214
233
  throw new Error("Database initialization failed recently. Retrying in a few seconds...");
215
234
  }
216
235
  // Deduplicate concurrent default-DB initialization so migrations never run
@@ -1,6 +1,7 @@
1
1
  import { readFile, readdir } from "fs/promises";
2
- import { join, basename } from "path";
3
- import { MEMORY_DIR, GLOBAL_KEY, buildMemoryContent, extractFacts, writeMemoryFile, storeFilePath, memoryFileName } from "../memory.js";
2
+ import { join } from "path";
3
+ import { MEMORY_DIR, GLOBAL_KEY, buildMemoryContent, extractFacts, writeMemoryFile } from "../memory.js";
4
+ import { toVectorBytes } from "../retrieval/retriever.js";
4
5
 
5
6
  let isSyncing = false;
6
7
 
@@ -104,18 +105,12 @@ async function processSyncTask(db, task) {
104
105
  // 5. Insert micro_chunks & FTS
105
106
  if (Array.isArray(data.micro_chunks)) {
106
107
  for (const mc of data.micro_chunks) {
107
- let vecBuf = Buffer.alloc(0);
108
- if (mc.vector) {
109
- if (Buffer.isBuffer(mc.vector)) {
110
- vecBuf = mc.vector;
111
- } else if (typeof mc.vector === "string") {
112
- vecBuf = Buffer.from(mc.vector, "base64");
113
- } else if (mc.vector.type === "Buffer" && Array.isArray(mc.vector.data)) {
114
- vecBuf = Buffer.from(mc.vector.data);
115
- } else if (Array.isArray(mc.vector)) {
116
- vecBuf = Buffer.from(mc.vector);
117
- }
118
- }
108
+ // node:sqlite hands back Uint8Array, which matched none of the old
109
+ // branches — hybrid-sync silently pushed EMPTY vectors to the cloud.
110
+ const vecBytes = toVectorBytes(mc.vector);
111
+ const vecBuf = vecBytes
112
+ ? Buffer.from(vecBytes.buffer, vecBytes.byteOffset, vecBytes.byteLength)
113
+ : Buffer.alloc(0);
119
114
  await db.cloudClient.execute({
120
115
  sql: "INSERT INTO micro_chunks (id, section_id, doc_id, content, vector, token_count, medium_id) VALUES (?, ?, ?, ?, ?, ?, ?);",
121
116
  args: [mc.id, mc.section_id, doc.id, mc.content, vecBuf, mc.token_count, mc.medium_id || null],
@@ -172,11 +167,6 @@ export async function enqueueSyncTask(action, keyOrId, payload = null) {
172
167
  });
173
168
  }
174
169
 
175
- // Map a store key to its local file path, mirroring memory.js naming.
176
- function localFilePath(key) {
177
- return join(MEMORY_DIR, memoryFileName(key));
178
- }
179
-
180
170
  // Enumerate local store files as { key, path }.
181
171
  async function enumerateLocalStores() {
182
172
  const files = await readdir(MEMORY_DIR).catch(() => []);
@@ -1,42 +1,112 @@
1
- #!/usr/bin/env node
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { ensureDir, MEMORY_DIR } from "./memory.js";
5
- import { registerAllTools } from "./tools/index.js";
6
-
7
- const cliArgs = process.argv.slice(2);
8
-
9
- if (cliArgs.includes("setup") || cliArgs.includes("install") || cliArgs.includes("--setup") || cliArgs.includes("-s")) {
10
- const { runSetup } = await import("./setup.js");
11
- await runSetup();
12
- process.exit(0);
13
- }
14
-
15
- if (
16
- cliArgs.includes("cli") ||
17
- cliArgs.includes("config") ||
18
- cliArgs.includes("--cli") ||
19
- cliArgs.includes("-c") ||
20
- cliArgs.includes("login") ||
21
- cliArgs.includes("logout") ||
22
- cliArgs.includes("auth-status") ||
23
- cliArgs.includes("auth_status") ||
24
- cliArgs.includes("auth")
25
- ) {
26
- const { runCli } = await import("./cli.js");
27
- await runCli();
28
- process.exit(0);
29
- }
30
-
31
- await ensureDir();
32
-
33
- const server = new McpServer({
34
- name: "memory-agent",
35
- version: "1.5.2",
36
- });
37
-
38
- registerAllTools(server);
39
-
40
- const transport = new StdioServerTransport();
41
- await server.connect(transport);
42
- console.error(`memory-agent MCP server running, data dir: ${MEMORY_DIR}`);
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { ensureDir, MEMORY_DIR } from "./memory.js";
5
+ import { registerAllTools } from "./tools/index.js";
6
+ import { closeDatabase } from "./db/database.js";
7
+ import { readFileSync } from "node:fs";
8
+
9
+ function readPackageVersion() {
10
+ try {
11
+ const pkgUrl = new URL("../package.json", import.meta.url);
12
+ return JSON.parse(readFileSync(pkgUrl, "utf8")).version || "0.0.0";
13
+ } catch {
14
+ return "0.0.0";
15
+ }
16
+ }
17
+
18
+ const cliArgs = process.argv.slice(2);
19
+
20
+ // Every command handled by cli.js/direct_commands.js must be routed here too,
21
+ // otherwise `memory_plugin link` etc. would fall through and start an MCP
22
+ // server that silently blocks on stdin.
23
+ const CLI_COMMANDS = new Set([
24
+ "cli",
25
+ "config",
26
+ "--cli",
27
+ "-c",
28
+ "login",
29
+ "logout",
30
+ "auth-status",
31
+ "auth_status",
32
+ "auth",
33
+ "link",
34
+ "unlink",
35
+ "relink",
36
+ "identity",
37
+ "migrate_titles",
38
+ "enable-prompt",
39
+ "disable-prompt",
40
+ ]);
41
+
42
+ function printUsage() {
43
+ console.log(`memory_plugin v${readPackageVersion()} — hybrid RAG memory for AI coding agents
44
+
45
+ Usage:
46
+ memory_plugin Start the MCP server on stdio (default)
47
+ memory_plugin setup [--opencode|--claude|--codex|--antigravity] [--mode <MODE>]
48
+ memory_plugin cli Interactive terminal UI
49
+ memory_plugin login [--from-env|--api-token|--db-url <URL>]
50
+ memory_plugin logout [--api-key]
51
+ memory_plugin auth-status
52
+ memory_plugin link|unlink|relink|identity [--dir <path>] [--remote <url>]
53
+ memory_plugin migrate_titles [--key <key>]
54
+ memory_plugin enable-prompt | disable-prompt
55
+
56
+ Options:
57
+ -h, --help Show this help text
58
+ -v, --version Print the package version
59
+
60
+ Secrets: prefer TURSO_API_TOKEN / TURSO_DB_URL / TURSO_DB_TOKEN environment
61
+ variables over command-line flags — argv is visible to other local processes.
62
+ Data directory: ${MEMORY_DIR}`);
63
+ }
64
+
65
+ if (cliArgs.includes("--help") || cliArgs.includes("-h") || cliArgs[0] === "help") {
66
+ printUsage();
67
+ process.exit(0);
68
+ }
69
+
70
+ if (cliArgs.includes("--version") || cliArgs.includes("-v")) {
71
+ console.log(readPackageVersion());
72
+ process.exit(0);
73
+ }
74
+
75
+ if (cliArgs.includes("setup") || cliArgs.includes("install") || cliArgs.includes("--setup") || cliArgs.includes("-s")) {
76
+ const { runSetup } = await import("./setup.js");
77
+ await runSetup();
78
+ process.exit(0);
79
+ }
80
+
81
+ if (cliArgs.some((a) => CLI_COMMANDS.has(a))) {
82
+ const { runCli } = await import("./cli.js");
83
+ await runCli();
84
+ process.exit(0);
85
+ }
86
+
87
+ await ensureDir();
88
+
89
+ process.on("exit", () => {
90
+ try {
91
+ closeDatabase();
92
+ } catch {}
93
+ });
94
+ for (const sig of ["SIGINT", "SIGTERM"]) {
95
+ process.on(sig, () => {
96
+ try {
97
+ closeDatabase();
98
+ } catch {}
99
+ process.exit(0);
100
+ });
101
+ }
102
+
103
+ const server = new McpServer({
104
+ name: "memory-agent",
105
+ version: readPackageVersion(),
106
+ });
107
+
108
+ registerAllTools(server);
109
+
110
+ const transport = new StdioServerTransport();
111
+ await server.connect(transport);
112
+ console.error(`memory-agent MCP server running, data dir: ${MEMORY_DIR}`);
@@ -1,4 +1,6 @@
1
1
  import { basename, extname } from "node:path";
2
+ import { isIP } from "node:net";
3
+ import { lookup } from "node:dns/promises";
2
4
  import { PDFParse } from "pdf-parse";
3
5
  import mammoth from "mammoth";
4
6
  import xlsx from "xlsx";
@@ -45,46 +47,131 @@ export function validateUrlForSsrf(urlStr) {
45
47
  throw new Error(`Unsupported URL scheme '${parsed.protocol}'. Only http/https are allowed.`);
46
48
  }
47
49
 
48
- const hostname = parsed.hostname.toLowerCase();
49
-
50
- const isBlocked =
51
- hostname === "localhost" ||
52
- hostname === "127.0.0.1" ||
53
- hostname === "::1" ||
54
- hostname === "169.254.169.254" ||
55
- hostname === "metadata.google.internal" ||
56
- /^127\./.test(hostname) ||
57
- /^10\./.test(hostname) ||
58
- /^192\.168\./.test(hostname) ||
59
- /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
60
- /^169\.254\./.test(hostname) ||
61
- /^0\./.test(hostname);
62
-
63
- if (isBlocked) {
50
+ const hostname = normalizeHostname(parsed.hostname);
51
+
52
+ if (isBlockedHost(hostname)) {
64
53
  throw new Error(`Ingestion blocked: URL '${urlStr}' targets a private/local IP address or metadata service.`);
65
54
  }
66
55
 
67
56
  return parsed;
68
57
  }
69
58
 
59
+ // URL.hostname keeps the brackets for IPv6 literals ("[::1]"), which broke the
60
+ // plain string comparisons and allowed http://[::1]/ and IPv4-mapped forms through.
61
+ export function normalizeHostname(rawHostname) {
62
+ let host = String(rawHostname || "").toLowerCase();
63
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
64
+ if (host.endsWith(".")) host = host.slice(0, -1);
65
+ return host;
66
+ }
67
+
68
+ function isPrivateIPv4(host) {
69
+ if (isIP(host) !== 4) return false;
70
+ const [a, b] = host.split(".").map(Number);
71
+ if (a === 127 || a === 0 || a === 10) return true;
72
+ if (a === 169 && b === 254) return true;
73
+ if (a === 192 && b === 168) return true;
74
+ if (a === 172 && b >= 16 && b <= 31) return true;
75
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64.0.0/10
76
+ if (a >= 224) return true; // multicast + reserved
77
+ return false;
78
+ }
79
+
80
+ function isPrivateIPv6(host) {
81
+ if (isIP(host) !== 6) return false;
82
+ // IPv4-mapped / IPv4-compatible forms: ::ffff:127.0.0.1 and ::ffff:7f00:1
83
+ const mapped = extractMappedIPv4(host);
84
+ if (mapped) return isPrivateIPv4(mapped);
85
+
86
+ if (host === "::" || host === "::1") return true;
87
+ if (/^fe[89ab]/.test(host)) return true; // fe80::/10 link-local
88
+ if (/^f[cd]/.test(host)) return true; // fc00::/7 unique-local
89
+ if (/^ff/.test(host)) return true; // ff00::/8 multicast
90
+ if (/^0{0,4}:/.test(host) && !host.startsWith("::ffff:")) return true; // ::/8
91
+ return false;
92
+ }
93
+
94
+ function extractMappedIPv4(host) {
95
+ const dotted = host.match(/::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
96
+ if (dotted) return dotted[1];
97
+ const hex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
98
+ if (hex) {
99
+ const hi = parseInt(hex[1], 16);
100
+ const lo = parseInt(hex[2], 16);
101
+ return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
102
+ }
103
+ return null;
104
+ }
105
+
106
+ export function isBlockedHost(hostname) {
107
+ const host = normalizeHostname(hostname);
108
+ if (!host) return true;
109
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
110
+ if (host === "metadata.google.internal" || host === "metadata") return true;
111
+ if (isPrivateIPv4(host)) return true;
112
+ if (isPrivateIPv6(host)) return true;
113
+ return false;
114
+ }
115
+
116
+ // Defence against DNS rebinding: resolve the hostname and re-check the actual
117
+ // address before the request is issued.
118
+ export async function assertResolvedHostAllowed(hostname) {
119
+ const host = normalizeHostname(hostname);
120
+ if (isIP(host)) return;
121
+ let addresses;
122
+ try {
123
+ addresses = await lookup(host, { all: true, verbatim: true });
124
+ } catch {
125
+ return; // fetch() will surface the resolution error itself
126
+ }
127
+ for (const { address } of addresses) {
128
+ if (isBlockedHost(address)) {
129
+ throw new Error(
130
+ `Ingestion blocked: host '${host}' resolves to a private/local address (${address}).`
131
+ );
132
+ }
133
+ }
134
+ }
135
+
70
136
  // Fetch a web page and convert it to Markdown/text. Used by the 'url' ingestion type
71
137
  // so the RAG store gets the page CONTENT, not just the URL string.
72
138
  export async function fetchUrlContent(url) {
73
139
  const parsed = validateUrlForSsrf(url);
74
- const targetUrl = parsed.toString();
75
- let res;
76
- try {
77
- res = await fetch(targetUrl, {
78
- headers: {
79
- "User-Agent": "memory-agent-rag/1.0",
80
- Accept: "text/html,application/xhtml+xml,application/json,text/plain,*/*",
81
- },
82
- redirect: "follow",
83
- signal: AbortSignal.timeout(15000),
84
- });
85
- } catch (err) {
86
- throw new Error(`Failed to fetch URL '${url}': ${err.message}`);
140
+ await assertResolvedHostAllowed(parsed.hostname);
141
+ let currentUrl = parsed.toString();
142
+
143
+ const fetchOnce = async (targetUrl) => {
144
+ try {
145
+ return await fetch(targetUrl, {
146
+ headers: {
147
+ "User-Agent": "memory-agent-rag/1.0",
148
+ Accept: "text/html,application/xhtml+xml,application/json,text/plain,*/*",
149
+ },
150
+ redirect: "manual",
151
+ signal: AbortSignal.timeout(15000),
152
+ });
153
+ } catch (err) {
154
+ throw new Error(`Failed to fetch URL '${url}': ${err.message}`);
155
+ }
156
+ };
157
+
158
+ // Follow up to 3 redirect hops manually, re-validating each target against SSRF rules.
159
+ let res = await fetchOnce(currentUrl);
160
+ for (let hop = 0; hop < 3 && res.status >= 300 && res.status < 400; hop++) {
161
+ const location = res.headers.get("location");
162
+ if (!location) break;
163
+ let redirectUrl;
164
+ try {
165
+ redirectUrl = new URL(location, currentUrl);
166
+ } catch {
167
+ throw new Error(`URL '${url}' redirected to an invalid location`);
168
+ }
169
+ validateUrlForSsrf(redirectUrl.toString());
170
+ await assertResolvedHostAllowed(redirectUrl.hostname);
171
+ currentUrl = redirectUrl.toString();
172
+ res = await fetchOnce(currentUrl);
87
173
  }
174
+
88
175
  if (!res.ok) {
89
176
  throw new Error(`Failed to fetch URL '${url}': HTTP ${res.status} ${res.statusText}`);
90
177
  }