@lotargo/memory_plugin 1.5.3 → 1.6.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/CHANGELOG.md +108 -0
- package/README.md +383 -352
- package/mcp-server/admin/auth.js +13 -4
- package/mcp-server/admin/snapshot.js +24 -7
- package/mcp-server/cli/direct_commands.js +334 -313
- package/mcp-server/cli/handlers/engine_actions.js +41 -0
- package/mcp-server/cli/handlers/storage_actions.js +58 -0
- package/mcp-server/cli/secret_input.js +44 -0
- package/mcp-server/cli/ui.js +564 -565
- package/mcp-server/cli.js +356 -324
- package/mcp-server/config/auth_store.js +74 -16
- package/mcp-server/config/config_manager.js +4 -0
- package/mcp-server/db/database.js +33 -14
- package/mcp-server/db/sync_queue.js +9 -19
- package/mcp-server/index.js +112 -42
- package/mcp-server/ingest/normalizer.js +116 -29
- package/mcp-server/ingest/pipeline.js +94 -6
- package/mcp-server/logger.js +49 -0
- package/mcp-server/memory.js +6 -9
- package/mcp-server/ml/gpu_monitor.js +169 -166
- package/mcp-server/ml/model_manager.js +17 -4
- package/mcp-server/retrieval/retriever.js +35 -15
- package/mcp-server/security/path_guard.js +67 -0
- package/mcp-server/setup.js +10 -2
- package/mcp-server/storage/blob_store.js +15 -2
- package/mcp-server/tools/core/memory_core.js +393 -0
- package/mcp-server/tools/helpers.js +59 -39
- package/mcp-server/tools/memory_tools.js +123 -516
- package/mcp-server/tools/rag_tools.js +49 -1
- package/opencode-plugin/index.js +94 -397
- package/package.json +7 -1
- package/skills/using-memory/SKILL.md +7 -2
|
@@ -57,14 +57,17 @@ function getMachineId() {
|
|
|
57
57
|
return _cachedMachineId;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
//
|
|
61
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
3
|
-
import { MEMORY_DIR, GLOBAL_KEY, buildMemoryContent, extractFacts, writeMemoryFile
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(() => []);
|
package/mcp-server/index.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
75
|
-
let
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
}
|