Initial project scaffold: Open Brain self-hosted knowledge infrastructure
Node.js/TypeScript server with MCP endpoint for AI assistant integration, Discord bot for thought capture and slash commands, PostgreSQL + pgvector for semantic search, and OpenRouter for embeddings/metadata extraction. Dockerized for deployment behind Caddy. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Database (host.docker.internal = host machine from inside Docker)
|
||||||
|
DATABASE_URL=postgresql://brain_user:password@host.docker.internal:5432/brain
|
||||||
|
|
||||||
|
# OpenRouter (AI gateway for embeddings + metadata extraction)
|
||||||
|
OPENROUTER_API_KEY=sk-or-...
|
||||||
|
|
||||||
|
# Discord bot
|
||||||
|
DISCORD_BOT_TOKEN=
|
||||||
|
DISCORD_CAPTURE_CHANNEL_ID=
|
||||||
|
|
||||||
|
# MCP endpoint authentication (generate with: openssl rand -hex 32)
|
||||||
|
MCP_ACCESS_KEY=
|
||||||
|
|
||||||
|
# Server port
|
||||||
|
PORT=3100
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
EXPOSE 3100
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
open-brain:
|
||||||
|
build: .
|
||||||
|
container_name: open-brain
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3100:3100"
|
||||||
|
env_file: .env
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
2171
package-lock.json
generated
Normal file
2171
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "open-brain",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Self-hosted personal knowledge infrastructure with MCP and Discord",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "tsx src/index.ts"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.option.design/will/open-brain.git"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.19.11",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
|
"discord.js": "^14.25.1",
|
||||||
|
"hono": "^4.12.5",
|
||||||
|
"pg": "^8.20.0",
|
||||||
|
"pgvector": "^0.2.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.3.5",
|
||||||
|
"@types/pg": "^8.18.0",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
377
project_plan.MD
Normal file
377
project_plan.MD
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
Open Brain — Self-Hosted Implementation Plan
|
||||||
|
|
||||||
|
Context
|
||||||
|
|
||||||
|
Build a self-hosted personal knowledge infrastructure ("Open Brain") on a DigitalOcean VPS. Based on the guide at promptkit.natebjones.com, adapted to replace Supabase with self-hosted components running in Docker behind Caddy.
|
||||||
|
|
||||||
|
What it does: A single PostgreSQL database that any AI assistant can read from and write to via MCP (Model Context Protocol). Thoughts can also be captured and queried through a Discord bot. Every thought gets a vector embedding for
|
||||||
|
semantic search and structured metadata (type, topics, people, action items) extracted by an LLM.
|
||||||
|
|
||||||
|
Two input paths:
|
||||||
|
- Discord #capture channel — type a thought, bot ingests it
|
||||||
|
- MCP capture_thought tool — any connected AI can save a thought directly
|
||||||
|
|
||||||
|
Three output paths:
|
||||||
|
- MCP tools — AI assistants call search_thoughts, list_thoughts, thought_stats
|
||||||
|
- Discord slash commands — /brain search, /brain recent, /brain stats
|
||||||
|
- Direct SQL via SSH — admin/debug access
|
||||||
|
|
||||||
|
---
|
||||||
|
Decisions
|
||||||
|
|
||||||
|
┌──────────────────┬──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Decision │ Choice │
|
||||||
|
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Database │ Existing PostgreSQL on VPS + pgvector extension │
|
||||||
|
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Schema │ Includes user_id column (future-proofing for multi-user) │
|
||||||
|
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Runtime │ Node.js / TypeScript │
|
||||||
|
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Deployment │ Dockerized (Dockerfile + docker-compose.yml) │
|
||||||
|
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Messaging │ Discord bot (new bot, existing server) with capture + query commands │
|
||||||
|
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ AI gateway │ OpenRouter (text-embedding-3-small + gpt-4o-mini) │
|
||||||
|
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Routing │ Subdomain brain.option.design via host-level Caddy │
|
||||||
|
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Security │ 64-char hex key on MCP endpoint + Caddy rate limiting (30 req/min) │
|
||||||
|
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ MCP write access │ Enabled — capture_thought tool included │
|
||||||
|
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Backups │ Not included — user will handle separately │
|
||||||
|
├──────────────────┼──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Repo │ https://git.option.design/will/open-brain.git │
|
||||||
|
└──────────────────┴──────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
---
|
||||||
|
Architecture
|
||||||
|
|
||||||
|
Internet (HTTPS)
|
||||||
|
|
|
||||||
|
Caddy (host-level, auto-TLS)
|
||||||
|
rate_limit: 30 req/min per IP
|
||||||
|
|
|
||||||
|
brain.option.design → localhost:3100
|
||||||
|
|
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ Docker: open-brain │
|
||||||
|
│ │
|
||||||
|
│ Hono HTTP Server (:3100) │
|
||||||
|
│ ├── POST /mcp ─┼──→ AI clients (Claude Desktop,
|
||||||
|
│ │ (MCP StreamableHTTP) │ ChatGPT, Claude Code, Cursor)
|
||||||
|
│ │ Auth: x-brain-key │ call this endpoint over HTTPS
|
||||||
|
│ │ │
|
||||||
|
│ └── Discord.js bot ─┼──→ Discord #capture channel
|
||||||
|
│ (outbound WebSocket) │ (bot connects OUT to Discord,
|
||||||
|
│ ├── message capture │ no inbound traffic needed)
|
||||||
|
│ └── slash commands │
|
||||||
|
│ /brain search │
|
||||||
|
│ /brain recent │
|
||||||
|
│ /brain stats │
|
||||||
|
│ │
|
||||||
|
│ Shared ingest pipeline │
|
||||||
|
│ ├── getEmbedding() ─┼──→ OpenRouter API
|
||||||
|
│ └── extractMetadata() ─┼──→ OpenRouter API
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
PostgreSQL + pgvector (host)
|
||||||
|
└── brain database
|
||||||
|
└── thoughts table
|
||||||
|
|
||||||
|
How each actor accesses the system
|
||||||
|
|
||||||
|
┌───────────────────────────────────┬─────────────────────────────────┬──────────────────────────────────────────────┬──────────────────────────┐
|
||||||
|
│ Actor │ Path │ Auth │ Direction │
|
||||||
|
├───────────────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────────┼──────────────────────────┤
|
||||||
|
│ Claude Desktop / ChatGPT / Cursor │ HTTPS → Caddy → :3100/mcp │ MCP key (configured once in client settings) │ Client calls your server │
|
||||||
|
├───────────────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────────┼──────────────────────────┤
|
||||||
|
│ Claude Code │ Same as above │ MCP key (configured via claude mcp add) │ Client calls your server │
|
||||||
|
├───────────────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────────┼──────────────────────────┤
|
||||||
|
│ Discord (you typing in #capture) │ Discord servers → bot WebSocket │ Discord login │ Bot connects outward │
|
||||||
|
├───────────────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────────┼──────────────────────────┤
|
||||||
|
│ Discord slash commands │ Same WebSocket │ Discord login │ Bot connects outward │
|
||||||
|
├───────────────────────────────────┼─────────────────────────────────┼──────────────────────────────────────────────┼──────────────────────────┤
|
||||||
|
│ You via SSH │ SSH → psql │ SSH key (existing) │ Direct DB access │
|
||||||
|
└───────────────────────────────────┴─────────────────────────────────┴──────────────────────────────────────────────┴──────────────────────────┘
|
||||||
|
|
||||||
|
Note: The MCP endpoint at brain.option.design is public-facing (reachable from the internet) because AI clients like Claude Desktop and ChatGPT call it from their own servers. It is protected by a 64-character hex key over HTTPS and
|
||||||
|
rate-limited at 30 requests/minute per IP. Without the correct key, all requests are rejected with 401.
|
||||||
|
|
||||||
|
---
|
||||||
|
Implementation Steps
|
||||||
|
|
||||||
|
Step 1: PostgreSQL + pgvector
|
||||||
|
|
||||||
|
Verify pgvector is installed on the existing PostgreSQL; install if needed. Create database, user, and schema.
|
||||||
|
|
||||||
|
File: sql/schema.sql
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
CREATE TABLE thoughts (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL DEFAULT 'default',
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
embedding VECTOR(1536),
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
source TEXT NOT NULL DEFAULT 'mcp', -- 'discord' or 'mcp'
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ON thoughts USING hnsw (embedding vector_cosine_ops);
|
||||||
|
CREATE INDEX ON thoughts USING gin (metadata);
|
||||||
|
CREATE INDEX ON thoughts (created_at DESC);
|
||||||
|
CREATE INDEX ON thoughts (user_id);
|
||||||
|
|
||||||
|
-- Auto-update trigger for updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN NEW.updated_at = now(); RETURN NEW; END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER thoughts_updated_at BEFORE UPDATE ON thoughts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
-- Semantic search function
|
||||||
|
CREATE OR REPLACE FUNCTION match_thoughts(
|
||||||
|
query_embedding VECTOR(1536),
|
||||||
|
match_threshold FLOAT DEFAULT 0.7,
|
||||||
|
match_count INT DEFAULT 10,
|
||||||
|
filter JSONB DEFAULT '{}'::jsonb
|
||||||
|
) RETURNS TABLE (
|
||||||
|
id UUID, content TEXT, metadata JSONB,
|
||||||
|
similarity FLOAT, created_at TIMESTAMPTZ
|
||||||
|
) LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT t.id, t.content, t.metadata,
|
||||||
|
1 - (t.embedding <=> query_embedding) AS similarity,
|
||||||
|
t.created_at
|
||||||
|
FROM thoughts t
|
||||||
|
WHERE 1 - (t.embedding <=> query_embedding) > match_threshold
|
||||||
|
ORDER BY t.embedding <=> query_embedding
|
||||||
|
LIMIT match_count;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
Deploy: SSH to VPS, run psql -d brain -f sql/schema.sql
|
||||||
|
|
||||||
|
Step 2: Node.js/TypeScript Project
|
||||||
|
|
||||||
|
Project structure:
|
||||||
|
open-brain/
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── .env.example
|
||||||
|
├── .gitignore
|
||||||
|
├── sql/
|
||||||
|
│ └── schema.sql
|
||||||
|
└── src/
|
||||||
|
├── index.ts — Hono server entry, starts HTTP + Discord bot
|
||||||
|
├── db.ts — pg client with pgvector, query helpers
|
||||||
|
├── ai.ts — OpenRouter: getEmbedding() + extractMetadata()
|
||||||
|
├── discord.ts — Discord.js bot: message capture + slash commands
|
||||||
|
├── mcp.ts — MCP server with 4 tool definitions
|
||||||
|
├── ingest.ts — Shared pipeline: embed → extract metadata → store
|
||||||
|
└── types.ts — TypeScript interfaces (Thought, Metadata)
|
||||||
|
|
||||||
|
Key dependencies:
|
||||||
|
- @modelcontextprotocol/sdk — MCP protocol server
|
||||||
|
- hono + @hono/node-server — HTTP framework on Node.js
|
||||||
|
- discord.js — Discord gateway bot
|
||||||
|
- pg + pgvector — PostgreSQL client with vector support
|
||||||
|
- zod — schema validation for MCP tool inputs
|
||||||
|
|
||||||
|
Step 3: Shared Ingest Pipeline (src/ingest.ts)
|
||||||
|
|
||||||
|
Both Discord and MCP capture_thought use the same pipeline:
|
||||||
|
|
||||||
|
Input text
|
||||||
|
→ getEmbedding(text) — OpenRouter text-embedding-3-small → 1536-dim vector
|
||||||
|
→ extractMetadata(text) — OpenRouter gpt-4o-mini → structured JSON
|
||||||
|
→ INSERT into thoughts table — content + embedding + metadata + source tag
|
||||||
|
→ Return confirmation — "Captured as [type] — [topics]"
|
||||||
|
|
||||||
|
Metadata schema extracted by LLM:
|
||||||
|
{
|
||||||
|
"type": "observation | task | idea | reference | person_note",
|
||||||
|
"topics": ["tag1", "tag2"],
|
||||||
|
"people": ["name1"],
|
||||||
|
"action_items": ["todo1"],
|
||||||
|
"dates_mentioned": ["YYYY-MM-DD"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Step 4: MCP Server (src/mcp.ts)
|
||||||
|
|
||||||
|
Exposed at POST /mcp via StreamableHTTP transport. Authenticated with x-brain-key header or ?key= query param.
|
||||||
|
|
||||||
|
┌─────────────────┬───────────────────────────────────────────────────────────────────────────────┬────────────────────────────────────────────────────┐
|
||||||
|
│ Tool │ Input │ What it does │
|
||||||
|
├─────────────────┼───────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────┤
|
||||||
|
│ search_thoughts │ query: string, limit?: number, threshold?: number │ Embed query → cosine similarity search in pgvector │
|
||||||
|
├─────────────────┼───────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────┤
|
||||||
|
│ list_thoughts │ limit?: number, type?: string, topic?: string, person?: string, days?: number │ Filter thoughts by metadata fields, return recent │
|
||||||
|
├─────────────────┼───────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────┤
|
||||||
|
│ thought_stats │ (none) │ Aggregate counts by type, topic, recent activity │
|
||||||
|
├─────────────────┼───────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────┤
|
||||||
|
│ capture_thought │ content: string │ Run full ingest pipeline, return confirmation │
|
||||||
|
└─────────────────┴───────────────────────────────────────────────────────────────────────────────┴────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Each tool is defined with Zod schemas and registered on the MCP server. The AI client discovers them automatically on connection.
|
||||||
|
|
||||||
|
Step 5: Discord Bot (src/discord.ts)
|
||||||
|
|
||||||
|
Setup (manual, one-time):
|
||||||
|
1. Create Discord Application at discord.com/developers/applications
|
||||||
|
2. Create Bot user, enable MESSAGE CONTENT privileged intent
|
||||||
|
3. Generate and copy bot token → goes in .env
|
||||||
|
4. Invite to server with permissions: Read/Send Messages, Read Message History, Use Slash Commands
|
||||||
|
5. Designate #capture channel, copy channel ID → goes in .env
|
||||||
|
|
||||||
|
Message capture behavior:
|
||||||
|
- Listens for messages in configured channel only
|
||||||
|
- Ignores bot messages and system messages
|
||||||
|
- Runs ingest pipeline on valid messages
|
||||||
|
- Replies with: Captured as [type] — [topic1], [topic2]
|
||||||
|
|
||||||
|
Slash commands:
|
||||||
|
- /brain search <query> — semantic search, returns top results
|
||||||
|
- /brain recent [count] — list recent thoughts
|
||||||
|
- /brain stats — show aggregate stats
|
||||||
|
|
||||||
|
Step 6: OpenRouter Integration (src/ai.ts)
|
||||||
|
|
||||||
|
Two API calls to https://openrouter.ai/api/v1/:
|
||||||
|
|
||||||
|
- getEmbedding(text): POST /embeddings, model text-embedding-3-small → returns 1536-dim float array
|
||||||
|
- extractMetadata(text): POST /chat/completions, model gpt-4o-mini, system prompt requesting JSON matching the metadata schema above
|
||||||
|
|
||||||
|
Estimated cost: ~$0.10–0.30/month at 20 thoughts/day.
|
||||||
|
|
||||||
|
Step 7: Docker Setup
|
||||||
|
|
||||||
|
Dockerfile — Multi-stage: Node.js 22 Alpine, install deps, compile TypeScript, run compiled JS in slim image.
|
||||||
|
|
||||||
|
docker-compose.yml:
|
||||||
|
services:
|
||||||
|
open-brain:
|
||||||
|
build: .
|
||||||
|
container_name: open-brain
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3100:3100"
|
||||||
|
env_file: .env
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
host.docker.internal lets the container reach the host's PostgreSQL. May require updating pg_hba.conf to allow connections from Docker's bridge subnet (typically 172.17.0.0/16).
|
||||||
|
|
||||||
|
Step 8: Caddy Configuration
|
||||||
|
|
||||||
|
Add to existing Caddyfile on the VPS:
|
||||||
|
|
||||||
|
brain.option.design {
|
||||||
|
rate_limit {remote.host} 30r/m
|
||||||
|
reverse_proxy localhost:3100
|
||||||
|
}
|
||||||
|
|
||||||
|
DNS: Add A record for brain.option.design → VPS IP. Caddy auto-provisions TLS certificate.
|
||||||
|
|
||||||
|
Note: Caddy's rate limiting requires the caddy-ratelimit plugin. If not available, we can implement rate limiting in the Node.js app instead (simpler, no Caddy plugin needed).
|
||||||
|
|
||||||
|
Step 9: AI Client Configuration
|
||||||
|
|
||||||
|
MCP URL: https://brain.option.design/mcp
|
||||||
|
|
||||||
|
┌────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client │ How to connect │
|
||||||
|
├────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Claude Desktop │ Settings → Connectors → Add remote MCP server → paste URL with ?key=ACCESS_KEY │
|
||||||
|
├────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Claude Code │ claude mcp add --transport http open-brain https://brain.option.design/mcp --header "x-brain-key: ACCESS_KEY" │
|
||||||
|
├────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ChatGPT │ Developer Mode → Apps & Connectors → Create → paste URL with ?key=ACCESS_KEY │
|
||||||
|
├────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Cursor │ Add to .cursor/mcp.json with URL and key header │
|
||||||
|
└────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
---
|
||||||
|
Environment Variables (.env)
|
||||||
|
|
||||||
|
# Database (host.docker.internal = host machine from inside Docker)
|
||||||
|
DATABASE_URL=postgresql://brain_user:password@host.docker.internal:5432/brain
|
||||||
|
|
||||||
|
# OpenRouter (AI gateway for embeddings + metadata extraction)
|
||||||
|
OPENROUTER_API_KEY=sk-or-...
|
||||||
|
|
||||||
|
# Discord bot
|
||||||
|
DISCORD_BOT_TOKEN=...
|
||||||
|
DISCORD_CAPTURE_CHANNEL_ID=...
|
||||||
|
|
||||||
|
# MCP endpoint authentication (generate with: openssl rand -hex 32)
|
||||||
|
MCP_ACCESS_KEY=...
|
||||||
|
|
||||||
|
# Server port
|
||||||
|
PORT=3100
|
||||||
|
|
||||||
|
---
|
||||||
|
Verification
|
||||||
|
|
||||||
|
1. Database: SSH to VPS → psql -d brain -c "SELECT count(*) FROM thoughts;" → returns 0
|
||||||
|
2. Discord capture: Send "Sarah is thinking about leaving her job to start consulting" in #capture → bot replies: Captured as person_note — career, consulting
|
||||||
|
3. Discord query: Type /brain search career → bot returns the thought about Sarah
|
||||||
|
4. Discord stats: Type /brain stats → bot shows 1 thought, type: person_note
|
||||||
|
5. MCP capture: In Claude Code, say "Remember that I prefer dark mode in all apps" → thought stored in DB
|
||||||
|
6. MCP search: Ask Claude "What do you know about my preferences?" → returns the dark mode thought
|
||||||
|
7. MCP list: Ask Claude "Show me recent thoughts" → returns both thoughts
|
||||||
|
8. Rate limit: Rapid-fire 30+ requests → subsequent requests get throttled
|
||||||
|
|
||||||
|
---
|
||||||
|
Current Status as of 3/6/2026 2:51:00 PM CST
|
||||||
|
---
|
||||||
|
|
||||||
|
● The project is fully scaffolded and compiles cleanly. Here's a summary of what was built:
|
||||||
|
Files Created
|
||||||
|
┌────────────────────┬─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ File │ Purpose │
|
||||||
|
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ sql/schema.sql │ PostgreSQL schema with pgvector, match_thoughts() function, indexes │
|
||||||
|
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ src/types.ts │ TypeScript interfaces for Thought, ThoughtMetadata, etc. │
|
||||||
|
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ src/db.ts │ PostgreSQL client — insert, search, list, stats queries │
|
||||||
|
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ src/ai.ts │ OpenRouter API — getEmbedding() + extractMetadata() │
|
||||||
|
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ src/ingest.ts │ Shared ingest pipeline — embed + extract + store │
|
||||||
|
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ src/mcp.ts │ MCP server with 4 tools (search, list, stats, capture) │
|
||||||
|
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ src/discord.ts │ Discord bot — message capture + /brain slash commands │
|
||||||
|
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ src/index.ts │ Hono HTTP server entry point, MCP auth, starts Discord bot │
|
||||||
|
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Dockerfile │ Multi-stage Docker build (Node 22 Alpine) │
|
||||||
|
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ docker-compose.yml │ Container config with host.docker.internal for PostgreSQL │
|
||||||
|
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ .env.example │ Template for environment variables │
|
||||||
|
├────────────────────┼─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ .gitignore │ Excludes node_modules, dist, .env │
|
||||||
|
└────────────────────┴─────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Next Steps (on your VPS)
|
||||||
|
|
||||||
|
1. Create Discord bot at discord.com/developers — get bot token, enable MESSAGE CONTENT intent, invite to server
|
||||||
|
2. Create .env from .env.example with your real credentials
|
||||||
|
3. Run schema on PostgreSQL: psql -d brain -f sql/schema.sql (ensure pgvector extension is available)
|
||||||
|
4. Add DNS A record for brain.option.design → VPS IP
|
||||||
|
5. Add Caddy block for brain.option.design → localhost:3100
|
||||||
|
6. Deploy: docker compose up -d --build
|
||||||
|
7. Connect AI clients using the MCP URL
|
||||||
|
|
||||||
|
Want me to commit this to the repo, or would you like to review anything first?
|
||||||
58
sql/schema.sql
Normal file
58
sql/schema.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thoughts (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL DEFAULT 'default',
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
embedding VECTOR(1536),
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
source TEXT NOT NULL DEFAULT 'mcp',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thoughts_embedding_idx ON thoughts USING hnsw (embedding vector_cosine_ops);
|
||||||
|
CREATE INDEX IF NOT EXISTS thoughts_metadata_idx ON thoughts USING gin (metadata);
|
||||||
|
CREATE INDEX IF NOT EXISTS thoughts_created_at_idx ON thoughts (created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS thoughts_user_id_idx ON thoughts (user_id);
|
||||||
|
|
||||||
|
-- Auto-update trigger for updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS thoughts_updated_at ON thoughts;
|
||||||
|
CREATE TRIGGER thoughts_updated_at
|
||||||
|
BEFORE UPDATE ON thoughts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
-- Semantic search function
|
||||||
|
CREATE OR REPLACE FUNCTION match_thoughts(
|
||||||
|
query_embedding VECTOR(1536),
|
||||||
|
match_threshold FLOAT DEFAULT 0.7,
|
||||||
|
match_count INT DEFAULT 10,
|
||||||
|
filter JSONB DEFAULT '{}'::jsonb
|
||||||
|
) RETURNS TABLE (
|
||||||
|
id UUID,
|
||||||
|
content TEXT,
|
||||||
|
metadata JSONB,
|
||||||
|
similarity FLOAT,
|
||||||
|
created_at TIMESTAMPTZ
|
||||||
|
) LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.content,
|
||||||
|
t.metadata,
|
||||||
|
(1 - (t.embedding <=> query_embedding))::FLOAT AS similarity,
|
||||||
|
t.created_at
|
||||||
|
FROM thoughts t
|
||||||
|
WHERE 1 - (t.embedding <=> query_embedding) > match_threshold
|
||||||
|
ORDER BY t.embedding <=> query_embedding
|
||||||
|
LIMIT match_count;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
85
src/ai.ts
Normal file
85
src/ai.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { ThoughtMetadata } from "./types";
|
||||||
|
|
||||||
|
const OPENROUTER_URL = "https://openrouter.ai/api/v1";
|
||||||
|
|
||||||
|
function getApiKey(): string {
|
||||||
|
const key = process.env.OPENROUTER_API_KEY;
|
||||||
|
if (!key) throw new Error("OPENROUTER_API_KEY not set");
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEmbedding(text: string): Promise<number[]> {
|
||||||
|
const res = await fetch(`${OPENROUTER_URL}/embeddings`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${getApiKey()}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "openai/text-embedding-3-small",
|
||||||
|
input: text,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`OpenRouter embedding error (${res.status}): ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = await res.json();
|
||||||
|
return data.data[0].embedding;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METADATA_SYSTEM_PROMPT = `You are a metadata extraction engine. Given a thought or note, extract structured metadata as JSON.
|
||||||
|
|
||||||
|
Return ONLY valid JSON matching this schema:
|
||||||
|
{
|
||||||
|
"type": one of "observation", "task", "idea", "reference", "person_note",
|
||||||
|
"topics": array of 1-4 short topic tags,
|
||||||
|
"people": array of people mentioned (empty if none),
|
||||||
|
"action_items": array of action items (empty if none),
|
||||||
|
"dates_mentioned": array of dates in YYYY-MM-DD format (empty if none)
|
||||||
|
}
|
||||||
|
|
||||||
|
Choose the most appropriate type:
|
||||||
|
- observation: general notes, things noticed
|
||||||
|
- task: something to do, reminders
|
||||||
|
- idea: creative thoughts, possibilities
|
||||||
|
- reference: facts, links, technical info
|
||||||
|
- person_note: notes about a specific person`;
|
||||||
|
|
||||||
|
export async function extractMetadata(text: string): Promise<ThoughtMetadata> {
|
||||||
|
const res = await fetch(`${OPENROUTER_URL}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${getApiKey()}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "openai/gpt-4o-mini",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: METADATA_SYSTEM_PROMPT },
|
||||||
|
{ role: "user", content: text },
|
||||||
|
],
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
temperature: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`OpenRouter metadata error (${res.status}): ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = await res.json();
|
||||||
|
const content = data.choices[0].message.content;
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: parsed.type || "observation",
|
||||||
|
topics: parsed.topics || [],
|
||||||
|
people: parsed.people || [],
|
||||||
|
action_items: parsed.action_items || [],
|
||||||
|
dates_mentioned: parsed.dates_mentioned || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
125
src/db.ts
Normal file
125
src/db.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { Pool } from "pg";
|
||||||
|
import pgvector from "pgvector/pg";
|
||||||
|
import { ThoughtMetadata, ThoughtMatch, Thought } from "./types";
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on("connect", async (client) => {
|
||||||
|
await pgvector.registerTypes(client);
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function insertThought(
|
||||||
|
content: string,
|
||||||
|
embedding: number[],
|
||||||
|
metadata: ThoughtMetadata,
|
||||||
|
source: "discord" | "mcp"
|
||||||
|
): Promise<string> {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO thoughts (content, embedding, metadata, source)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id`,
|
||||||
|
[content, pgvector.toSql(embedding), JSON.stringify(metadata), source]
|
||||||
|
);
|
||||||
|
return result.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchThoughts(
|
||||||
|
embedding: number[],
|
||||||
|
limit: number = 10,
|
||||||
|
threshold: number = 0.7
|
||||||
|
): Promise<ThoughtMatch[]> {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM match_thoughts($1, $2, $3)`,
|
||||||
|
[pgvector.toSql(embedding), threshold, limit]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listThoughts(options: {
|
||||||
|
limit?: number;
|
||||||
|
type?: string;
|
||||||
|
topic?: string;
|
||||||
|
person?: string;
|
||||||
|
days?: number;
|
||||||
|
}): Promise<Thought[]> {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (options.type) {
|
||||||
|
conditions.push(`metadata->>'type' = $${paramIndex++}`);
|
||||||
|
params.push(options.type);
|
||||||
|
}
|
||||||
|
if (options.topic) {
|
||||||
|
conditions.push(`metadata->'topics' ? $${paramIndex++}`);
|
||||||
|
params.push(options.topic);
|
||||||
|
}
|
||||||
|
if (options.person) {
|
||||||
|
conditions.push(`metadata->'people' ? $${paramIndex++}`);
|
||||||
|
params.push(options.person);
|
||||||
|
}
|
||||||
|
if (options.days) {
|
||||||
|
conditions.push(`created_at > now() - interval '${Number(options.days)} days'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
const limit = Math.min(options.limit || 20, 100);
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, user_id, content, metadata, source, created_at, updated_at
|
||||||
|
FROM thoughts ${where}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${limit}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getThoughtStats(): Promise<{
|
||||||
|
total: number;
|
||||||
|
by_type: Record<string, number>;
|
||||||
|
by_source: Record<string, number>;
|
||||||
|
recent_topics: string[];
|
||||||
|
last_captured: Date | null;
|
||||||
|
}> {
|
||||||
|
const [totalRes, typeRes, sourceRes, topicsRes, lastRes] = await Promise.all([
|
||||||
|
pool.query(`SELECT count(*)::int AS total FROM thoughts`),
|
||||||
|
pool.query(
|
||||||
|
`SELECT metadata->>'type' AS type, count(*)::int AS count
|
||||||
|
FROM thoughts GROUP BY metadata->>'type' ORDER BY count DESC`
|
||||||
|
),
|
||||||
|
pool.query(
|
||||||
|
`SELECT source, count(*)::int AS count
|
||||||
|
FROM thoughts GROUP BY source ORDER BY count DESC`
|
||||||
|
),
|
||||||
|
pool.query(
|
||||||
|
`SELECT DISTINCT jsonb_array_elements_text(metadata->'topics') AS topic
|
||||||
|
FROM thoughts
|
||||||
|
WHERE created_at > now() - interval '7 days'
|
||||||
|
LIMIT 20`
|
||||||
|
),
|
||||||
|
pool.query(
|
||||||
|
`SELECT created_at FROM thoughts ORDER BY created_at DESC LIMIT 1`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const by_type: Record<string, number> = {};
|
||||||
|
for (const row of typeRes.rows) {
|
||||||
|
if (row.type) by_type[row.type] = row.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const by_source: Record<string, number> = {};
|
||||||
|
for (const row of sourceRes.rows) {
|
||||||
|
by_source[row.source] = row.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: totalRes.rows[0].total,
|
||||||
|
by_type,
|
||||||
|
by_source,
|
||||||
|
recent_topics: topicsRes.rows.map((r) => r.topic),
|
||||||
|
last_captured: lastRes.rows[0]?.created_at || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
177
src/discord.ts
Normal file
177
src/discord.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
Client,
|
||||||
|
GatewayIntentBits,
|
||||||
|
Events,
|
||||||
|
REST,
|
||||||
|
Routes,
|
||||||
|
SlashCommandBuilder,
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
Message,
|
||||||
|
} from "discord.js";
|
||||||
|
import { ingestThought, formatIngestResult } from "./ingest";
|
||||||
|
import { searchThoughts, listThoughts, getThoughtStats } from "./db";
|
||||||
|
import { getEmbedding } from "./ai";
|
||||||
|
import { ThoughtMatch, Thought } from "./types";
|
||||||
|
|
||||||
|
const CAPTURE_CHANNEL_ID = process.env.DISCORD_CAPTURE_CHANNEL_ID || "";
|
||||||
|
|
||||||
|
const commands = [
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName("brain")
|
||||||
|
.setDescription("Interact with your Open Brain")
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
sub
|
||||||
|
.setName("search")
|
||||||
|
.setDescription("Search your brain semantically")
|
||||||
|
.addStringOption((opt) =>
|
||||||
|
opt.setName("query").setDescription("What to search for").setRequired(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
sub
|
||||||
|
.setName("recent")
|
||||||
|
.setDescription("List recent thoughts")
|
||||||
|
.addIntegerOption((opt) =>
|
||||||
|
opt.setName("count").setDescription("Number of thoughts (default 10)").setRequired(false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
sub.setName("stats").setDescription("Show brain statistics")
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
async function registerSlashCommands(token: string, clientId: string) {
|
||||||
|
const rest = new REST({ version: "10" }).setToken(token);
|
||||||
|
await rest.put(Routes.applicationCommands(clientId), {
|
||||||
|
body: commands.map((c) => c.toJSON()),
|
||||||
|
});
|
||||||
|
console.log("[discord] Slash commands registered globally");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch(interaction: ChatInputCommandInteraction) {
|
||||||
|
await interaction.deferReply();
|
||||||
|
const query = interaction.options.getString("query", true);
|
||||||
|
|
||||||
|
const embedding = await getEmbedding(query);
|
||||||
|
const results = await searchThoughts(embedding, 5, 0.7);
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
await interaction.editReply("No matching thoughts found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = results
|
||||||
|
.map((r: ThoughtMatch, i: number) => {
|
||||||
|
const date = new Date(r.created_at).toLocaleDateString();
|
||||||
|
return `**${i + 1}.** [${r.metadata.type}] ${r.content}\n> Topics: ${r.metadata.topics.join(", ") || "none"} | ${(r.similarity * 100).toFixed(0)}% match | ${date}`;
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
await interaction.editReply(text.slice(0, 2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRecent(interaction: ChatInputCommandInteraction) {
|
||||||
|
await interaction.deferReply();
|
||||||
|
const count = interaction.options.getInteger("count") || 10;
|
||||||
|
|
||||||
|
const results = await listThoughts({ limit: count });
|
||||||
|
if (results.length === 0) {
|
||||||
|
await interaction.editReply("No thoughts captured yet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = results
|
||||||
|
.map((r: Thought, i: number) => {
|
||||||
|
const date = new Date(r.created_at).toLocaleDateString();
|
||||||
|
return `**${i + 1}.** [${r.metadata.type}] ${r.content}\n> ${r.metadata.topics.join(", ") || "no topics"} | ${r.source} | ${date}`;
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
await interaction.editReply(text.slice(0, 2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStats(interaction: ChatInputCommandInteraction) {
|
||||||
|
await interaction.deferReply();
|
||||||
|
const stats = await getThoughtStats();
|
||||||
|
|
||||||
|
const typeLines = Object.entries(stats.by_type)
|
||||||
|
.map(([type, count]) => ` ${type}: ${count}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
`**Total thoughts:** ${stats.total}`,
|
||||||
|
"",
|
||||||
|
"**By type:**",
|
||||||
|
typeLines || " (none)",
|
||||||
|
"",
|
||||||
|
`**Recent topics (7 days):** ${stats.recent_topics.join(", ") || "(none)"}`,
|
||||||
|
"",
|
||||||
|
`**Last captured:** ${stats.last_captured ? new Date(stats.last_captured).toLocaleString() : "(never)"}`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
await interaction.editReply(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMessage(message: Message) {
|
||||||
|
if (message.author.bot) return;
|
||||||
|
if (message.channel.id !== CAPTURE_CHANNEL_ID) return;
|
||||||
|
if (!message.content.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await ingestThought(message.content, "discord");
|
||||||
|
await message.reply(formatIngestResult(result));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[discord] Ingest error:", err);
|
||||||
|
await message.reply("Failed to capture thought. Check server logs.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startDiscordBot(): Promise<Client> {
|
||||||
|
const token = process.env.DISCORD_BOT_TOKEN;
|
||||||
|
if (!token) throw new Error("DISCORD_BOT_TOKEN not set");
|
||||||
|
if (!CAPTURE_CHANNEL_ID) throw new Error("DISCORD_CAPTURE_CHANNEL_ID not set");
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
intents: [
|
||||||
|
GatewayIntentBits.Guilds,
|
||||||
|
GatewayIntentBits.GuildMessages,
|
||||||
|
GatewayIntentBits.MessageContent,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
client.once(Events.ClientReady, async (c) => {
|
||||||
|
console.log(`[discord] Bot ready as ${c.user.tag}`);
|
||||||
|
await registerSlashCommands(token, c.user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on(Events.MessageCreate, handleMessage);
|
||||||
|
|
||||||
|
client.on(Events.InteractionCreate, async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
if (interaction.commandName !== "brain") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sub = interaction.options.getSubcommand();
|
||||||
|
switch (sub) {
|
||||||
|
case "search":
|
||||||
|
await handleSearch(interaction);
|
||||||
|
break;
|
||||||
|
case "recent":
|
||||||
|
await handleRecent(interaction);
|
||||||
|
break;
|
||||||
|
case "stats":
|
||||||
|
await handleStats(interaction);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[discord] Command error:", err);
|
||||||
|
const reply = interaction.deferred
|
||||||
|
? interaction.editReply("An error occurred.")
|
||||||
|
: interaction.reply("An error occurred.");
|
||||||
|
await reply;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.login(token);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
62
src/index.ts
Normal file
62
src/index.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||||
|
import { createMcpServer } from "./mcp";
|
||||||
|
import { startDiscordBot } from "./discord";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
const PORT = parseInt(process.env.PORT || "3100", 10);
|
||||||
|
const MCP_ACCESS_KEY = process.env.MCP_ACCESS_KEY || "";
|
||||||
|
|
||||||
|
function authenticate(req: Request): boolean {
|
||||||
|
if (!MCP_ACCESS_KEY) return false;
|
||||||
|
const headerKey = req.headers.get("x-brain-key");
|
||||||
|
if (headerKey === MCP_ACCESS_KEY) return true;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const paramKey = url.searchParams.get("key");
|
||||||
|
return paramKey === MCP_ACCESS_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get("/", (c) => c.json({ status: "ok", service: "open-brain" }));
|
||||||
|
|
||||||
|
// MCP endpoint — stateless: new transport per request, connect server, handle, close
|
||||||
|
app.all("/mcp", async (c) => {
|
||||||
|
if (!authenticate(c.req.raw)) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createMcpServer();
|
||||||
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.connect(transport);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await transport.handleRequest(c.req.raw);
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start everything
|
||||||
|
async function main() {
|
||||||
|
if (!MCP_ACCESS_KEY) {
|
||||||
|
console.error("MCP_ACCESS_KEY is not set. Generate one with: openssl rand -hex 32");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
serve({ fetch: app.fetch, port: PORT }, () => {
|
||||||
|
console.log(`[server] Open Brain listening on port ${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startDiscordBot();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[discord] Failed to start bot:", err);
|
||||||
|
console.error("[discord] The MCP server will continue running without Discord.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
37
src/ingest.ts
Normal file
37
src/ingest.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { getEmbedding, extractMetadata } from "./ai";
|
||||||
|
import { insertThought } from "./db";
|
||||||
|
import { IngestResult } from "./types";
|
||||||
|
|
||||||
|
export async function ingestThought(
|
||||||
|
content: string,
|
||||||
|
source: "discord" | "mcp"
|
||||||
|
): Promise<IngestResult> {
|
||||||
|
const [embedding, metadata] = await Promise.all([
|
||||||
|
getEmbedding(content),
|
||||||
|
extractMetadata(content),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const id = await insertThought(content, embedding, metadata, source);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: metadata.type,
|
||||||
|
topics: metadata.topics,
|
||||||
|
people: metadata.people,
|
||||||
|
action_items: metadata.action_items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatIngestResult(result: IngestResult): string {
|
||||||
|
let msg = `Captured as ${result.type}`;
|
||||||
|
if (result.topics.length > 0) {
|
||||||
|
msg += ` — ${result.topics.join(", ")}`;
|
||||||
|
}
|
||||||
|
if (result.people.length > 0) {
|
||||||
|
msg += `\nPeople: ${result.people.join(", ")}`;
|
||||||
|
}
|
||||||
|
if (result.action_items.length > 0) {
|
||||||
|
msg += `\nAction items: ${result.action_items.join(", ")}`;
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
109
src/mcp.ts
Normal file
109
src/mcp.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { searchThoughts, listThoughts, getThoughtStats } from "./db";
|
||||||
|
import { getEmbedding } from "./ai";
|
||||||
|
import { ingestThought, formatIngestResult } from "./ingest";
|
||||||
|
import { ThoughtMatch, Thought } from "./types";
|
||||||
|
|
||||||
|
export function createMcpServer(): McpServer {
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "open-brain",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"search_thoughts",
|
||||||
|
"Search your brain using semantic similarity. Use this when looking for specific topics, people, or concepts.",
|
||||||
|
{
|
||||||
|
query: z.string().describe("What to search for"),
|
||||||
|
limit: z.number().optional().default(10).describe("Max results (default 10)"),
|
||||||
|
threshold: z.number().optional().default(0.7).describe("Similarity threshold 0-1 (default 0.7)"),
|
||||||
|
},
|
||||||
|
async ({ query, limit, threshold }) => {
|
||||||
|
const embedding = await getEmbedding(query);
|
||||||
|
const results = await searchThoughts(embedding, limit, threshold);
|
||||||
|
if (results.length === 0) {
|
||||||
|
return { content: [{ type: "text" as const, text: "No matching thoughts found." }] };
|
||||||
|
}
|
||||||
|
const text = results
|
||||||
|
.map((r: ThoughtMatch, i: number) => {
|
||||||
|
const date = new Date(r.created_at).toLocaleDateString();
|
||||||
|
const meta = r.metadata;
|
||||||
|
return `${i + 1}. [${meta.type}] ${r.content}\n Topics: ${meta.topics.join(", ") || "none"} | Similarity: ${(r.similarity * 100).toFixed(0)}% | ${date}`;
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
return { content: [{ type: "text" as const, text }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"list_thoughts",
|
||||||
|
"List recent thoughts with optional filters. Use this to browse or filter by type, topic, person, or time range.",
|
||||||
|
{
|
||||||
|
limit: z.number().optional().default(20).describe("Max results (default 20)"),
|
||||||
|
type: z.string().optional().describe("Filter by type: observation, task, idea, reference, person_note"),
|
||||||
|
topic: z.string().optional().describe("Filter by topic tag"),
|
||||||
|
person: z.string().optional().describe("Filter by person name"),
|
||||||
|
days: z.number().optional().describe("Only thoughts from the last N days"),
|
||||||
|
},
|
||||||
|
async ({ limit, type, topic, person, days }) => {
|
||||||
|
const results = await listThoughts({ limit, type, topic, person, days });
|
||||||
|
if (results.length === 0) {
|
||||||
|
return { content: [{ type: "text" as const, text: "No thoughts found matching those filters." }] };
|
||||||
|
}
|
||||||
|
const text = results
|
||||||
|
.map((r: Thought, i: number) => {
|
||||||
|
const date = new Date(r.created_at).toLocaleDateString();
|
||||||
|
const meta = r.metadata;
|
||||||
|
return `${i + 1}. [${meta.type}] ${r.content}\n Topics: ${meta.topics.join(", ") || "none"} | Source: ${r.source} | ${date}`;
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
return { content: [{ type: "text" as const, text: `${results.length} thoughts:\n\n${text}` }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"thought_stats",
|
||||||
|
"Get aggregate statistics about your brain: total thoughts, breakdown by type, recent topics, and more.",
|
||||||
|
{},
|
||||||
|
async () => {
|
||||||
|
const stats = await getThoughtStats();
|
||||||
|
const typeBreakdown = Object.entries(stats.by_type)
|
||||||
|
.map(([type, count]) => ` ${type}: ${count}`)
|
||||||
|
.join("\n");
|
||||||
|
const sourceBreakdown = Object.entries(stats.by_source)
|
||||||
|
.map(([source, count]) => ` ${source}: ${count}`)
|
||||||
|
.join("\n");
|
||||||
|
const text = [
|
||||||
|
`Total thoughts: ${stats.total}`,
|
||||||
|
"",
|
||||||
|
"By type:",
|
||||||
|
typeBreakdown || " (none)",
|
||||||
|
"",
|
||||||
|
"By source:",
|
||||||
|
sourceBreakdown || " (none)",
|
||||||
|
"",
|
||||||
|
`Recent topics (7 days): ${stats.recent_topics.join(", ") || "(none)"}`,
|
||||||
|
"",
|
||||||
|
`Last captured: ${stats.last_captured ? new Date(stats.last_captured).toLocaleString() : "(never)"}`,
|
||||||
|
].join("\n");
|
||||||
|
return { content: [{ type: "text" as const, text }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"capture_thought",
|
||||||
|
"Save a new thought to the brain. Use this when the user wants to remember something, save a note, or store information for later.",
|
||||||
|
{
|
||||||
|
content: z.string().describe("The thought or note to capture"),
|
||||||
|
},
|
||||||
|
async ({ content }) => {
|
||||||
|
const result = await ingestThought(content, "mcp");
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: formatIngestResult(result) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
34
src/types.ts
Normal file
34
src/types.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export interface ThoughtMetadata {
|
||||||
|
type: "observation" | "task" | "idea" | "reference" | "person_note";
|
||||||
|
topics: string[];
|
||||||
|
people: string[];
|
||||||
|
action_items: string[];
|
||||||
|
dates_mentioned: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Thought {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
content: string;
|
||||||
|
embedding: number[] | null;
|
||||||
|
metadata: ThoughtMetadata;
|
||||||
|
source: "discord" | "mcp";
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThoughtMatch {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
metadata: ThoughtMetadata;
|
||||||
|
similarity: number;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IngestResult {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
topics: string[];
|
||||||
|
people: string[];
|
||||||
|
action_items: string[];
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user