local-ai-memory-bridge 0.0.0-stage โ 0.1.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/LICENSE +21 -0
- package/README.md +103 -2
- package/dist/cli.js +31 -0
- package/dist/compress.js +36 -0
- package/dist/crypto.js +15 -0
- package/dist/embeddings.js +17 -0
- package/dist/keystore.js +34 -0
- package/dist/mcp.js +20 -0
- package/dist/redact.js +54 -0
- package/dist/store.js +122 -0
- package/package.json +42 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Radwan Al-Maswari - Software Developer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,104 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ๐ง Local AI Memory Bridge
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Persistent, encrypted, fully offline long-term memory for AI agents** (Claude Code, Cursor, and any MCP client).
|
|
4
|
+
Your memories never leave your machine.
|
|
5
|
+
|
|
6
|
+
> โ ๏ธ **Status: alpha (v0.1).** Save, search, redaction and the MCP server are tested. `compact` is implemented but has had limited testing.
|
|
7
|
+
|
|
8
|
+
## Why
|
|
9
|
+
|
|
10
|
+
- ๐ **Whole-database encryption** (SQLCipher) plus AES-256-GCM on every memory text
|
|
11
|
+
- ๐ **Master key in your OS keychain** (falls back to a `chmod 600` key file where no keychain exists, e.g. Codespaces or Docker)
|
|
12
|
+
- ๐งน **Secrets redacted before anything is embedded or stored** (API keys, tokens, passwords, JWTs, private keys, credit cards, emails)
|
|
13
|
+
- ๐ **Multilingual semantic search** (`multilingual-e5-small`, 100+ languages including Arabic), running locally via ONNX
|
|
14
|
+
- ๐ **Native MCP server** over stdio
|
|
15
|
+
|
|
16
|
+
## Architecture
|
|
17
|
+
|
|
18
|
+
```mermaid
|
|
19
|
+
flowchart LR
|
|
20
|
+
A[AI Agent<br/>Claude Code / Cursor] -- MCP stdio --> B[MCP Server]
|
|
21
|
+
B --> C[PII Redaction<br/>regex + entropy]
|
|
22
|
+
C --> D[Local Embeddings<br/>e5-small ONNX]
|
|
23
|
+
D --> E[(SQLCipher DB<br/>sqlite-vec + AES-GCM text)]
|
|
24
|
+
K[OS Keychain] -. master key .-> E
|
|
25
|
+
E --> F[Compaction<br/>cluster + dedupe]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git clone https://github.com/radwanalmsora/local-ai-memory-bridge
|
|
32
|
+
cd local-ai-memory-bridge
|
|
33
|
+
npm install
|
|
34
|
+
npx tsx src/cli.ts add "I prefer TypeScript for new projects"
|
|
35
|
+
npx tsx src/cli.ts search "what language do I prefer?"
|
|
36
|
+
npx tsx src/cli.ts setup
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The first run downloads the embedding model (about 100-150 MB) to `~/.memory-bridge/models`.
|
|
40
|
+
|
|
41
|
+
### Connect to Claude Code
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
claude mcp add memory-bridge -- npx -y tsx /path/to/local-ai-memory-bridge/src/cli.ts serve
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Connect to Cursor (`mcp.json`)
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"mcpServers": {
|
|
52
|
+
"memory-bridge": {
|
|
53
|
+
"command": "npx",
|
|
54
|
+
"args": ["-y", "tsx", "/path/to/local-ai-memory-bridge/src/cli.ts", "serve"]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## CLI
|
|
61
|
+
|
|
62
|
+
| Command | Description |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `serve` | Run the MCP server (stdio) |
|
|
65
|
+
| `add <text> [-p project]` | Save a memory (redacted automatically) |
|
|
66
|
+
| `search <query> [-n 5] [-p project]` | Semantic search |
|
|
67
|
+
| `forget <id>` | Delete a memory |
|
|
68
|
+
| `compact [-d 30] [-t 0.93]` | Merge near-duplicate memories older than N days |
|
|
69
|
+
| `stats` | Show counts and DB path |
|
|
70
|
+
| `setup` | Print MCP config snippets |
|
|
71
|
+
|
|
72
|
+
## MCP Tools
|
|
73
|
+
|
|
74
|
+
| Tool | Input | Purpose |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `memory_save` | `text`, `project?` | Store a durable fact or decision |
|
|
77
|
+
| `memory_search` | `query`, `limit?`, `project?` | Semantic recall |
|
|
78
|
+
| `memory_forget` | `id` | Delete a memory |
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
| Env var | Default | Meaning |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `MEMORY_BRIDGE_HOME` | `~/.memory-bridge` | Data directory |
|
|
85
|
+
| `MEMORY_BRIDGE_KEY` | *(unset)* | 64-char hex master key override |
|
|
86
|
+
| `MEMORY_BRIDGE_MODEL` | `Xenova/multilingual-e5-small` | Embedding model |
|
|
87
|
+
| `MEMORY_BRIDGE_MARGIN` | `0.04` | Relevance cut-off relative to the best hit |
|
|
88
|
+
|
|
89
|
+
## Security model and limits
|
|
90
|
+
|
|
91
|
+
- Redaction is best-effort (patterns plus entropy). It will miss some secrets, so don't rely on it as your only control.
|
|
92
|
+
- If the master key is lost, the data cannot be recovered.
|
|
93
|
+
- This protects data at rest on your machine, not against malware running as your user.
|
|
94
|
+
|
|
95
|
+
## Roadmap
|
|
96
|
+
|
|
97
|
+
- [ ] One-command install via npm (`npx`)
|
|
98
|
+
- [ ] Optional NER-based PII detection
|
|
99
|
+
- [ ] Optional local-LLM summarization (Ollama)
|
|
100
|
+
- [ ] Tests and CI
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { MemoryStore } from "./store.js";
|
|
5
|
+
import { serve } from "./mcp.js";
|
|
6
|
+
const program = new Command("memory-bridge")
|
|
7
|
+
.description("Local, encrypted, offline long-term memory for AI agents").version("0.1.0");
|
|
8
|
+
program.command("serve").description("Run the MCP server (stdio)").action(serve);
|
|
9
|
+
program.command("add <text>").option("-p, --project <name>", "project", "default")
|
|
10
|
+
.action(async (text, o) => {
|
|
11
|
+
const r = await (await MemoryStore.open()).add(text, o.project);
|
|
12
|
+
console.log(`saved #${r.id}`, r.redactions.length ? `(redacted: ${r.redactions.join(", ")})` : "");
|
|
13
|
+
});
|
|
14
|
+
program.command("search <query>").option("-n, --limit <n>", "results", "5").option("-p, --project <name>")
|
|
15
|
+
.action(async (q, o) => {
|
|
16
|
+
for (const h of await (await MemoryStore.open()).search(q, +o.limit, o.project))
|
|
17
|
+
console.log(`#${h.id} [${h.score}] ${h.text}\n`);
|
|
18
|
+
});
|
|
19
|
+
program.command("forget <id>").action(async (id) => console.log((await MemoryStore.open()).forget(+id) ? "deleted" : "not found"));
|
|
20
|
+
program.command("stats").action(async () => console.log((await MemoryStore.open()).stats()));
|
|
21
|
+
program.command("compact").option("-d, --days <n>", "older than N days", "30").option("-t, --threshold <x>", "similarity", "0.93")
|
|
22
|
+
.action(async (o) => console.log(await (await MemoryStore.open()).compact(+o.days, +o.threshold)));
|
|
23
|
+
program.command("setup").description("Print MCP config for Claude Code / Cursor").action(() => {
|
|
24
|
+
const script = resolve(process.argv[1]);
|
|
25
|
+
const cmd = script.endsWith(".ts") ? ["npx", "-y", "tsx", script, "serve"] : ["node", script, "serve"];
|
|
26
|
+
console.log("Claude Code:\n claude mcp add memory-bridge -- " + cmd.join(" "));
|
|
27
|
+
console.log("\nCursor / mcp.json:\n" + JSON.stringify({ mcpServers: { "memory-bridge": { command: cmd[0], args: cmd.slice(1) } } }, null, 2));
|
|
28
|
+
});
|
|
29
|
+
await program.parseAsync();
|
|
30
|
+
if (process.argv[2] !== "serve")
|
|
31
|
+
process.exit(0);
|
package/dist/compress.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Local compaction (no LLM): cluster near-duplicates, merge, dedupe lines. */
|
|
2
|
+
export const cosine = (a, b) => {
|
|
3
|
+
let s = 0;
|
|
4
|
+
for (let i = 0; i < a.length; i++)
|
|
5
|
+
s += a[i] * b[i];
|
|
6
|
+
return s; // vectors are normalized
|
|
7
|
+
};
|
|
8
|
+
export function cluster(vecs, threshold) {
|
|
9
|
+
const used = new Set(), groups = [];
|
|
10
|
+
for (let i = 0; i < vecs.length; i++) {
|
|
11
|
+
if (used.has(i))
|
|
12
|
+
continue;
|
|
13
|
+
const g = [i];
|
|
14
|
+
used.add(i);
|
|
15
|
+
for (let j = i + 1; j < vecs.length; j++)
|
|
16
|
+
if (!used.has(j) && cosine(vecs[i], vecs[j]) >= threshold) {
|
|
17
|
+
g.push(j);
|
|
18
|
+
used.add(j);
|
|
19
|
+
}
|
|
20
|
+
groups.push(g);
|
|
21
|
+
}
|
|
22
|
+
return groups;
|
|
23
|
+
}
|
|
24
|
+
export function mergeTexts(texts, maxChars = 1500) {
|
|
25
|
+
const seen = new Set(), lines = [];
|
|
26
|
+
for (const t of texts)
|
|
27
|
+
for (const l of t.split("\n")) {
|
|
28
|
+
const k = l.trim().toLowerCase();
|
|
29
|
+
if (k && !seen.has(k)) {
|
|
30
|
+
seen.add(k);
|
|
31
|
+
lines.push(l.trim());
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const out = lines.join("\n");
|
|
35
|
+
return out.length > maxChars ? out.slice(0, maxChars) + "โฆ" : out;
|
|
36
|
+
}
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
|
|
2
|
+
/** Derive independent subkeys (db / field) from the master key. */
|
|
3
|
+
export const subkey = (master, label) => Buffer.from(hkdfSync("sha256", master, Buffer.alloc(0), label, 32));
|
|
4
|
+
/** AES-256-GCM. Layout: iv(12) | tag(16) | ciphertext */
|
|
5
|
+
export function seal(key, text) {
|
|
6
|
+
const iv = randomBytes(12);
|
|
7
|
+
const c = createCipheriv("aes-256-gcm", key, iv);
|
|
8
|
+
const ct = Buffer.concat([c.update(text, "utf8"), c.final()]);
|
|
9
|
+
return Buffer.concat([iv, c.getAuthTag(), ct]);
|
|
10
|
+
}
|
|
11
|
+
export function open(key, blob) {
|
|
12
|
+
const d = createDecipheriv("aes-256-gcm", key, blob.subarray(0, 12));
|
|
13
|
+
d.setAuthTag(blob.subarray(12, 28));
|
|
14
|
+
return Buffer.concat([d.update(blob.subarray(28)), d.final()]).toString("utf8");
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { env, pipeline } from "@huggingface/transformers";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { HOME } from "./keystore.js";
|
|
4
|
+
/** Local multilingual embeddings (ONNX, int8). Downloaded once, cached locally. */
|
|
5
|
+
env.cacheDir = join(HOME, "models");
|
|
6
|
+
const MODEL = process.env.MEMORY_BRIDGE_MODEL ?? "Xenova/multilingual-e5-small";
|
|
7
|
+
export const DIM = 384;
|
|
8
|
+
let extractor = null;
|
|
9
|
+
const load = () => (extractor ??= pipeline("feature-extraction", MODEL, { dtype: "q8" }));
|
|
10
|
+
async function embed(text) {
|
|
11
|
+
const ex = await load();
|
|
12
|
+
const out = await ex(text, { pooling: "mean", normalize: true });
|
|
13
|
+
return Float32Array.from(out.data);
|
|
14
|
+
}
|
|
15
|
+
// E5 models REQUIRE these prefixes for good quality
|
|
16
|
+
export const embedPassage = (t) => embed("passage: " + t);
|
|
17
|
+
export const embedQuery = (t) => embed("query: " + t);
|
package/dist/keystore.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
export const HOME = process.env.MEMORY_BRIDGE_HOME ?? join(homedir(), ".memory-bridge");
|
|
6
|
+
/** Master key: env override -> OS keychain -> 0600 file fallback. */
|
|
7
|
+
export async function getMasterKey() {
|
|
8
|
+
if (process.env.MEMORY_BRIDGE_KEY)
|
|
9
|
+
return Buffer.from(process.env.MEMORY_BRIDGE_KEY, "hex");
|
|
10
|
+
try {
|
|
11
|
+
const { Entry } = await import("@napi-rs/keyring");
|
|
12
|
+
const entry = new Entry("local-ai-memory-bridge", "master-key");
|
|
13
|
+
let hex = null;
|
|
14
|
+
try {
|
|
15
|
+
hex = entry.getPassword();
|
|
16
|
+
}
|
|
17
|
+
catch { }
|
|
18
|
+
if (!hex) {
|
|
19
|
+
hex = randomBytes(32).toString("hex");
|
|
20
|
+
entry.setPassword(hex);
|
|
21
|
+
}
|
|
22
|
+
console.error("[memory-bridge] key source: OS keychain");
|
|
23
|
+
return Buffer.from(hex, "hex");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// stderr only: stdout is reserved for the MCP protocol
|
|
27
|
+
console.error("[memory-bridge] OS keychain unavailable, using key file (chmod 600)");
|
|
28
|
+
mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
29
|
+
const f = join(HOME, "master.key");
|
|
30
|
+
if (!existsSync(f))
|
|
31
|
+
writeFileSync(f, randomBytes(32).toString("hex"), { mode: 0o600 });
|
|
32
|
+
return Buffer.from(readFileSync(f, "utf8").trim(), "hex");
|
|
33
|
+
}
|
|
34
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { MemoryStore } from "./store.js";
|
|
5
|
+
/** MCP server over stdio. NEVER write to stdout here (it carries the protocol). */
|
|
6
|
+
export async function serve() {
|
|
7
|
+
const store = await MemoryStore.open();
|
|
8
|
+
const server = new McpServer({ name: "local-ai-memory-bridge", version: "0.1.0" });
|
|
9
|
+
const reply = (s) => ({ content: [{ type: "text", text: s }] });
|
|
10
|
+
server.tool("memory_save", "Save a durable fact, decision or preference to long-term local memory. Secrets are auto-redacted.", { text: z.string().min(1), project: z.string().optional() }, async ({ text, project }) => {
|
|
11
|
+
const r = await store.add(text, project);
|
|
12
|
+
return reply(`Saved #${r.id}` + (r.redactions.length ? ` (redacted: ${r.redactions.join(", ")})` : ""));
|
|
13
|
+
});
|
|
14
|
+
server.tool("memory_search", "Semantic search over long-term memory (multilingual, incl. Arabic). Call at the start of a task.", { query: z.string().min(1), limit: z.number().int().min(1).max(20).optional(), project: z.string().optional() }, async ({ query, limit, project }) => {
|
|
15
|
+
const hits = await store.search(query, limit ?? 5, project);
|
|
16
|
+
return reply(hits.length ? hits.map((h) => `#${h.id} [${h.score}] ${h.text}`).join("\n---\n") : "No memories found.");
|
|
17
|
+
});
|
|
18
|
+
server.tool("memory_forget", "Delete a memory by id.", { id: z.number().int() }, async ({ id }) => reply(store.forget(id) ? `Deleted #${id}` : `#${id} not found`));
|
|
19
|
+
await server.connect(new StdioServerTransport());
|
|
20
|
+
}
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** PII / secret redaction: known patterns + Shannon-entropy fallback. */
|
|
2
|
+
// [label, regex, keepPrefix] keepPrefix=true keeps capture group 1
|
|
3
|
+
const RULES = [
|
|
4
|
+
["PRIVATE_KEY", /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, false],
|
|
5
|
+
["AWS_KEY", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, false],
|
|
6
|
+
["GITHUB_TOKEN", /\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b/g, false],
|
|
7
|
+
["ANTHROPIC_KEY", /\bsk-ant-[A-Za-z0-9_-]{20,}/g, false],
|
|
8
|
+
["API_KEY", /\bsk-[A-Za-z0-9_-]{20,}/g, false],
|
|
9
|
+
["SLACK_TOKEN", /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, false],
|
|
10
|
+
["GOOGLE_KEY", /\bAIza[0-9A-Za-z_-]{35}\b/g, false],
|
|
11
|
+
["JWT", /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, false],
|
|
12
|
+
["URL_PASSWORD", /([a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:)[^\s@/]+(?=@)/gi, true],
|
|
13
|
+
["SECRET_ASSIGN", /(\b(?:password|passwd|pwd|secret|token|api[_-]?key|apikey)\s*[:=]\s*)(?:"[^"]+"|'[^']+'|[^\s"',;]{4,})/gi, true],
|
|
14
|
+
["EMAIL", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g, false],
|
|
15
|
+
];
|
|
16
|
+
const entropy = (s) => {
|
|
17
|
+
const f = new Map();
|
|
18
|
+
for (const ch of s)
|
|
19
|
+
f.set(ch, (f.get(ch) ?? 0) + 1);
|
|
20
|
+
return -[...f.values()].reduce((a, n) => a + (n / s.length) * Math.log2(n / s.length), 0);
|
|
21
|
+
};
|
|
22
|
+
const luhn = (d) => {
|
|
23
|
+
let sum = 0, alt = false;
|
|
24
|
+
for (let i = d.length - 1; i >= 0; i--) {
|
|
25
|
+
let n = +d[i];
|
|
26
|
+
if (alt && (n *= 2) > 9)
|
|
27
|
+
n -= 9;
|
|
28
|
+
sum += n;
|
|
29
|
+
alt = !alt;
|
|
30
|
+
}
|
|
31
|
+
return sum % 10 === 0;
|
|
32
|
+
};
|
|
33
|
+
export function redact(input) {
|
|
34
|
+
let text = input;
|
|
35
|
+
const findings = [];
|
|
36
|
+
for (const [label, re, keep] of RULES)
|
|
37
|
+
text = text.replace(re, (...m) => { findings.push(label); return (keep ? m[1] : "") + `[REDACTED:${label}]`; });
|
|
38
|
+
text = text.replace(/\b(?:\d[ -]?){13,19}\b/g, (m) => {
|
|
39
|
+
const d = m.replace(/\D/g, "");
|
|
40
|
+
if (d.length >= 13 && luhn(d)) {
|
|
41
|
+
findings.push("CREDIT_CARD");
|
|
42
|
+
return "[REDACTED:CREDIT_CARD]";
|
|
43
|
+
}
|
|
44
|
+
return m;
|
|
45
|
+
});
|
|
46
|
+
text = text.replace(/[A-Za-z0-9_+=-]{24,}/g, (m) => {
|
|
47
|
+
if (/[A-Za-z]/.test(m) && /\d/.test(m) && entropy(m) > 4.0) {
|
|
48
|
+
findings.push("HIGH_ENTROPY");
|
|
49
|
+
return "[REDACTED:HIGH_ENTROPY]";
|
|
50
|
+
}
|
|
51
|
+
return m;
|
|
52
|
+
});
|
|
53
|
+
return { text, findings };
|
|
54
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import Database from "better-sqlite3-multiple-ciphers";
|
|
2
|
+
import * as sqliteVec from "sqlite-vec";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getMasterKey, HOME } from "./keystore.js";
|
|
6
|
+
import { open, seal, subkey } from "./crypto.js";
|
|
7
|
+
import { redact } from "./redact.js";
|
|
8
|
+
import { DIM, embedPassage, embedQuery } from "./embeddings.js";
|
|
9
|
+
import { cluster, mergeTexts } from "./compress.js";
|
|
10
|
+
const blob = (v) => Buffer.from(v.buffer, v.byteOffset, v.byteLength);
|
|
11
|
+
const toF32 = (b) => new Float32Array(Uint8Array.from(b).buffer);
|
|
12
|
+
export class MemoryStore {
|
|
13
|
+
db;
|
|
14
|
+
fieldKey;
|
|
15
|
+
constructor(db, fieldKey) {
|
|
16
|
+
this.db = db;
|
|
17
|
+
this.fieldKey = fieldKey;
|
|
18
|
+
}
|
|
19
|
+
static async open() {
|
|
20
|
+
mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
21
|
+
const master = await getMasterKey();
|
|
22
|
+
const db = new Database(join(HOME, "memory.db"));
|
|
23
|
+
db.pragma("cipher='sqlcipher'"); // whole-DB encryption
|
|
24
|
+
try {
|
|
25
|
+
db.pragma(`key="x'${subkey(master, "db").toString("hex")}'"`);
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
if (e.code === "SQLITE_NOTADB")
|
|
29
|
+
throw new Error("Cannot decrypt memory.db: the master key does not match. Restore your key (MEMORY_BRIDGE_KEY) or delete ~/.memory-bridge/memory.db to start fresh.");
|
|
30
|
+
throw e;
|
|
31
|
+
}
|
|
32
|
+
sqliteVec.load(db);
|
|
33
|
+
db.exec(`
|
|
34
|
+
CREATE TABLE IF NOT EXISTS memories(
|
|
35
|
+
id INTEGER PRIMARY KEY, project TEXT NOT NULL DEFAULT 'default',
|
|
36
|
+
content BLOB NOT NULL, created_at INTEGER NOT NULL,
|
|
37
|
+
accessed_at INTEGER NOT NULL, hits INTEGER NOT NULL DEFAULT 0,
|
|
38
|
+
compacted INTEGER NOT NULL DEFAULT 0);
|
|
39
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_memories USING vec0(embedding float[${DIM}]);`);
|
|
40
|
+
return new MemoryStore(db, subkey(master, "field"));
|
|
41
|
+
}
|
|
42
|
+
put(project, text, vec, compacted = 0, createdAt = Date.now()) {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const r = this.db.prepare("INSERT INTO memories(project,content,created_at,accessed_at,compacted) VALUES (?,?,?,?,?)").run(project, seal(this.fieldKey, text), createdAt, now, compacted);
|
|
45
|
+
this.db.prepare("INSERT INTO vec_memories(rowid, embedding) VALUES (?, ?)")
|
|
46
|
+
.run(BigInt(r.lastInsertRowid), blob(vec));
|
|
47
|
+
return Number(r.lastInsertRowid);
|
|
48
|
+
}
|
|
49
|
+
async add(raw, project = "default") {
|
|
50
|
+
const { text, findings } = redact(raw); // redact BEFORE embedding/storing
|
|
51
|
+
const vec = await embedPassage(text);
|
|
52
|
+
const id = this.db.transaction(() => this.put(project, text, vec))();
|
|
53
|
+
return { id: id, redactions: findings };
|
|
54
|
+
}
|
|
55
|
+
async search(query, limit = 5, project) {
|
|
56
|
+
const q = await embedQuery(query);
|
|
57
|
+
const rows = this.db.prepare("SELECT rowid, distance FROM vec_memories WHERE embedding MATCH ? AND k = ?").all(blob(q), limit * 4);
|
|
58
|
+
const get = this.db.prepare("SELECT id,project,content,created_at FROM memories WHERE id=?");
|
|
59
|
+
const touch = this.db.prepare("UPDATE memories SET hits=hits+1, accessed_at=? WHERE id=?");
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const r of rows) {
|
|
62
|
+
const m = get.get(Number(r.rowid));
|
|
63
|
+
if (!m || (project && m.project !== project))
|
|
64
|
+
continue;
|
|
65
|
+
touch.run(Date.now(), m.id);
|
|
66
|
+
out.push({ id: m.id, project: m.project, text: open(this.fieldKey, m.content),
|
|
67
|
+
score: +(1 - (r.distance ** 2) / 2).toFixed(3), createdAt: m.created_at });
|
|
68
|
+
if (out.length >= limit)
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
return filterRelevant(out);
|
|
72
|
+
}
|
|
73
|
+
forget(id) {
|
|
74
|
+
return this.db.transaction(() => {
|
|
75
|
+
this.db.prepare("DELETE FROM vec_memories WHERE rowid=?").run(BigInt(id));
|
|
76
|
+
return this.db.prepare("DELETE FROM memories WHERE id=?").run(id).changes > 0;
|
|
77
|
+
})();
|
|
78
|
+
}
|
|
79
|
+
stats() {
|
|
80
|
+
const r = this.db.prepare("SELECT COUNT(*) n, COALESCE(SUM(compacted),0) c FROM memories").get();
|
|
81
|
+
return { memories: r.n, compacted: r.c, path: join(HOME, "memory.db") };
|
|
82
|
+
}
|
|
83
|
+
/** Merge near-duplicate old memories (per project). E5 cosines run high, so threshold ~0.93. */
|
|
84
|
+
async compact(olderThanDays = 30, threshold = 0.93) {
|
|
85
|
+
const cutoff = Date.now() - olderThanDays * 864e5;
|
|
86
|
+
const rows = this.db.prepare("SELECT id,project,content,created_at FROM memories WHERE created_at < ? AND compacted = 0").all(cutoff);
|
|
87
|
+
const byProject = new Map();
|
|
88
|
+
for (const r of rows) {
|
|
89
|
+
if (!byProject.has(r.project))
|
|
90
|
+
byProject.set(r.project, []);
|
|
91
|
+
byProject.get(r.project).push(r);
|
|
92
|
+
}
|
|
93
|
+
const getVec = this.db.prepare("SELECT embedding FROM vec_memories WHERE rowid=?");
|
|
94
|
+
let merged = 0, removed = 0;
|
|
95
|
+
for (const [project, items] of byProject) {
|
|
96
|
+
const vecs = items.map((r) => toF32(getVec.get(BigInt(r.id)).embedding));
|
|
97
|
+
for (const g of cluster(vecs, threshold)) {
|
|
98
|
+
if (g.length < 2)
|
|
99
|
+
continue;
|
|
100
|
+
const text = mergeTexts(g.map((i) => open(this.fieldKey, items[i].content)));
|
|
101
|
+
const vec = await embedPassage(text);
|
|
102
|
+
this.db.transaction(() => {
|
|
103
|
+
this.put(project, text, vec, 1, Math.min(...g.map((i) => items[i].created_at)));
|
|
104
|
+
for (const i of g) {
|
|
105
|
+
this.db.prepare("DELETE FROM vec_memories WHERE rowid=?").run(BigInt(items[i].id));
|
|
106
|
+
this.db.prepare("DELETE FROM memories WHERE id=?").run(items[i].id);
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
109
|
+
merged++;
|
|
110
|
+
removed += g.length;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { merged, removed };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** E5 scores are compressed (~0.7-0.9), so cut relative to the best hit, not by a fixed score. */
|
|
117
|
+
function filterRelevant(h, margin = Number(process.env.MEMORY_BRIDGE_MARGIN ?? 0.04), floor = 0.7) {
|
|
118
|
+
if (!h.length)
|
|
119
|
+
return h;
|
|
120
|
+
const top = h[0].score;
|
|
121
|
+
return h.filter((x) => x.score >= floor && x.score >= top - margin);
|
|
122
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "local-ai-memory-bridge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Encrypted, offline long-term memory for AI agents via MCP",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsx src/cli.ts",
|
|
10
|
+
"prepublishOnly": "npm run build"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [],
|
|
13
|
+
"author": "",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"memory-bridge": "dist/cli.js"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@huggingface/transformers": "^4.3.2",
|
|
21
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
22
|
+
"@napi-rs/keyring": "^2.1.2",
|
|
23
|
+
"better-sqlite3-multiple-ciphers": "^13.0.3",
|
|
24
|
+
"commander": "^15.0.0",
|
|
25
|
+
"sqlite-vec": "^0.1.9",
|
|
26
|
+
"zod": "^4.6.7"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
30
|
+
"@types/node": "^26.6.6",
|
|
31
|
+
"tsx": "^4.23.15",
|
|
32
|
+
"typescript": "^7.0.2"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/radwanalmsora/local-ai-memory-bridge.git"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist"
|
|
43
|
+
]
|
|
44
|
+
}
|