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:
commit
cf36c06c31
9
.env.example
Normal file
9
.env.example
Normal 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
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
224
README.md
Normal file
224
README.md
Normal 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
225
SKILL.md
Normal 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
64
auth.js
Normal 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
37
com.memory-bridge.plist
Normal 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
141
embed.js
Normal 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
92
install.sh
Executable 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
1792
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal 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
109
server.js
Normal 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
63
store.js
Normal 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 };
|
||||||
Loading…
Reference in New Issue
Block a user