@lotargo/memory_plugin 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +334 -252
- package/mcp-server/admin/auth.js +90 -0
- package/mcp-server/admin/snapshot.js +34 -32
- package/mcp-server/cli.js +109 -12
- package/mcp-server/config/auth_store.js +101 -0
- package/mcp-server/config/config_manager.js +3 -0
- package/mcp-server/db/database.js +180 -13
- package/mcp-server/db/migrations.js +62 -33
- package/mcp-server/db/sync_queue.js +211 -0
- package/mcp-server/graph/graph_extractor.js +40 -17
- package/mcp-server/graph/knowledge_linker.js +14 -14
- package/mcp-server/index.js +26 -24
- package/mcp-server/ingest/exporter.js +12 -12
- package/mcp-server/ingest/normalizer.js +102 -5
- package/mcp-server/ingest/pipeline.js +53 -29
- package/mcp-server/memory.js +73 -0
- package/mcp-server/retrieval/retriever.js +24 -15
- package/package.json +25 -6
- package/mcp-server/admin/server.js +0 -228
|
@@ -11,7 +11,7 @@ export function sanitizeFtsQuery(query) {
|
|
|
11
11
|
return words.join(" OR ");
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export function bm25Search(db, query, limit = 30) {
|
|
14
|
+
export async function bm25Search(db, query, limit = 30) {
|
|
15
15
|
const ftsQuery = sanitizeFtsQuery(query);
|
|
16
16
|
if (!ftsQuery) return [];
|
|
17
17
|
|
|
@@ -23,7 +23,7 @@ export function bm25Search(db, query, limit = 30) {
|
|
|
23
23
|
ORDER BY rank
|
|
24
24
|
LIMIT ?;
|
|
25
25
|
`);
|
|
26
|
-
const rows = stmt.all(ftsQuery, limit);
|
|
26
|
+
const rows = await stmt.all(ftsQuery, limit);
|
|
27
27
|
return rows.map((r, i) => ({
|
|
28
28
|
id: r.id,
|
|
29
29
|
content: r.content,
|
|
@@ -37,7 +37,7 @@ export function bm25Search(db, query, limit = 30) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export function vectorSearch(db, queryVector, limit = 30, minSim = 0.25) {
|
|
40
|
+
export async function vectorSearch(db, queryVector, limit = 30, minSim = 0.25) {
|
|
41
41
|
if (!queryVector || queryVector.length === 0) return [];
|
|
42
42
|
|
|
43
43
|
const vectorDim = queryVector.length;
|
|
@@ -52,8 +52,17 @@ export function vectorSearch(db, queryVector, limit = 30, minSim = 0.25) {
|
|
|
52
52
|
`);
|
|
53
53
|
|
|
54
54
|
const scored = [];
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
const rows = await stmt.all();
|
|
56
|
+
for (const r of rows) {
|
|
57
|
+
let vecSub = r.vector;
|
|
58
|
+
if (typeof vecSub === "string") {
|
|
59
|
+
vecSub = Buffer.from(vecSub, "base64");
|
|
60
|
+
} else if (vecSub.type === "Buffer" && Array.isArray(vecSub.data)) {
|
|
61
|
+
vecSub = Buffer.from(vecSub.data);
|
|
62
|
+
} else if (Array.isArray(vecSub)) {
|
|
63
|
+
vecSub = Buffer.from(vecSub);
|
|
64
|
+
}
|
|
65
|
+
tempView.set(vecSub.subarray(0, vectorDim * 4));
|
|
57
66
|
|
|
58
67
|
const sim = cosineSimilarity(queryVector, tempVec);
|
|
59
68
|
if (!isNaN(sim) && sim >= minSim) {
|
|
@@ -205,7 +214,7 @@ export async function hybridQuery({
|
|
|
205
214
|
instruction = null,
|
|
206
215
|
generateEmbeddings = true,
|
|
207
216
|
}) {
|
|
208
|
-
const db = customDb || getDatabase();
|
|
217
|
+
const db = customDb || await getDatabase();
|
|
209
218
|
const activeConfig = getConfig();
|
|
210
219
|
|
|
211
220
|
// If embeddings are disabled (e.g. fast/offline test mode or model not cached),
|
|
@@ -223,28 +232,28 @@ export async function hybridQuery({
|
|
|
223
232
|
let fusedHits = [];
|
|
224
233
|
|
|
225
234
|
if (algo === "lexical_only" || algo === "bm25_only") {
|
|
226
|
-
const bm25Hits = bm25Search(db, query, limit * 4);
|
|
235
|
+
const bm25Hits = await bm25Search(db, query, limit * 4);
|
|
227
236
|
fusedHits = bm25Hits.map((hit) => ({
|
|
228
237
|
...hit,
|
|
229
238
|
score: 1.0 / hit.bm25_rank,
|
|
230
239
|
}));
|
|
231
240
|
} else if (algo === "semantic_only" || algo === "vector_only") {
|
|
232
241
|
const queryVector = await embedText(query, true, embModel, null, instruction);
|
|
233
|
-
const vectorHits = vectorSearch(db, queryVector, limit * 4, 0.10);
|
|
242
|
+
const vectorHits = await vectorSearch(db, queryVector, limit * 4, 0.10);
|
|
234
243
|
fusedHits = vectorHits.map((hit) => ({
|
|
235
244
|
...hit,
|
|
236
245
|
score: hit.cosine_sim,
|
|
237
246
|
}));
|
|
238
247
|
} else if (algo === "rrf") {
|
|
239
|
-
const bm25Hits = bm25Search(db, query, 30);
|
|
248
|
+
const bm25Hits = await bm25Search(db, query, 30);
|
|
240
249
|
const queryVector = await embedText(query, true, embModel, null, instruction);
|
|
241
|
-
const vectorHits = vectorSearch(db, queryVector, 30, 0.10);
|
|
250
|
+
const vectorHits = await vectorSearch(db, queryVector, 30, 0.10);
|
|
242
251
|
fusedHits = rrfFusion(bm25Hits, vectorHits, 60, scoreThreshold);
|
|
243
252
|
} else {
|
|
244
253
|
// Default: RSF
|
|
245
|
-
const bm25Hits = bm25Search(db, query, 30);
|
|
254
|
+
const bm25Hits = await bm25Search(db, query, 30);
|
|
246
255
|
const queryVector = await embedText(query, true, embModel, null, instruction);
|
|
247
|
-
const vectorHits = vectorSearch(db, queryVector, 30, 0.10);
|
|
256
|
+
const vectorHits = await vectorSearch(db, queryVector, 30, 0.10);
|
|
248
257
|
fusedHits = rsfFusion(bm25Hits, vectorHits, alphaWeight, scoreThreshold);
|
|
249
258
|
}
|
|
250
259
|
|
|
@@ -260,7 +269,7 @@ export async function hybridQuery({
|
|
|
260
269
|
`);
|
|
261
270
|
|
|
262
271
|
for (const hit of fusedHits) {
|
|
263
|
-
const row = parentLookupStmt.get(hit.id);
|
|
272
|
+
const row = await parentLookupStmt.get(hit.id);
|
|
264
273
|
const parentKey = row ? (row.medium_id || row.section_id) : hit.id;
|
|
265
274
|
if (!seenParents.has(parentKey)) {
|
|
266
275
|
seenParents.add(parentKey);
|
|
@@ -282,12 +291,12 @@ export async function hybridQuery({
|
|
|
282
291
|
`);
|
|
283
292
|
|
|
284
293
|
for (const hit of topHits) {
|
|
285
|
-
const detail = secStmt.get(hit.id);
|
|
294
|
+
const detail = await secStmt.get(hit.id);
|
|
286
295
|
if (!detail) continue;
|
|
287
296
|
|
|
288
297
|
let symbols = [];
|
|
289
298
|
if (includeGraphContext) {
|
|
290
|
-
symbols = getRelatedSymbols(db, detail.section_id);
|
|
299
|
+
symbols = await getRelatedSymbols(db, detail.section_id);
|
|
291
300
|
}
|
|
292
301
|
|
|
293
302
|
results.push({
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotargo/memory_plugin",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "100% local hybrid RAG memory for AI coding agents (OpenCode, Claude Code, Codex, Antigravity). MCP server + plugin: persistent user facts, document ingestion, vector + SQLite FTS5 retrieval across sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "opencode-plugin/index.js",
|
|
7
7
|
"scripts": {
|
|
@@ -33,14 +33,29 @@
|
|
|
33
33
|
"skills"
|
|
34
34
|
],
|
|
35
35
|
"keywords": [
|
|
36
|
+
"memory",
|
|
37
|
+
"persistent-memory",
|
|
38
|
+
"ai-memory",
|
|
39
|
+
"ai-agent",
|
|
40
|
+
"ai-agents",
|
|
41
|
+
"rag",
|
|
42
|
+
"knowledge-base",
|
|
43
|
+
"llm",
|
|
44
|
+
"mcp",
|
|
45
|
+
"mcp-server",
|
|
46
|
+
"model-context-protocol",
|
|
47
|
+
"mcp-tools",
|
|
36
48
|
"opencode",
|
|
37
49
|
"claude-code",
|
|
38
50
|
"codex",
|
|
39
51
|
"antigravity",
|
|
40
52
|
"plugin",
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
53
|
+
"vector-search",
|
|
54
|
+
"hybrid-search",
|
|
55
|
+
"semantic-search",
|
|
56
|
+
"embeddings",
|
|
57
|
+
"sqlite",
|
|
58
|
+
"fts5",
|
|
44
59
|
"context"
|
|
45
60
|
],
|
|
46
61
|
"author": "Lotargo",
|
|
@@ -53,8 +68,12 @@
|
|
|
53
68
|
"url": "https://github.com/Lotargo/memory_pugin.git"
|
|
54
69
|
},
|
|
55
70
|
"dependencies": {
|
|
56
|
-
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
57
71
|
"@huggingface/transformers": "^3.3.3",
|
|
72
|
+
"@libsql/client": "^0.17.4",
|
|
73
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
74
|
+
"mammoth": "^1.12.0",
|
|
75
|
+
"pdf-parse": "^2.4.5",
|
|
76
|
+
"xlsx": "^0.18.5",
|
|
58
77
|
"zod": "^4.1.0"
|
|
59
78
|
}
|
|
60
79
|
}
|
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
2
|
-
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
3
|
-
import { join, dirname } from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { getDatabase, DB_PATH, BLOBS_DIR } from "../db/database.js";
|
|
6
|
-
import { ingestDocument, deleteDocument } from "../ingest/pipeline.js";
|
|
7
|
-
import { hybridQuery } from "../retrieval/retriever.js";
|
|
8
|
-
import { exportSnapshot, importSnapshot } from "./snapshot.js";
|
|
9
|
-
import { readBlob } from "../storage/blob_store.js";
|
|
10
|
-
|
|
11
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
-
const __dirname = dirname(__filename);
|
|
13
|
-
|
|
14
|
-
export function findAvailablePort(startPort = 8765, maxPort = 8785) {
|
|
15
|
-
return new Promise((resolve, reject) => {
|
|
16
|
-
let port = startPort;
|
|
17
|
-
const tryPort = () => {
|
|
18
|
-
if (port > maxPort) {
|
|
19
|
-
return reject(new Error(`No free port found between ${startPort} and ${maxPort}`));
|
|
20
|
-
}
|
|
21
|
-
const server = createServer();
|
|
22
|
-
server.listen(port, () => {
|
|
23
|
-
server.close(() => resolve(port));
|
|
24
|
-
});
|
|
25
|
-
server.on("error", () => {
|
|
26
|
-
port++;
|
|
27
|
-
tryPort();
|
|
28
|
-
});
|
|
29
|
-
};
|
|
30
|
-
tryPort();
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function parseJsonBody(req) {
|
|
35
|
-
return new Promise((resolve, reject) => {
|
|
36
|
-
let body = "";
|
|
37
|
-
req.on("data", (chunk) => {
|
|
38
|
-
body += chunk.toString();
|
|
39
|
-
});
|
|
40
|
-
req.on("end", () => {
|
|
41
|
-
try {
|
|
42
|
-
resolve(body ? JSON.parse(body) : {});
|
|
43
|
-
} catch (err) {
|
|
44
|
-
reject(err);
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
req.on("error", reject);
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export async function startAdminServer({ port = null, customDb = null, customBlobDir = BLOBS_DIR } = {}) {
|
|
52
|
-
const db = customDb || getDatabase();
|
|
53
|
-
const selectedPort = port || (await findAvailablePort());
|
|
54
|
-
const htmlPath = join(__dirname, "index.html");
|
|
55
|
-
|
|
56
|
-
const server = createServer(async (req, res) => {
|
|
57
|
-
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
58
|
-
const pathname = url.pathname;
|
|
59
|
-
|
|
60
|
-
// Helper for CORS and JSON response
|
|
61
|
-
const sendJson = (data, status = 200) => {
|
|
62
|
-
res.writeHead(status, {
|
|
63
|
-
"Content-Type": "application/json",
|
|
64
|
-
"Access-Control-Allow-Origin": "*",
|
|
65
|
-
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
66
|
-
"Access-Control-Allow-Headers": "Content-Type",
|
|
67
|
-
});
|
|
68
|
-
res.end(JSON.stringify(data));
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
if (req.method === "OPTIONS") {
|
|
72
|
-
res.writeHead(204, {
|
|
73
|
-
"Access-Control-Allow-Origin": "*",
|
|
74
|
-
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
75
|
-
"Access-Control-Allow-Headers": "Content-Type",
|
|
76
|
-
});
|
|
77
|
-
return res.end();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
// 1. Static HTML SPA
|
|
82
|
-
if (pathname === "/" || pathname === "/index.html") {
|
|
83
|
-
if (!existsSync(htmlPath)) {
|
|
84
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
85
|
-
return res.end("index.html not found");
|
|
86
|
-
}
|
|
87
|
-
const html = readFileSync(htmlPath, "utf-8");
|
|
88
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
89
|
-
return res.end(html);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// 2. API: Stats
|
|
93
|
-
if (pathname === "/api/stats" && req.method === "GET") {
|
|
94
|
-
const docCount = db.prepare("SELECT COUNT(*) as cnt FROM documents").get().cnt;
|
|
95
|
-
const secCount = db.prepare("SELECT COUNT(*) as cnt FROM sections").get().cnt;
|
|
96
|
-
const chunkCount = db.prepare("SELECT COUNT(*) as cnt FROM micro_chunks").get().cnt;
|
|
97
|
-
const edgeCount = db.prepare("SELECT COUNT(*) as cnt FROM graph_edges").get().cnt;
|
|
98
|
-
let dbSize = 0;
|
|
99
|
-
if (existsSync(DB_PATH)) {
|
|
100
|
-
try {
|
|
101
|
-
dbSize = statSync(DB_PATH).size;
|
|
102
|
-
} catch {}
|
|
103
|
-
}
|
|
104
|
-
return sendJson({
|
|
105
|
-
documents: docCount,
|
|
106
|
-
sections: secCount,
|
|
107
|
-
micro_chunks: chunkCount,
|
|
108
|
-
graph_edges: edgeCount,
|
|
109
|
-
db_size_bytes: dbSize,
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// 3. API: Documents List
|
|
114
|
-
if (pathname === "/api/documents" && req.method === "GET") {
|
|
115
|
-
const docs = db.prepare("SELECT * FROM documents ORDER BY updated_at DESC").all();
|
|
116
|
-
return sendJson(docs);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// 4. API: Document Detail
|
|
120
|
-
if (pathname.startsWith("/api/documents/") && req.method === "GET") {
|
|
121
|
-
const docId = pathname.replace("/api/documents/", "");
|
|
122
|
-
const doc = db.prepare("SELECT * FROM documents WHERE id = ?").get(docId);
|
|
123
|
-
if (!doc) return sendJson({ error: "Document not found" }, 404);
|
|
124
|
-
|
|
125
|
-
const sections = db.prepare("SELECT * FROM sections WHERE doc_id = ?").all(docId);
|
|
126
|
-
const microChunks = db.prepare("SELECT id, section_id, token_count FROM micro_chunks WHERE doc_id = ?").all(docId);
|
|
127
|
-
const edges = db.prepare("SELECT * FROM graph_edges WHERE source_id = ? OR target_id = ?").all(docId, docId);
|
|
128
|
-
|
|
129
|
-
let blobContent = null;
|
|
130
|
-
if (doc.blob_hash) {
|
|
131
|
-
try {
|
|
132
|
-
blobContent = await readBlob(doc.blob_hash, customBlobDir);
|
|
133
|
-
} catch {}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return sendJson({ doc, sections, microChunks, edges, blobContent });
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// 5. API: Delete Document
|
|
140
|
-
if (pathname.startsWith("/api/documents/") && req.method === "DELETE") {
|
|
141
|
-
const docId = pathname.replace("/api/documents/", "");
|
|
142
|
-
const result = await deleteDocument(docId, db, customBlobDir);
|
|
143
|
-
return sendJson(result);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// 6. API: Ingest Document
|
|
147
|
-
if (pathname === "/api/ingest" && req.method === "POST") {
|
|
148
|
-
const body = await parseJsonBody(req);
|
|
149
|
-
const result = await ingestDocument({
|
|
150
|
-
content: body.content,
|
|
151
|
-
type: body.type || "text",
|
|
152
|
-
path: body.path || null,
|
|
153
|
-
title: body.title || null,
|
|
154
|
-
generateEmbeddings: body.generateEmbeddings !== false,
|
|
155
|
-
customDb: db,
|
|
156
|
-
customBlobDir,
|
|
157
|
-
});
|
|
158
|
-
return sendJson(result, 201);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// 7. API: Query Knowledge Base
|
|
162
|
-
if (pathname === "/api/query" && req.method === "POST") {
|
|
163
|
-
const body = await parseJsonBody(req);
|
|
164
|
-
const results = await hybridQuery({
|
|
165
|
-
query: body.query,
|
|
166
|
-
limit: body.limit || 5,
|
|
167
|
-
generateEmbeddings: body.generateEmbeddings !== false,
|
|
168
|
-
customDb: db,
|
|
169
|
-
});
|
|
170
|
-
return sendJson({ results });
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// 8. API: Graph Visualizer Data
|
|
174
|
-
if (pathname === "/api/graph" && req.method === "GET") {
|
|
175
|
-
const docs = db.prepare("SELECT id, title, path FROM documents").all();
|
|
176
|
-
const edges = db.prepare("SELECT * FROM graph_edges").all();
|
|
177
|
-
|
|
178
|
-
const nodes = docs.map((d) => ({
|
|
179
|
-
id: d.id,
|
|
180
|
-
label: d.title || d.path || d.id,
|
|
181
|
-
type: "DOCUMENT",
|
|
182
|
-
}));
|
|
183
|
-
|
|
184
|
-
// Add code symbol nodes
|
|
185
|
-
const symbolEdges = edges.filter((e) => e.relation_type === "DEFINES_SYMBOL");
|
|
186
|
-
for (const se of symbolEdges) {
|
|
187
|
-
if (!nodes.some((n) => n.id === se.target_id)) {
|
|
188
|
-
nodes.push({
|
|
189
|
-
id: se.target_id,
|
|
190
|
-
label: se.target_id,
|
|
191
|
-
type: "CODE_SYMBOL",
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
return sendJson({ nodes, edges });
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// 9. API: Export Snapshot
|
|
200
|
-
if (pathname === "/api/snapshot/export" && (req.method === "GET" || req.method === "POST")) {
|
|
201
|
-
const snapshot = await exportSnapshot({ customDb: db, customBlobDir });
|
|
202
|
-
return sendJson(snapshot);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// 10. API: Import Snapshot
|
|
206
|
-
if (pathname === "/api/snapshot/import" && req.method === "POST") {
|
|
207
|
-
const body = await parseJsonBody(req);
|
|
208
|
-
const result = await importSnapshot({ customDb: db, customBlobDir, snapshotPathOrData: body });
|
|
209
|
-
return sendJson(result);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// 404 Fallback
|
|
213
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
214
|
-
res.end("Not Found");
|
|
215
|
-
} catch (err) {
|
|
216
|
-
console.error("Admin server error:", err);
|
|
217
|
-
sendJson({ error: err.message }, 500);
|
|
218
|
-
}
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
return new Promise((resolve) => {
|
|
222
|
-
server.listen(selectedPort, () => {
|
|
223
|
-
const url = `http://localhost:${selectedPort}`;
|
|
224
|
-
console.log(`🚀 memory-agent Web Admin Dashboard running at ${url}`);
|
|
225
|
-
resolve({ server, port: selectedPort, url });
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
}
|