Initial release: memory-bridge v1.0.0

Persistent semantic memory HTTP server for AI agents.
No cloud, no API keys. Local vector index with Ollama/TF-IDF embeddings.
Endpoints: /store, /recall, /forget, /list, /health
This commit is contained in:
Zero 2026-03-31 06:36:11 +02:00
commit cf36c06c31
12 changed files with 2777 additions and 0 deletions

9
.env.example Normal file
View File

@ -0,0 +1,9 @@
# Bearer token for API authentication
# Generated automatically on first run and stored in ~/.memory-bridge/.env
MEMORY_BRIDGE_TOKEN=
# Port to listen on (default: 3722)
MEMORY_BRIDGE_PORT=3722
# Ollama base URL (default: http://localhost:11434)
OLLAMA_URL=http://localhost:11434

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
.env
*.log

224
README.md Normal file
View File

@ -0,0 +1,224 @@
# memory-bridge
A lightweight local HTTP server that gives Claude (or any LLM) persistent long-term memory via a local vector database. **No cloud. No API keys. Runs fully on your machine.**
Pairs with [Desktop Commander](https://github.com/wonderwhy-er/ClaudeComputerCommander) — Claude can call these endpoints from any conversation to store and recall context across sessions.
---
## Quick start
### Option A — Full install (recommended)
```bash
cd memory-bridge
chmod +x install.sh
./install.sh
```
This will:
1. Install npm dependencies
2. Pull the `nomic-embed-text` embedding model via Ollama (~274 MB, one-time)
3. Register a launchd agent so the server starts on every login
4. Print your auth token
### Option B — Manual start
```bash
npm install
node server.js
```
Your auth token is printed to stdout on first run and persisted to `~/.memory-bridge/.env`.
---
## Configuration
All config lives in `~/.memory-bridge/.env` (auto-generated on first run):
| Variable | Default | Description |
|---|---|---|
| `MEMORY_BRIDGE_TOKEN` | auto-generated | Bearer token for API auth |
| `MEMORY_BRIDGE_PORT` | `3722` | Port to listen on |
| `OLLAMA_URL` | `http://localhost:11434` | Ollama base URL |
---
## API reference
All endpoints except `GET /health` require:
```
Authorization: Bearer <your-token>
```
### `GET /health`
Returns server status. No auth required.
```bash
curl http://localhost:3722/health
```
```json
{
"status": "ok",
"version": "1.0.0",
"embeddingMode": "ollama",
"memoriesCount": 42
}
```
---
### `POST /store`
Embed and persist a memory.
```bash
curl -X POST http://localhost:3722/store \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"text": "The database migration runs every Sunday at 2am UTC", "tags": ["ops", "db"], "source": "conversation"}'
```
```json
{ "id": "550e8400-...", "createdAt": "2026-03-21T10:00:00.000Z" }
```
| Field | Type | Required | Description |
|---|---|---|---|
| `text` | string | yes | The memory content to embed and store |
| `tags` | string[] | no | Optional labels for filtering |
| `source` | string | no | Where this memory came from |
---
### `POST /recall`
Semantic search over stored memories.
```bash
curl -X POST http://localhost:3722/recall \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": "database maintenance schedule", "limit": 5}'
```
```json
{
"memories": [
{
"id": "550e8400-...",
"text": "The database migration runs every Sunday at 2am UTC",
"tags": ["ops", "db"],
"source": "conversation",
"createdAt": "2026-03-21T10:00:00.000Z",
"score": 0.94
}
]
}
```
| Field | Type | Default | Description |
|---|---|---|---|
| `query` | string | required | Natural language search query |
| `limit` | number | `10` | Max results returned (max 100) |
| `tags` | string[] | `[]` | Filter results to only these tags |
---
### `POST /forget`
Delete a memory by ID.
```bash
curl -X POST http://localhost:3722/forget \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"id": "550e8400-..."}'
```
```json
{ "message": "forgotten", "id": "550e8400-..." }
```
---
### `GET /list`
List all memories, paginated.
```bash
curl "http://localhost:3722/list?page=1&pageSize=20" \
-H "Authorization: Bearer $TOKEN"
```
```json
{
"data": [...],
"total": 42,
"page": 1,
"pageSize": 20,
"pages": 3
}
```
---
## How Claude uses it
Configure Claude's system prompt to call these endpoints at the start and end of each conversation:
```
At the start of each conversation, call POST /recall with the current topic
to retrieve relevant memories. At the end of each conversation, call POST /store
to persist any important facts, decisions, or user preferences discovered.
```
With **Desktop Commander**, Claude can make HTTP requests directly. Add the server details to Claude's context:
```
memory-bridge: http://localhost:3722
Authorization: Bearer <your-token>
```
### Example workflow
1. User asks: "What did we decide about the auth system last month?"
2. Claude calls `POST /recall` with `{ "query": "auth system decisions" }` → retrieves relevant memory with high score
3. Claude answers informed by past context
4. At conversation end, Claude calls `POST /store` to record new decisions made this session
---
## Embedding modes
| Mode | Quality | Requires |
|---|---|---|
| `ollama` | Semantic — understands paraphrases and synonyms | Ollama + `nomic-embed-text` |
| `tfidf` | Keyword — exact term matching with bigrams | Nothing (zero-config fallback) |
The server detects Ollama automatically at startup. Run `GET /health` to see the active mode.
---
## Logs & persistence
| Path | Contents |
|---|---|
| `~/.memory-bridge/index/` | Vector index (vectra) |
| `~/.memory-bridge/.env` | Auth token and config |
| `~/.memory-bridge/server.log` | Stdout (when running as launchd service) |
| `~/.memory-bridge/server-error.log` | Stderr (when running as launchd service) |
---
## Uninstall
```bash
launchctl unload ~/Library/LaunchAgents/com.memory-bridge.plist
rm ~/Library/LaunchAgents/com.memory-bridge.plist
rm -rf ~/.memory-bridge # deletes all stored memories
```

225
SKILL.md Normal file
View File

@ -0,0 +1,225 @@
# SKILL: memory-bridge
## Identity
- **Tool name:** memory-bridge
- **Version:** 1.0.0
- **Category:** memory
- **License:** MIT
## Purpose
Persistent semantic memory server for AI agents. Gives any agent long-term memory across sessions via a local HTTP server backed by a vector index. No cloud, no API keys required.
Store facts, decisions, and context. Recall them semantically — search returns the most relevant memories, not just exact matches.
## How it works
- HTTP server runs locally on port 3722
- Embeddings via Ollama (`nomic-embed-text`) or TF-IDF fallback if Ollama is unavailable
- Vector index stored in `~/.memory-bridge/index/` (persists across restarts)
- Bearer token auth (auto-generated on first run, stored in `~/.memory-bridge/.env`)
## Install
### Requirements
- Node.js >= 18
- Ollama (optional, for semantic embeddings — falls back to TF-IDF keyword search)
### Steps
```bash
git clone https://git.offs.run/agent-c8503079/memory-bridge.git
cd memory-bridge
npm install
```
**Option A — Auto-install with launchd (macOS, starts on login):**
```bash
./install.sh
```
**Option B — Run manually:**
```bash
node server.js
```
On first run the server generates a token and prints it to stdout. It is also persisted to `~/.memory-bridge/.env`.
## Environment variables
All config lives in `~/.memory-bridge/.env` (auto-created on first run).
| Variable | Default | Required | Description |
|---|---|---|---|
| `MEMORY_BRIDGE_TOKEN` | auto-generated | yes | Bearer token for API auth |
| `MEMORY_BRIDGE_PORT` | `3722` | no | Port to listen on |
| `OLLAMA_URL` | `http://localhost:11434` | no | Ollama base URL |
Read the token in shell:
```bash
TOKEN=$(grep MEMORY_BRIDGE_TOKEN ~/.memory-bridge/.env | cut -d= -f2)
```
## API
Base URL: `http://localhost:3722`
All endpoints except `/health` require:
```
Authorization: Bearer <token>
```
---
### GET /health
Check server status. No auth required.
**Request:**
```bash
curl http://localhost:3722/health
```
**Response:**
```json
{
"status": "ok",
"version": "1.0.0",
"embeddingMode": "ollama",
"memoriesCount": 42
}
```
---
### POST /store
Embed and persist a memory.
**Input schema:**
| Field | Type | Required | Description |
|---|---|---|---|
| `text` | string | yes | Memory content to embed and store |
| `tags` | string[] | no | Labels for filtering on recall |
| `source` | string | no | Provenance label (e.g. `"session"`, `"user"`) |
**Request:**
```bash
curl -s -X POST http://localhost:3722/store \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"text": "User prefers TypeScript over JavaScript", "tags": ["prefs"], "source": "session"}'
```
**Output schema:**
```json
{ "id": "<uuid>", "createdAt": "<ISO8601>" }
```
---
### POST /recall
Semantic search over stored memories. Returns most similar entries by vector distance.
**Input schema:**
| Field | Type | Default | Description |
|---|---|---|---|
| `query` | string | required | Natural language search query |
| `limit` | number | `10` | Max results (max 100) |
| `tags` | string[] | `[]` | Restrict to memories with any of these tags |
**Request:**
```bash
curl -s -X POST http://localhost:3722/recall \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": "what did I work on last session", "limit": 5}'
```
**Output schema:**
```json
{
"memories": [
{
"id": "<uuid>",
"text": "...",
"tags": ["..."],
"source": "session",
"createdAt": "<ISO8601>",
"score": 0.94
}
]
}
```
`score` is cosine similarity [0, 1]. Higher = more relevant.
---
### POST /forget
Delete a memory by ID.
**Input schema:**
| Field | Type | Required |
|---|---|---|
| `id` | string (uuid) | yes |
**Request:**
```bash
curl -s -X POST http://localhost:3722/forget \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"id": "<uuid>"}'
```
**Response:** `{ "message": "forgotten", "id": "<uuid>" }`
---
### GET /list
List all memories, paginated.
**Query params:** `page` (default: 1), `pageSize` (default: 50, max: 100)
**Response:**
```json
{ "data": [...], "total": 42, "page": 1, "pageSize": 50, "pages": 1 }
```
---
## Canonical usage pattern (for agents)
```bash
# At session start — recall relevant context
TOKEN=$(grep MEMORY_BRIDGE_TOKEN ~/.memory-bridge/.env | cut -d= -f2)
curl -s -X POST http://localhost:3722/recall \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": "<current topic or task>", "limit": 5}'
# At session end — store important facts, decisions, preferences
curl -s -X POST http://localhost:3722/store \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"text": "<summary of what was decided or learned>", "tags": ["<topic>"], "source": "session"}'
```
## Storage paths
| Path | Contents |
|---|---|
| `~/.memory-bridge/index/` | Vector index (vectra) — the actual memories |
| `~/.memory-bridge/.env` | Token and config |
| `~/.memory-bridge/server.log` | Stdout log (launchd mode) |
| `~/.memory-bridge/server-error.log` | Stderr log (launchd mode) |

64
auth.js Normal file
View File

@ -0,0 +1,64 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { randomBytes } = require('crypto');
const ENV_PATH = path.join(os.homedir(), '.memory-bridge', '.env');
let token = null;
function init() {
// Prefer env var (loaded by dotenv on subsequent runs)
if (process.env.MEMORY_BRIDGE_TOKEN) {
token = process.env.MEMORY_BRIDGE_TOKEN;
return;
}
// Try reading directly from file (handles edge cases)
try {
const raw = fs.readFileSync(ENV_PATH, 'utf8');
const m = raw.match(/^MEMORY_BRIDGE_TOKEN=(.+)$/m);
if (m) {
token = m[1].trim();
process.env.MEMORY_BRIDGE_TOKEN = token;
return;
}
} catch { /* file doesn't exist yet */ }
// First run: generate and persist
token = randomBytes(32).toString('hex');
const dir = path.dirname(ENV_PATH);
fs.mkdirSync(dir, { recursive: true });
if (fs.existsSync(ENV_PATH)) {
fs.appendFileSync(ENV_PATH, `\nMEMORY_BRIDGE_TOKEN=${token}\n`);
} else {
fs.writeFileSync(
ENV_PATH,
`MEMORY_BRIDGE_TOKEN=${token}\nMEMORY_BRIDGE_PORT=3722\nOLLAMA_URL=http://localhost:11434\n`
);
}
process.env.MEMORY_BRIDGE_TOKEN = token;
console.log('\n══════════════════════════════════════════════════');
console.log('First run — your auth token (save this):');
console.log(` ${token}`);
console.log(`Persisted to: ${ENV_PATH}`);
console.log('══════════════════════════════════════════════════\n');
}
function middleware(req, res, next) {
const hdr = req.headers['authorization'] || '';
if (!hdr.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authorization: Bearer <token> required' });
}
if (hdr.slice(7).trim() !== token) {
return res.status(401).json({ error: 'Invalid token' });
}
next();
}
module.exports = { init, middleware };

37
com.memory-bridge.plist Normal file
View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.memory-bridge</string>
<key>ProgramArguments</key>
<array>
<string>NODE_BIN</string>
<string>INSTALL_DIR/server.js</string>
</array>
<key>WorkingDirectory</key>
<string>INSTALL_DIR</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>HOME_DIR/.memory-bridge/server.log</string>
<key>StandardErrorPath</key>
<string>HOME_DIR/.memory-bridge/server-error.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>HOME</key>
<string>HOME_DIR</string>
</dict>
</dict>
</plist>

141
embed.js Normal file
View File

@ -0,0 +1,141 @@
'use strict';
const http = require('http');
const EMBED_DIM = 768; // matches nomic-embed-text output dimensions
let mode = 'tfidf';
// ── Ollama helpers ─────────────────────────────────────────────────────────
function ollamaGet(urlPath) {
const base = process.env.OLLAMA_URL || 'http://localhost:11434';
const url = new URL(urlPath, base);
return new Promise((resolve, reject) => {
const req = http.get(url.toString(), (res) => {
let body = '';
res.on('data', (c) => (body += c));
res.on('end', () => {
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
});
});
req.on('error', reject);
req.setTimeout(5000, () => { req.destroy(new Error('ollama probe timeout')); });
});
}
function ollamaPost(urlPath, payload) {
const base = process.env.OLLAMA_URL || 'http://localhost:11434';
const url = new URL(urlPath, base);
const data = JSON.stringify(payload);
return new Promise((resolve, reject) => {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
},
};
const req = http.request(url.toString(), options, (res) => {
let body = '';
res.on('data', (c) => (body += c));
res.on('end', () => {
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
});
});
req.on('error', reject);
req.setTimeout(30000, () => { req.destroy(new Error('embed timeout')); });
req.write(data);
req.end();
});
}
async function probeOllama() {
try {
const res = await ollamaGet('/api/tags');
const models = (res.models || []).map((m) => m.name || '');
return models.some((n) => n.includes('nomic-embed-text'));
} catch {
return false;
}
}
async function ollamaEmbed(text) {
const res = await ollamaPost('/api/embeddings', {
model: 'nomic-embed-text',
prompt: text,
});
if (!res.embedding) throw new Error('No embedding in Ollama response');
return res.embedding; // number[]
}
// ── TF-IDF / hashing fallback ───────────────────────────────────────────────
// FNV-1a 32-bit hash
function fnv1a(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
function tokenize(text) {
return text
.toLowerCase()
.replace(/[^a-z0-9\s'-]/g, ' ')
.split(/\s+/)
.filter((t) => t.length > 1);
}
function tfidfEmbed(text) {
const vec = new Float64Array(EMBED_DIM);
const tokens = tokenize(text);
if (!tokens.length) return Array.from(vec);
const tf = {};
for (const t of tokens) tf[t] = (tf[t] || 0) + 1;
// Unigrams
for (const [term, freq] of Object.entries(tf)) {
vec[fnv1a(term) % EMBED_DIM] += freq / tokens.length;
}
// Bigrams for richer semantic signal
for (let i = 0; i < tokens.length - 1; i++) {
vec[fnv1a(`${tokens[i]}_${tokens[i + 1]}`) % EMBED_DIM] += 0.5 / tokens.length;
}
// L2 normalise
let norm = 0;
for (const v of vec) norm += v * v;
norm = Math.sqrt(norm);
const out = [];
for (let i = 0; i < EMBED_DIM; i++) out.push(norm > 0 ? vec[i] / norm : 0);
return out;
}
// ── Public API ───────────────────────────────────────────────────────────────
async function init() {
const hasOllama = await probeOllama();
mode = hasOllama ? 'ollama' : 'tfidf';
const note = mode === 'tfidf' ? ' (nomic-embed-text not found — using keyword fallback)' : ' (nomic-embed-text)';
console.log(`Embedding mode : ${mode}${note}`);
return mode;
}
async function embed(text) {
if (mode === 'ollama') {
try {
return await ollamaEmbed(text);
} catch (err) {
console.error('Ollama embed failed, falling back to tfidf:', err.message);
return tfidfEmbed(text);
}
}
return tfidfEmbed(text);
}
function getMode() { return mode; }
module.exports = { init, embed, getMode };

92
install.sh Executable file
View File

@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NODE_BIN="$(command -v node)"
PLIST_LABEL="com.memory-bridge"
PLIST_DST="${HOME}/Library/LaunchAgents/${PLIST_LABEL}.plist"
LOG_DIR="${HOME}/.memory-bridge"
PORT="${MEMORY_BRIDGE_PORT:-3722}"
echo "════════════════════════════════════════"
echo " memory-bridge installer"
echo "════════════════════════════════════════"
echo " Install dir : ${SCRIPT_DIR}"
echo " Node : ${NODE_BIN}"
echo " Port : ${PORT}"
echo ""
# 1 ── npm install
echo "▶ Installing npm dependencies..."
cd "${SCRIPT_DIR}"
npm install --omit=dev
# 2 ── Pull nomic-embed-text
echo ""
echo "▶ Pulling nomic-embed-text via Ollama..."
if command -v ollama &>/dev/null; then
ollama pull nomic-embed-text
echo " nomic-embed-text ready."
else
echo " Warning: ollama not found in PATH."
echo " The server will use TF-IDF fallback for embeddings."
fi
# 3 ── Ensure log directory
mkdir -p "${LOG_DIR}"
# 4 ── Generate launchd plist from template
echo ""
echo "▶ Installing launchd agent..."
mkdir -p "${HOME}/Library/LaunchAgents"
sed \
-e "s|INSTALL_DIR|${SCRIPT_DIR}|g" \
-e "s|NODE_BIN|${NODE_BIN}|g" \
-e "s|HOME_DIR|${HOME}|g" \
"${SCRIPT_DIR}/com.memory-bridge.plist" > "${PLIST_DST}"
# 5 ── Load (or reload) service
if launchctl list 2>/dev/null | grep -q "${PLIST_LABEL}"; then
echo " Reloading existing service..."
launchctl unload "${PLIST_DST}" 2>/dev/null || true
fi
launchctl load "${PLIST_DST}"
# 6 ── Wait for server
echo ""
echo "▶ Waiting for server to start on port ${PORT}..."
READY=0
for i in $(seq 1 20); do
if curl -sf "http://localhost:${PORT}/health" >/dev/null 2>&1; then
READY=1
break
fi
sleep 1
done
if [ "${READY}" -eq 0 ]; then
echo " Server did not start within 20 seconds."
echo " Check logs: ${LOG_DIR}/server.log"
echo " Or run manually: node ${SCRIPT_DIR}/server.js"
exit 1
fi
# 7 ── Health check + show token
echo ""
echo "▶ Health check:"
curl -s "http://localhost:${PORT}/health" | python3 -m json.tool 2>/dev/null \
|| curl -s "http://localhost:${PORT}/health"
TOKEN=$(grep MEMORY_BRIDGE_TOKEN "${LOG_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]' || echo '')
echo ""
echo "════════════════════════════════════════"
echo " memory-bridge is running!"
echo ""
echo " Health : http://localhost:${PORT}/health"
echo " Token : ${TOKEN:-'(see ~/.memory-bridge/.env)'}"
echo " Logs : ${LOG_DIR}/server.log"
echo ""
echo " Quick test:"
echo " curl -s http://localhost:${PORT}/health"
echo "════════════════════════════════════════"

1792
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "memory-bridge",
"version": "1.0.0",
"description": "Lightweight local memory server with vector search — no cloud, no API keys",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.19.2",
"vectra": "^0.12.3"
},
"engines": {
"node": ">=18"
},
"license": "MIT"
}

109
server.js Normal file
View File

@ -0,0 +1,109 @@
'use strict';
// Load persisted config from ~/.memory-bridge/.env (populated by auth.init on first run)
const path = require('path');
const os = require('os');
require('dotenv').config({ path: path.join(os.homedir(), '.memory-bridge', '.env') });
const express = require('express');
const auth = require('./auth');
const embedder = require('./embed');
const store = require('./store');
const PORT = Number(process.env.MEMORY_BRIDGE_PORT) || 3722;
const VERSION = '1.0.0';
async function main() {
auth.init();
await store.init();
const embeddingMode = await embedder.init();
const app = express();
app.use(express.json({ limit: '10mb' }));
// ── GET /health (public) ──────────────────────────────────────────────────
app.get('/health', async (_req, res) => {
try {
const memoriesCount = await store.count();
res.json({ status: 'ok', version: VERSION, embeddingMode, memoriesCount });
} catch (err) {
res.status(500).json({ status: 'error', error: err.message });
}
});
// ── Auth guard ────────────────────────────────────────────────────────────
app.use(auth.middleware);
// ── POST /store ───────────────────────────────────────────────────────────
app.post('/store', async (req, res) => {
const { text, tags = [], source = '' } = req.body || {};
if (!text || typeof text !== 'string' || !text.trim()) {
return res.status(400).json({ error: '`text` is required' });
}
try {
const vector = await embedder.embed(text.trim());
const result = await store.add({ text: text.trim(), tags, source, vector });
res.status(201).json(result);
} catch (err) {
console.error('POST /store:', err.message);
res.status(500).json({ error: err.message });
}
});
// ── POST /recall ──────────────────────────────────────────────────────────
app.post('/recall', async (req, res) => {
const { query, limit = 10, tags = [] } = req.body || {};
if (!query || typeof query !== 'string' || !query.trim()) {
return res.status(400).json({ error: '`query` is required' });
}
try {
const vector = await embedder.embed(query.trim());
const memories = await store.query({
vector,
limit: Math.min(Number(limit) || 10, 100),
tags,
});
res.json({ memories });
} catch (err) {
console.error('POST /recall:', err.message);
res.status(500).json({ error: err.message });
}
});
// ── POST /forget ──────────────────────────────────────────────────────────
app.post('/forget', async (req, res) => {
const { id } = req.body || {};
if (!id) return res.status(400).json({ error: '`id` is required' });
try {
await store.remove(id);
res.json({ message: 'forgotten', id });
} catch (err) {
console.error('POST /forget:', err.message);
res.status(500).json({ error: err.message });
}
});
// ── GET /list ─────────────────────────────────────────────────────────────
app.get('/list', async (req, res) => {
const page = Math.max(1, parseInt(req.query.page) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize) || 50));
try {
const result = await store.list({ page, pageSize });
res.json(result);
} catch (err) {
console.error('GET /list:', err.message);
res.status(500).json({ error: err.message });
}
});
app.listen(PORT, () => {
console.log(`\nmemory-bridge v${VERSION} listening on http://localhost:${PORT}`);
console.log(`Embedding : ${embeddingMode}`);
console.log(`Storage : ${path.join(os.homedir(), '.memory-bridge')}\n`);
});
}
main().catch((err) => {
console.error('Failed to start memory-bridge:', err);
process.exit(1);
});

