Skip to content

Commit 8eb8baf

Browse files
Merge pull request #304 from dhanjit/contrib/dhanjit/local-brain-no-mcp
[recipes] Add local-brain-no-mcp recipe + ob1-local-http skill
2 parents 6acdff4 + 0df61b1 commit 8eb8baf

16 files changed

Lines changed: 1164 additions & 0 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# local-brain-no-mcp -- environment overlay
2+
#
3+
# This file is the source of truth for the brain stack. After running setup.sh,
4+
# a fully-populated .env (with generated secrets) is written to
5+
# supabase-docker/docker/.env -- that's where docker compose reads from.
6+
#
7+
# Edit the values below BEFORE running setup.sh if you want non-defaults.
8+
# Once the stack has booted with a given EMBED_DIM, the value is baked into
9+
# the thoughts table and cannot be changed without wiping the Postgres volume.
10+
11+
# ---------- embedding ----------
12+
# The Ollama model used to embed thought text. Server-side only -- dev hosts
13+
# never need Ollama. See README "Embedding model is a one-way door" for the
14+
# rules around switching models later.
15+
EMBED_MODEL=nomic-embed-text
16+
EMBED_DIM=768
17+
18+
# Where the capture / search Edge Functions reach Ollama from inside the
19+
# Docker network. Don't change unless you also change the Ollama service name
20+
# in docker-compose.yml.
21+
OLLAMA_URL=http://ollama:11434
22+
23+
# Host port to expose Ollama on (handy for `ollama pull` from the brain host
24+
# shell). The Edge Functions reach Ollama over the internal Docker network,
25+
# not this port.
26+
OLLAMA_PORT=11434
27+
28+
# ---------- networking ----------
29+
# Hostname or IP that dev hosts use to reach the brain. Used only by
30+
# setup.sh when it prints the "skill install" snippet at the end. The
31+
# Supabase Kong gateway listens on KONG_HTTP_PORT (default 8000).
32+
BRAIN_HOST=brain.local
33+
KONG_HTTP_PORT=8000
34+
35+
# Everything else (POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY,
36+
# DASHBOARD_USERNAME, DASHBOARD_PASSWORD, etc.) is generated for you by
37+
# setup.sh into supabase-docker/docker/.env -- do NOT put real secrets in
38+
# THIS file.
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# Local Brain (No MCP)
2+
3+
Self-hosted Open Brain on a single LAN host: the official Supabase docker-compose stack plus an Ollama sidecar for local embeddings and two Edge Functions (`capture`, `search`) wired up for OB1's `thoughts` schema. Reached from each dev host through `curl`, via the companion [`ob1-local-http`](../../skills/ob1-local-http/) skill -- no MCP transport involved.
4+
5+
## Why this exists (deliberate exception to canonical OB1)
6+
7+
The canonical OB1 architecture assumes (a) Claude Code can call a remote Supabase MCP server and (b) Supabase Cloud is reachable. This recipe is intentionally for environments where neither is true:
8+
9+
- **No third-party cloud reachable** -- the host can only talk to other LAN hosts. Supabase Cloud is off the table; everything runs on a single Linux box in the office.
10+
- **MCP feature disabled in Claude Code** -- corporate policy or a locked-down build blocks Claude Code from speaking MCP at all (stdio or remote). The canonical capture/search tools therefore can't run.
11+
12+
So this recipe diverges from `CLAUDE.md`'s rule "MCP servers must be remote Supabase Edge Functions." It still uses Edge Functions, but exposes them as plain HTTPS endpoints -- not as MCP -- because the network won't let MCP through. The skill on each dev host calls those endpoints with `curl`. Cloud-shaped OB1 recipes that target PostgREST/Edge Functions still work locally with a base-URL swap; only the MCP transport is gone.
13+
14+
If your environment does NOT have these constraints, use the canonical cloud OB1 instead -- this recipe trades roughly 30 minutes of setup, ~3 GB RAM, and operational complexity for the air-gapped/no-MCP property.
15+
16+
## What you get
17+
18+
After setup, on one office Linux host:
19+
20+
- Postgres 15 with `pgvector` (HNSW index on a configurable embedding dim, default 768)
21+
- The full Supabase stack (Kong gateway, PostgREST, GoTrue, Realtime, Storage, Studio, Edge Functions runtime, Logflare) -- canonical, unmodified, just self-hosted
22+
- An `ollama` sidecar that the Edge Functions call for embedding generation -- dev hosts never need Ollama
23+
- A `thoughts` table that mirrors the canonical OB1 schema exactly, plus `match_thoughts(...)` and `upsert_thought(...)` RPCs with the same signatures as cloud
24+
- Three Edge Functions reachable through Kong:
25+
- `POST /functions/v1/capture` -- embed and store
26+
- `POST /functions/v1/search` -- embed query and run match_thoughts
27+
- `GET /functions/v1/list` -- recent thoughts for browsing or downstream digest skills
28+
- Supabase Studio at `http://<brain-host>:3000` -- your day-one read-only dashboard, free
29+
- A `BRAIN_URL` + `BRAIN_ANON_KEY` pair that you paste into each dev host's environment for the [`ob1-local-http`](../../skills/ob1-local-http/) skill
30+
31+
## Prerequisites
32+
33+
On the **brain host** (one Linux box on the office network):
34+
35+
- Docker 24+ with Compose v2.20+ (`include:` directive required)
36+
- `git`, `openssl`, `python3` (stdlib only -- no `pip install`)
37+
- ~8 GB RAM available, ~5 GB disk for images and the embedding model
38+
- Outbound HTTPS to GitHub and to the configured Docker registry, *one-time*, for the clone and image pulls
39+
- A stable hostname or IP reachable from every dev host (e.g., `brain.local`)
40+
41+
On each **dev host**:
42+
43+
- `curl`
44+
- Claude Code (or any skill-aware AI tool that reads `~/.claude/skills/`)
45+
46+
## Setup
47+
48+
> Run all of these on the **brain host**, from this directory (`recipes/local-brain-no-mcp/`).
49+
50+
1. Copy `.env.example` to `.env` and edit the overlay values if you want non-defaults. The important one is `EMBED_DIM` -- see the one-way door warning below before you change it.
51+
52+
```sh
53+
cp .env.example .env
54+
$EDITOR .env
55+
```
56+
57+
2. Run the one-time setup. It clones `supabase/supabase`, generates secrets (POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, dashboard password, vault key, logflare tokens), writes them to `supabase-docker/docker/.env`, symlinks the Edge Functions into the supabase volume, and installs the SQL init scripts.
58+
59+
```sh
60+
./setup.sh
61+
```
62+
63+
`setup.sh` is idempotent. Re-running preserves an existing `.env` so already-captured data stays accessible.
64+
65+
3. Bring up the stack.
66+
67+
```sh
68+
docker compose up -d
69+
docker compose ps
70+
```
71+
72+
First boot pulls ~3 GB of images and runs the Postgres init scripts (creating the `thoughts` table, indexes, RPCs).
73+
74+
4. Pull the embedding model into Ollama (one-time, model size depends on choice):
75+
76+
```sh
77+
docker compose exec ollama ollama pull "$(grep ^EMBED_MODEL supabase-docker/docker/.env | cut -d= -f2-)"
78+
```
79+
80+
5. Verify reachability from the brain host itself:
81+
82+
```sh
83+
ANON_KEY="$(grep ^ANON_KEY supabase-docker/docker/.env | cut -d= -f2-)"
84+
curl -fsS -X POST "http://localhost:8000/functions/v1/capture" \
85+
-H "apikey: $ANON_KEY" \
86+
-H "Authorization: Bearer $ANON_KEY" \
87+
-H "Content-Type: application/json" \
88+
-d '{"content":"first thought from setup.sh smoke test","metadata":{"source":"smoke-test"}}'
89+
```
90+
91+
Expected: HTTP 200 with `{"ok":true,"id":"...","fingerprint":"..."}`.
92+
93+
6. On each dev host, install [`ob1-local-http`](../../skills/ob1-local-http/) and export the two env vars `setup.sh` printed at the end. The skill takes over from there.
94+
95+
## Expected outcome
96+
97+
- Claude Code on any dev host can capture and search thoughts by asking in natural language ("remember X", "what did I note about Y") and the skill translates that into `curl` against the brain.
98+
- The brain accumulates thoughts in `public.thoughts`, vector-indexed for similarity search.
99+
- Supabase Studio at `http://<brain-host>:3000` lets you browse, edit, or delete rows manually.
100+
- Nothing leaves the LAN.
101+
102+
## The embedding model is a one-way door
103+
104+
The `EMBED_DIM` env var is baked into the `embedding vector(N)` column when the Postgres volume is initialized. pgvector enforces this at insert time -- once the volume exists, **you cannot change `EMBED_DIM` without wiping the volume and losing all captured thoughts.**
105+
106+
What this means in practice:
107+
108+
- Pick `EMBED_MODEL` / `EMBED_DIM` once before first boot.
109+
- If you must change them later:
110+
1. `docker compose down`
111+
2. Back up first (see below) if you want to migrate data
112+
3. `docker volume rm` the Postgres data volume (find it with `docker volume ls | grep db`)
113+
4. Edit `supabase-docker/docker/.env` to the new dim
114+
5. `docker compose up -d` -- Postgres re-runs the init scripts with the new dim
115+
6. Re-embed your backed-up content via the new model
116+
117+
The `embed.ts` helper does a dim check on every call and returns a clear error (`embedding-dim mismatch...`) if the model and the column disagree.
118+
119+
## Backup and restore
120+
121+
Stop the stack first, then snapshot the data volumes:
122+
123+
```sh
124+
docker compose stop db
125+
docker run --rm \
126+
-v "$(docker volume ls -q | grep _db-config):/from" \
127+
-v "$(pwd)/backups:/to" \
128+
alpine tar czf "/to/db-$(date +%F).tar.gz" -C /from .
129+
docker compose start db
130+
```
131+
132+
To restore: `docker compose down`, `docker volume rm` the db volume, recreate empty, untar into it, `docker compose up -d`.
133+
134+
## Troubleshooting
135+
136+
- **`docker compose` fails with `unknown field "include"`**: you're on Compose < 2.20. Upgrade Docker Desktop or install a current `docker compose` plugin.
137+
- **`functions` service exits with module-resolution errors against `_shared/*.ts`**: the symlinks didn't take. Check `ls -la supabase-docker/docker/volumes/functions/` -- you should see `capture`, `search`, `list`, `_shared` as symlinks pointing back at this recipe. Re-run `./setup.sh`.
138+
- **`embed.ts: did you 'ollama pull <model>'?`**: you skipped step 4. Run the `ollama pull` line.
139+
- **`embed.ts: embedding-dim mismatch`**: someone changed `EMBED_DIM` in `.env` after the volume was initialized. Either revert the env or follow the one-way-door procedure above.
140+
- **HTTP 401 from Kong**: `ANON_KEY` in `.env` and the one your dev host is using are out of sync. Re-export from `supabase-docker/docker/.env`.
141+
- **Dev host can't reach `http://<brain-host>:8000`**: confirm the brain host's firewall allows `KONG_HTTP_PORT` inbound on the office network and that the hostname resolves.
142+
143+
## Related
144+
145+
- [`skills/ob1-local-http`](../../skills/ob1-local-http/) -- the companion skill pack that runs on each dev host
146+
- [`docs/drafts/agent-memory-staging-base.sql`](../../docs/drafts/agent-memory-staging-base.sql) -- the canonical thoughts schema this recipe mirrors
147+
- [`server/index.ts`](../../server/index.ts) -- the canonical MCP server this recipe deliberately does NOT use as a transport
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# local-brain-no-mcp -- docker compose overlay
2+
#
3+
# This file merges the official supabase/supabase docker-compose stack with
4+
# our additions (Ollama + EMBED_* env vars). Run from this directory:
5+
#
6+
# ./setup.sh # one-time
7+
# docker compose up -d # bring up the brain
8+
#
9+
# `include:` resolves paths relative to the parent compose file, so
10+
# supabase-docker/ must be a sibling directory (setup.sh handles this).
11+
# Requires Docker Compose v2.20 or newer.
12+
13+
include:
14+
- path: supabase-docker/docker/docker-compose.yml
15+
env_file: supabase-docker/docker/.env
16+
17+
services:
18+
ollama:
19+
image: ollama/ollama:0.6.5
20+
container_name: ob1-ollama
21+
restart: unless-stopped
22+
networks:
23+
- default
24+
ports:
25+
- "${OLLAMA_PORT:-11434}:11434"
26+
volumes:
27+
- ollama_data:/root/.ollama
28+
healthcheck:
29+
test: ["CMD-SHELL", "ollama list >/dev/null 2>&1"]
30+
interval: 30s
31+
timeout: 10s
32+
retries: 5
33+
start_period: 60s
34+
35+
# Extend supabase's Edge Functions service so our handlers can find Ollama
36+
# and know which model/dim to use. The functions volume mount comes from
37+
# supabase's compose (./volumes/functions) -- setup.sh symlinks our handler
38+
# dirs into there.
39+
functions:
40+
environment:
41+
OLLAMA_URL: ${OLLAMA_URL:-http://ollama:11434}
42+
EMBED_MODEL: ${EMBED_MODEL:-nomic-embed-text}
43+
EMBED_DIM: ${EMBED_DIM:-768}
44+
depends_on:
45+
ollama:
46+
condition: service_healthy
47+
48+
volumes:
49+
ollama_data:
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export const corsHeaders = {
2+
"Access-Control-Allow-Origin": "*",
3+
"Access-Control-Allow-Headers":
4+
"authorization, x-client-info, apikey, content-type",
5+
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
6+
};
7+
8+
export function jsonResponse(
9+
body: unknown,
10+
status = 200,
11+
extraHeaders: Record<string, string> = {},
12+
): Response {
13+
return new Response(JSON.stringify(body), {
14+
status,
15+
headers: {
16+
"Content-Type": "application/json",
17+
...corsHeaders,
18+
...extraHeaders,
19+
},
20+
});
21+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { createClient, type SupabaseClient } from "jsr:@supabase/supabase-js@2";
2+
3+
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
4+
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
5+
6+
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
7+
throw new Error(
8+
"SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set by the Edge Functions runtime",
9+
);
10+
}
11+
12+
export const db: SupabaseClient = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
13+
auth: { autoRefreshToken: false, persistSession: false },
14+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Server-side embedding via a local Ollama sidecar. Dev hosts never need
2+
// Ollama -- the capture and search Edge Functions both call this helper.
3+
4+
const OLLAMA_URL = Deno.env.get("OLLAMA_URL") ?? "http://ollama:11434";
5+
const EMBED_MODEL = Deno.env.get("EMBED_MODEL") ?? "nomic-embed-text";
6+
const EMBED_DIM = Number(Deno.env.get("EMBED_DIM") ?? "768");
7+
8+
export class EmbedError extends Error {
9+
constructor(message: string, public readonly cause?: unknown) {
10+
super(message);
11+
this.name = "EmbedError";
12+
}
13+
}
14+
15+
export async function embed(text: string): Promise<number[]> {
16+
const trimmed = text?.trim();
17+
if (!trimmed) throw new EmbedError("empty text");
18+
19+
let r: Response;
20+
try {
21+
r = await fetch(`${OLLAMA_URL}/api/embeddings`, {
22+
method: "POST",
23+
headers: { "Content-Type": "application/json" },
24+
body: JSON.stringify({ model: EMBED_MODEL, prompt: trimmed }),
25+
});
26+
} catch (e) {
27+
throw new EmbedError(
28+
`unable to reach Ollama at ${OLLAMA_URL} -- is the ollama container running?`,
29+
e,
30+
);
31+
}
32+
33+
if (!r.ok) {
34+
const body = await r.text().catch(() => "");
35+
throw new EmbedError(
36+
`Ollama embedding failed: HTTP ${r.status} ${r.statusText} -- ${body.slice(0, 200)}`,
37+
);
38+
}
39+
40+
const data = await r.json();
41+
const v = data?.embedding;
42+
if (!Array.isArray(v) || v.length === 0) {
43+
throw new EmbedError(
44+
`Ollama returned no embedding -- did you 'ollama pull ${EMBED_MODEL}'?`,
45+
);
46+
}
47+
if (v.length !== EMBED_DIM) {
48+
throw new EmbedError(
49+
`embedding-dim mismatch: env EMBED_DIM=${EMBED_DIM} but Ollama returned ${v.length}. ` +
50+
`Either change EMBED_MODEL to one that produces ${EMBED_DIM} dims, or wipe the Postgres volume and re-bootstrap with the new dim.`,
51+
);
52+
}
53+
return v;
54+
}
55+
56+
export const config = { EMBED_MODEL, EMBED_DIM, OLLAMA_URL };
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// POST /functions/v1/capture
2+
//
3+
// Body:
4+
// { content: string, metadata?: object }
5+
//
6+
// Embeds `content` via the local Ollama sidecar and INSERTs via the
7+
// upsert_thought() RPC (which dedupes by sha256(normalized content)).
8+
//
9+
// Auth: Kong gateway verifies the anon-or-service-role JWT before this
10+
// function runs. We do not re-check.
11+
12+
import { corsHeaders, jsonResponse } from "../_shared/cors.ts";
13+
import { embed, EmbedError } from "../_shared/embed.ts";
14+
import { db } from "../_shared/db.ts";
15+
16+
interface CaptureBody {
17+
content?: unknown;
18+
metadata?: unknown;
19+
}
20+
21+
Deno.serve(async (req: Request) => {
22+
if (req.method === "OPTIONS") {
23+
return new Response(null, { status: 204, headers: corsHeaders });
24+
}
25+
if (req.method !== "POST") {
26+
return jsonResponse({ error: "method not allowed" }, 405);
27+
}
28+
29+
let body: CaptureBody;
30+
try {
31+
body = await req.json();
32+
} catch {
33+
return jsonResponse({ error: "body must be valid JSON" }, 400);
34+
}
35+
36+
const content = typeof body.content === "string" ? body.content.trim() : "";
37+
if (!content) {
38+
return jsonResponse({ error: "content is required (non-empty string)" }, 400);
39+
}
40+
const metadata =
41+
body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
42+
? body.metadata
43+
: {};
44+
45+
let vec: number[];
46+
try {
47+
vec = await embed(content);
48+
} catch (e) {
49+
if (e instanceof EmbedError) return jsonResponse({ error: e.message }, 502);
50+
throw e;
51+
}
52+
53+
const { data, error } = await db.rpc("upsert_thought", {
54+
p_content: content,
55+
p_embedding: vec,
56+
p_metadata: metadata,
57+
});
58+
59+
if (error) {
60+
return jsonResponse(
61+
{ error: `upsert_thought failed: ${error.message}`, details: error.details ?? null },
62+
500,
63+
);
64+
}
65+
66+
return jsonResponse({ ok: true, ...data }, 200);
67+
});

0 commit comments

Comments
 (0)