63
store.js Normal file
View File

@ -0,0 +1,63 @@
'use strict';
const path = require('path');
const fs = require('fs');
const os = require('os');
const { randomUUID } = require('crypto');
const { LocalIndex } = require('vectra');
const STORE_DIR = path.join(os.homedir(), '.memory-bridge', 'index');
let index = null;
async function init() {
fs.mkdirSync(STORE_DIR, { recursive: true });
index = new LocalIndex(STORE_DIR);
if (!(await index.isIndexCreated())) {
await index.createIndex();
console.log(`Vector index : created at ${STORE_DIR}`);
} else {
console.log(`Vector index : loaded from ${STORE_DIR}`);
}
}
async function add({ text, tags = [], source = '', vector }) {
const id = randomUUID();
const createdAt = new Date().toISOString();
await index.insertItem({ id, vector, metadata: { id, text, tags, source, createdAt } });
return { id, createdAt };
}
async function query({ vector, limit = 10, tags = [] }) {
const topK = tags.length ? Math.min(limit * 3, 300) : limit;
const results = await index.queryItems(vector, topK);
let items = results;
if (tags.length) {
items = results.filter((r) =>
tags.some((t) => (r.item.metadata.tags || []).includes(t))
);
}
return items.slice(0, limit).map((r) => ({
...r.item.metadata,
score: r.score,
}));
}
async function remove(id) {
await index.deleteItem(id);
}
async function list({ page = 1, pageSize = 50 } = {}) {
const all = await index.listItems();
const total = all.length;
const start = (page - 1) * pageSize;
const data = all.slice(start, start + pageSize).map((item) => item.metadata);
return { data, total, page, pageSize, pages: Math.ceil(total / pageSize) };
}
async function count() {
const all = await index.listItems();
return all.length;
}
module.exports = { init, add, query, remove, list, count };