diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
new file mode 100644
index 0000000..3b25cb4
--- /dev/null
+++ b/.claude-plugin/marketplace.json
@@ -0,0 +1,18 @@
+{
+ "name": "mempalace",
+ "owner": { "name": "MemPalace", "url": "https://github.com/debpalash" },
+ "metadata": {
+ "description": "Local-first memory for AI coding agents.",
+ "version": "0.2.0"
+ },
+ "plugins": [
+ {
+ "name": "fast-mempalace",
+ "source": "./claude-plugin",
+ "description": "Persistent, private, local memory for Claude Code — your agent remembers your code and decisions across sessions. One static binary, no cloud. Requires the fast-mempalace binary (curl install).",
+ "version": "0.2.0",
+ "license": "MIT",
+ "keywords": ["memory", "local-first", "mcp", "privacy"]
+ }
+ ]
+}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1a2b031..60584c5 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -69,7 +69,8 @@ jobs:
- name: Download Neural Matrix (GGUF)
run: |
mkdir -p lib
- curl -L -o lib/minilm.gguf "https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.f16.gguf"
+ # MiniLM-L6-v2, 384-dim — must match EMBEDDING_DIM and the float[384] schema.
+ curl -L -o lib/minilm.gguf "https://huggingface.co/leliuga/all-MiniLM-L6-v2-GGUF/resolve/main/all-MiniLM-L6-v2.F16.gguf"
- name: Build llama.cpp Static Libraries
if: steps.cache_llama.outputs.cache-hit != 'true'
@@ -79,9 +80,11 @@ jobs:
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=OFF \
-DGGML_METAL=ON \
+ -DGGML_BLAS=ON \
-DLLAMA_BUILD_TESTS=OFF \
-DLLAMA_BUILD_EXAMPLES=OFF \
- -DLLAMA_BUILD_SERVER=OFF
+ -DLLAMA_BUILD_SERVER=OFF \
+ -DLLAMA_BUILD_TOOLS=OFF
else
# Force clang + libc++ so the ABI matches Zig's bundled libc++.
CC=clang CXX=clang++ \
@@ -113,7 +116,11 @@ jobs:
- name: Compile Native Vector Pipelines (ReleaseFast)
run: zig build --release=fast
- - name: Execute CLI Validation
+ - name: Execute CLI Validation (incl. real embeddings)
run: |
+ set -eux
./zig-out/bin/fast-mempalace init
./zig-out/bin/fast-mempalace stats
+ printf 'The deploy key id is QUASAR-77 and must never be logged.\n' > smoke.txt
+ ./zig-out/bin/fast-mempalace mine smoke.txt smoke
+ ./zig-out/bin/fast-mempalace search "what is the deploy key" 2>&1 | grep -q QUASAR-77
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index da2ff8a..d2829f6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -77,8 +77,9 @@ jobs:
- name: Download embedding model
run: |
mkdir -p lib
+ # MiniLM-L6-v2, 384-dim — must match EMBEDDING_DIM and the float[384] schema.
curl -L -o lib/minilm.gguf \
- "https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.f16.gguf"
+ "https://huggingface.co/leliuga/all-MiniLM-L6-v2-GGUF/resolve/main/all-MiniLM-L6-v2.F16.gguf"
- name: Build llama.cpp static libraries
if: steps.cache_llama.outputs.cache-hit != 'true'
@@ -88,9 +89,11 @@ jobs:
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=OFF \
-DGGML_METAL=ON \
+ -DGGML_BLAS=ON \
-DLLAMA_BUILD_TESTS=OFF \
-DLLAMA_BUILD_EXAMPLES=OFF \
- -DLLAMA_BUILD_SERVER=OFF
+ -DLLAMA_BUILD_SERVER=OFF \
+ -DLLAMA_BUILD_TOOLS=OFF
else
CC=clang CXX=clang++ \
cmake -S lib/llama.cpp -B lib/llama.cpp/build \
@@ -109,10 +112,15 @@ jobs:
- name: Build release
run: zig build --release=fast
- - name: Smoke test
+ - name: Smoke test (incl. real embeddings)
run: |
+ set -eux
./zig-out/bin/fast-mempalace init
./zig-out/bin/fast-mempalace stats
+ # Validate the full semantic path: mine a file, then recall it.
+ printf 'The deploy key id is QUASAR-77 and must never be logged.\n' > smoke.txt
+ ./zig-out/bin/fast-mempalace mine smoke.txt smoke
+ ./zig-out/bin/fast-mempalace search "what is the deploy key" 2>&1 | grep -q QUASAR-77
- name: Package tarball
env:
@@ -134,6 +142,9 @@ jobs:
release:
name: Publish GitHub Release
needs: build
+ # Publish whatever platforms succeeded even if one leg fails, rather than
+ # skipping the whole release (fail-fast is off on the build matrix).
+ if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
diff --git a/BENCHMARK.md b/BENCHMARK.md
index b154ab4..82f2bd5 100644
--- a/BENCHMARK.md
+++ b/BENCHMARK.md
@@ -1,59 +1,60 @@
# ⏱️ fast-mempalace Benchmarks
-Hardware performance of identical pipelines: legacy Pip `mempalace` (Python / ONNX / ChromaDB) vs `fast-mempalace` (Zig 0.16.0 / Metal / sqlite-vec).
-
-Hardware: Apple M2, 16 GB, macOS. Each command run cold (fresh DB, fresh process). Memory = peak resident set size.
-
-## 1. Cold start
-
-Python carries ~1 s of `chromadb` + `pydantic` parse overhead before any DB work. `fast-mempalace` defers the LLM context entirely and goes straight to `sqlite-vec`.
-
-| Engine | Command | Time | Peak RAM |
-| ------ | ------- | ---- | -------- |
-| 🐢 `mempalace` (Python) | `mempalace init` | 1.40 s | 50.15 MB |
-| ⚡ `fast-mempalace` (Zig) | `fast-mempalace stats` | **0.01 s** | **8.40 MB** |
-
-## 2. Neural ingestion — OmniVoice corpus
-
-Corpus from the [OmniVoice](https://github.com/debpalash/OmniVoice-Studio) repo — all `.py`, `.md`, `.yaml`, `.toml` files concatenated (**450 368 bytes across 56 files → 1 171 drawers**). Apples-to-apples: same bytes, same embedder, same drawer-chunking.
-
-| Engine | Command | Time | Peak RAM |
-| ------ | ------- | ---- | -------- |
-| 🐢 `mempalace` (Python) | `mempalace mine` | ~120 s (blocks on ChromaDB) | ~320 MB |
-| ⚡ `fast-mempalace` (Zig) | `fast-mempalace mine` | **0.59 s** | **95 MB** |
-
-Python blocks on I/O mapping across arrays and pushes system RAM into the hundreds of MB. Zig decouples I/O via `std.Io.Group` concurrency and maps matrices into the Metal API.
-
-## 3. Semantic clustering — search
-
-Mathematical proximity sort against the full DB geometry. Memory deltas are astronomical because `fast-mempalace` binds statically against `sqlite-vec`.
-
-| Engine | Command | Time | Peak RAM |
-| ------ | ------- | ---- | -------- |
-| 🐢 `mempalace` (Python) | `mempalace search` | 0.78 s | 270 MB |
-| ⚡ `fast-mempalace` (Zig) | `fast-mempalace search` | **0.59 s** | **94 MB** |
-
-## 4. Extended commands
-
-Pure execution paths that never touch the embedder.
-
-| Engine | Command | Time | Peak RAM |
-| ------ | ------- | ---- | -------- |
-| 🐢 `mempalace` (Python) | `mempalace wake-up` | 0.53 s | 76 MB |
-| ⚡ `fast-mempalace` (Zig) | `fast-mempalace kg` | **0.02 s** | **8.40 MB** |
+Absolute performance of the real semantic engine (`llama.cpp` MiniLM-L6-v2 embeddings +
+`sqlite-vec`). Hardware: Apple Silicon, macOS, Metal backend. Each command run cold
+(fresh process) unless noted. Memory = peak resident set size.
+
+> Earlier versions of this file reported a `0.59 s` mine of 1 171 drawers. That run used
+> **placeholder vectors** (the embedder returned a constant dummy vector), so it measured
+> I/O, not embedding. The numbers below are the real on-device semantic pipeline.
+
+## 1. Latency
+
+| Operation | Time | Peak RAM | Loads model? |
+| --------- | ---- | -------- | ------------ |
+| `stats` (cold start) | **0.01 s** | ~8 MB | no |
+| `wake-up` (session context) | **0.01 s** | ~8 MB | no |
+| `mine` — 15 files → 31 drawers | **~1.0 s** | ~100 MB | yes |
+| `search` — one-shot CLI | **~0.55 s** | ~100 MB | yes (each call) |
+| `search` — vector match only | **sub-ms** | — | model already resident |
+
+The key distinction for agent use: the **CLI reloads the embedding model on every
+invocation** (~0.5 s of that 0.55 s is Metal model init). The **MCP server loads the
+model once and stays resident**, so per-query recall after warm-up is dominated by the
+`sqlite-vec` match — sub-millisecond on these corpus sizes. Session `wake-up` never loads
+the model at all, which is why it's 10 ms and runs on every session start.
+
+## 2. Footprint
+
+| | Value |
+| -- | -- |
+| Binary size (`--release=fast`, statically linked) | ~6 MB |
+| Embedding model (MiniLM-L6-v2, F16 GGUF) | ~45 MB |
+| Runtime dependencies | none (no Python, no Docker, no external vector DB) |
+| Network calls at query time | none |
+
+## 3. Retrieval quality (sanity)
+
+On a 4-topic corpus (biology / coffee / algorithms / history) with paraphrased queries
+that share no keywords with the stored text, top-1 retrieval is correct 7/7 — e.g.
+"how do cells produce energy" → the mitochondria/ATP drawer; "brewing a good cup of
+coffee" → the espresso drawer. Identical strings cosine to 1.00; paraphrases ~0.55;
+unrelated topics ~0.03.
## Reproducing
```bash
-# Build fast-mempalace
zig build --release=fast
-# Mine the OmniVoice corpus
-find /path/to/OmniVoice -type f \( -name "*.py" -o -name "*.md" -o -name "*.yaml" -o -name "*.toml" \) \
- | xargs cat > corpus.txt
-/usr/bin/time -l ./zig-out/bin/fast-mempalace mine corpus.txt omnivoice
+# point at a 384-dim model
+export FAST_MEMPALACE_MODEL=$PWD/lib/minilm.gguf
+export FAST_MEMPALACE_DB=/tmp/bench.db
-# Run the Python baseline (for comparison rows above)
-pip install mempalace
-/usr/bin/time -l mempalace mine corpus.txt
+./zig-out/bin/fast-mempalace init
+/usr/bin/time -p ./zig-out/bin/fast-mempalace mine ./src bench # mine a directory
+/usr/bin/time -p ./zig-out/bin/fast-mempalace search "how does search ranking work"
+/usr/bin/time -p ./zig-out/bin/fast-mempalace wake-up
```
+
+For warm MCP-server latency, start `fast-mempalace mcp` and issue repeated
+`tools/call → memory_search` requests over stdio; only the first pays the model-load cost.
diff --git a/README.md b/README.md
index 81e2c6e..06f12f9 100644
--- a/README.md
+++ b/README.md
@@ -1,128 +1,154 @@
fast-mempalace
-
200× faster, zero-dependency Zig rewrite of Python mempalace.
+
Local-first long-term memory for AI coding agents.
+ Your agent remembers your codebase and decisions across sessions — in a single static binary. No cloud, no Python, nothing leaves your machine.
+
+
---
-Native, statically linked. Reads the same `mempalace.yaml`, same CLI surface. `ChromaDB` + `Pydantic` out; `sqlite-vec` + `llama.cpp` + `std.Io.Group` in.
+Most AI coding sessions start amnesiac: you re-explain the architecture, re-state why
+you chose SQLite over Postgres, and the agent still contradicts last week's decision.
+**fast-mempalace** gives the agent a persistent, on-device memory it can search and
+write to — wired into Claude Code via MCP tools and session hooks.
+
+- 🧠 **Remembers across sessions** — semantic recall over everything you've mined or saved.
+- 🔒 **Fully local & private** — embeddings, storage, and search run on-device (`llama.cpp` + `sqlite-vec`). No API keys, no network at query time.
+- 📦 **One static binary** — no Python, no Docker, no vector database to run. ~6 MB + a 45 MB embedding model.
+- ⚡ **Invisible in the loop** — session wake-up in ~10 ms; vector search is sub-millisecond once the model is resident.
## ⚡ Install
```bash
-curl -fsSL https://raw.githubusercontent.com/MemPalace/fast-mempalace/main/install.sh | bash
+curl -fsSL https://raw.githubusercontent.com/debpalash/fast-mempalace/main/install.sh | bash
```
-Detects `darwin|linux` × `x86_64|aarch64`. Binary + GGUF land in `~/.fast-mempalace/`. No Python.
+Detects `darwin|linux` × `x86_64|aarch64`. Binary + embedding model land in
+`~/.fast-mempalace/`.
-## 🎯 Drop-in replacement
+## 🤖 Use with Claude Code (the main event)
-```bash
-pip uninstall mempalace
-curl -fsSL https://raw.githubusercontent.com/MemPalace/fast-mempalace/main/install.sh | bash
-```
+Install the plugin — it wires up the MCP memory tools, the session hooks, a skill, and
+slash commands:
-Legacy scripts calling `mempalace`? Symlink:
-
-```bash
-ln -s ~/.fast-mempalace/bin/fast-mempalace ~/.fast-mempalace/bin/mempalace
+```text
+/plugin marketplace add debpalash/fast-mempalace
+/plugin install fast-mempalace
```
-## 🚀 Architecture
-
-- **Zero deps** — single static binary.
-- **Sub-ms retrieval** — `sqlite-vec`, `mmap_size=512MB`.
-- **Bare-metal embeddings** — `llama.cpp` static; Metal / CUDA.
-- **Concurrent mining** — `std.Io.Group`.
-- **Local reranker** — `std.http.Client` → Ollama.
+You now have:
-## 📊 Benchmarks
+| Surface | What it does |
+|--|--|
+| **`memory_search`** (MCP) | Semantic recall before answering about past work |
+| **`memory_store`** (MCP) | Persist a decision, constraint, or snippet — verbatim |
+| **`memory_wake_up`** (MCP) | Load the compact continuity brief |
+| **SessionStart hook** | Auto-injects recent memory at the start of every session |
+| **PreCompact hook** | Saves the conversation tail *before* it's compacted away |
+| **`/remember`, `/recall`** | Slash commands for explicit save/recall |
-
+Optionally seed memory from a codebase:
-**Apple M2 · cold runs**
+```bash
+~/.fast-mempalace/bin/fast-mempalace mine . my-project
+```
-| | 🐢 Python `mempalace` | ⚡ Zig `fast-mempalace` | Speedup | Less RAM |
-|:--|:--:|:--:|:--:|:--:|
-| **Cold start**
`init` / `stats` | `1.40 s`
`50 MB` | **`0.01 s`**
**`8 MB`** | **140×** | **6×** |
-| **Mine**
450 KB · 1 171 drawers | `~120 s`
`320 MB` | **`0.59 s`**
**`95 MB`** | **203×** | **3.4×** |
-| **Search**
semantic query | `0.78 s`
`270 MB` | **`0.59 s`**
**`94 MB`** | `1.3×` | **2.9×** |
-| **Wake-up / kg**
context load | `0.53 s`
`76 MB` | **`0.02 s`**
**`8 MB`** | **26×** | **9×** |
+> Works with any MCP client (Cursor, Zed, Windsurf, …) too — point it at
+> `fast-mempalace mcp`.
-
+## 🧩 How it works
-### Relative timing (lower = faster)
+Content is organized as **Wings** (a project/domain) → **Rooms** (a topic) →
+**Drawers** (a verbatim chunk + its embedding). Retrieval is vector similarity
+(`sqlite-vec`, L2 over L2-normalized MiniLM-L6-v2 embeddings) with light recency and
+keyword re-ranking. Everything is one SQLite file.
-```text
-Cold start 🐢 ████████████████████ 1.40 s
- ⚡ ▏ 0.01 s
+- **Bare-metal embeddings** — `llama.cpp` statically linked, Metal/CUDA accelerated.
+- **Verbatim storage** — your memories are never silently rewritten or summarized by an LLM (and there's no graph-query injection surface to poison).
+- **Concurrent mining** — files embed in parallel via `std.Io.Group`.
-Mine 🐢 ████████████████████ 120 s
- ⚡ ▏ 0.59 s
+## 📊 Benchmarks
-Search 🐢 ████████████████████ 0.78 s
- ⚡ ███████████████ 0.59 s
+Apple Silicon · Metal · cold process unless noted. Methodology →
+[`BENCHMARK.md`](./BENCHMARK.md).
-Wake-up / kg 🐢 ████████████████████ 0.53 s
- ⚡ ▏ 0.02 s
-```
+| Operation | Time | Notes |
+|--|--|--|
+| Cold start (`stats`) | **0.01 s** | no model load |
+| Session wake-up | **0.01 s** | recency SQL, no model load — runs every session |
+| Mine (15 files → 31 drawers) | **~1.0 s** | real on-device MiniLM embeddings |
+| Vector search | **sub-ms** | once the model is resident (e.g. the MCP server); ~0.5 s amortized model load on a one-shot CLI call |
+| Peak RAM (search, model loaded) | **~100 MB** | mostly the embedding model |
-Corpus: [OmniVoice-Studio](https://github.com/debpalash/OmniVoice-Studio). Methodology → [`BENCHMARK.md`](./BENCHMARK.md).
+> Honest note: earlier `0.59 s / 1171-drawer` numbers measured placeholder vectors.
+> These figures are the real semantic engine. The cost of mining is dominated by
+> embedding throughput, not I/O.
-## 📦 Commands
+## 📦 CLI
```text
-fast-mempalace init Initialize palace database
-fast-mempalace mine [wing] Mine files into the palace
+fast-mempalace init Initialize the palace database
+fast-mempalace mine [wing] Mine a file or directory into the palace
fast-mempalace search Semantic search
+fast-mempalace wake-up [--wing X] Print the wake-up context
fast-mempalace stats Palace statistics
-fast-mempalace kg [subject] Query knowledge graph
-fast-mempalace wake-up [--wing X] Show L0+L1 wake-up context
-fast-mempalace hook Run hook (JSON stdin/stdout)
-fast-mempalace instructions Output skill instructions
-fast-mempalace mcp Start MCP JSON-RPC server
+fast-mempalace mcp Start the MCP server (stdio JSON-RPC)
+fast-mempalace hook Run a Claude Code hook (JSON stdin/stdout)
+fast-mempalace kg [subject] Query the knowledge graph
```
-## 🐳 Local CI (Docker)
+## ⚙️ Configuration
-Mirror the Ubuntu GitHub Actions leg locally before pushing:
+Reads `fast-mempalace.yaml` (falls back to `mempalace.yaml`). Environment variables
+override everything — this is how the plugin pins one global palace regardless of the
+project directory:
```bash
-./scripts/ci-local.sh
+FAST_MEMPALACE_DB=~/.fast-mempalace/palace.db # database path
+FAST_MEMPALACE_MODEL=~/.fast-mempalace/lib/minilm.gguf # 384-dim GGUF embedder
+FAST_MEMPALACE_WING=my-project # default wing
```
-Requires Docker daemon. Builds `Dockerfile.ci` → runs smoke test.
+```yaml
+database_path: "fast-mempalace.db"
+model_path: "lib/minilm.gguf"
+default_wing: "production"
+```
+
+The embedding model must be **384-dim** (MiniLM-L6-v2); the vector table is declared
+`float[384]` and the binary validates the model on load.
## 🔧 Build from source
Needs `zig 0.16.0` + `cmake`.
```bash
-git clone --recursive https://github.com/MemPalace/fast-mempalace
+git clone --recursive https://github.com/debpalash/fast-mempalace
cd fast-mempalace
+
+# 1) Build the statically-linked llama.cpp backend (once)
+cmake -S lib/llama.cpp -B lib/llama.cpp/build \
+ -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \
+ -DGGML_METAL=ON -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF \
+ -DLLAMA_BUILD_SERVER=OFF -DLLAMA_BUILD_TOOLS=OFF
+cmake --build lib/llama.cpp/build -j
+
+# 2) Fetch the 384-dim embedding model
mkdir -p lib && curl -L -o lib/minilm.gguf \
- "https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.f16.gguf"
+ "https://huggingface.co/leliuga/all-MiniLM-L6-v2-GGUF/resolve/main/all-MiniLM-L6-v2.F16.gguf"
+
+# 3) Build
zig build --release=fast
./zig-out/bin/fast-mempalace stats
```
-## ⚙️ Configuration
-
-Reads `fast-mempalace.yaml`, falls back to `mempalace.yaml` for drop-in compat.
-
-```yaml
-database_path: "fast-mempalace.db"
-model_path: "lib/minilm.gguf"
-default_wing: "production"
-ignore_patterns:
- - ".git"
- - ".zig-cache"
-```
+(On Linux, use `-DGGML_METAL=OFF -DGGML_BLAS=OFF`.)
## 🗺️ Roadmap
-Parity + outmatch milestones → [`ROADMAP.md`](./ROADMAP.md).
+→ [`ROADMAP.md`](./ROADMAP.md).
## 📄 License
diff --git a/ROADMAP.md b/ROADMAP.md
index d7a56ce..6053565 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -6,22 +6,25 @@ Legend: `[x]` done · `[~]` partial · `[ ]` planned
---
-## Phase 0 — Shipped (v0.1)
+## Phase 0 — Shipped (v0.2)
-Core engine that runs the four pillars faster than upstream while holding 80–93% less RAM (see `BENCHMARK.md`).
+Real, working local memory engine + Claude Code integration. Verified end-to-end
+against Claude Code 2.1.191.
- [x] `init` / `stats` — palace DB bootstrap + pragma mapping
-- [x] `mine [wing]` — concurrent file ingestion (single-file path; directory walker being fixed in v0.1.1)
-- [x] `search ` — sqlite-vec L2 semantic retrieval
-- [x] `kg [subject]` — knowledge-graph relationship query
+- [x] `mine [wing]` — concurrent file ingestion; **directory walker fixed** (v0.1 mis-routed dirs to the conversation path → 0 files)
+- [x] **Real on-device embeddings** — `llama.cpp` MiniLM-L6-v2 (384-dim), Metal/CUDA, mean-pooled + L2-normalized (was a placeholder dummy vector)
+- [x] `search ` — `sqlite-vec` retrieval with **corrected hybrid scoring** (the old score was inverted → best match ranked last)
+- [x] **Static linking fixed** — links the cmake-built `llama.cpp` `.a` archives, not Homebrew dylibs (which crashed with a duplicate-dylib error)
- [x] `wake-up [--wing X]` — L0+L1 context loader (~600–900 tok)
-- [x] `hook` — JSON stdin/stdout for Claude Code hook pipeline
-- [x] `instructions` — skill-instruction emitter
-- [x] `mcp` — JSON-RPC MCP server for editor integration
-- [x] `mempalace.yaml` + `fast-mempalace.yaml` drop-in config
-- [x] Native Metal / CUDA embedding via statically linked `llama.cpp`
-- [x] MIT license, GitHub Actions CI, one-line curl installer, prebuilt release binaries
-- [x] OmniVoice corpus benchmark (450 KB → 1 171 drawers in 0.59 s)
+- [x] **`mcp` — real MCP server**: `memory_search` / `memory_store` / `memory_wake_up` / `memory_stats`, lazy model load, protocol-version echo (was a hardcoded stub)
+- [x] **`hook` — real Claude Code protocol**: SessionStart injects wake-up via `additionalContext`; PreCompact reads the transcript and **auto-saves** the tail (was a custom protocol + a nag)
+- [x] **Claude Code plugin** — `claude-plugin/` + marketplace manifest: MCP server + hooks + `using-memory` skill + `/remember` `/recall` commands; one global palace via `FAST_MEMPALACE_DB`/`_MODEL` env overrides
+- [x] `kg [subject]` — knowledge-graph relationship query (manual population)
+- [x] `instructions` — memory-instruction emitter
+- [x] `mempalace.yaml` + `fast-mempalace.yaml` config + env-var overrides
+- [x] MIT license, GitHub Actions CI, one-line curl installer (now fetches a **384-dim** model)
+- [x] Honest benchmarks vs the real engine (`BENCHMARK.md`); retrieval 7/7 top-1 on a paraphrase test
---
@@ -31,7 +34,7 @@ Close every remaining gap with the upstream `pip install mempalace` surface. Eac
> ⚠ Upstream CLI audit is still pending (PyPI fetch was blocked during planning). These items are inferred from project structure and the typical memory-tool surface. Cross-check against the upstream docs before cutting v0.2.
-- [ ] **Directory walker bug** — `fast-mempalace mine ` currently enumerates 0 files; single-file path works. Root cause in `src/miner.zig` walker loop on Zig 0.16. Blocks large-repo mining.
+- [x] **Directory walker bug** — fixed in v0.2. Root cause was `cmdMine` using `openFile` to discriminate (it succeeds on directories in Zig 0.16's IO), mis-routing dirs to the conversation path; now discriminates with `openDir`.
- [ ] **`mine` flag parity** — `--wing`, `--room`, `--recursive`, `--ignore`, `--dry-run`
- [ ] **`search` flag parity** — `--limit`, `--wing`, `--format=json|md|plain`, similarity threshold
- [ ] **`init` vs `stats`** — upstream uses `init`; alias our `stats` where appropriate
@@ -71,7 +74,7 @@ Ship features upstream Python cannot match without rewriting. Each lands a capab
- [ ] **Homebrew formula** — `brew install fast-mempalace`
- [ ] **Docker image** — ~15 MB distroless image (vs upstream ~1.2 GB Python+ML)
- [ ] **Shell completions** — zsh / bash / fish
-- [ ] **Claude Code plugin** — one-line install that wires hooks + MCP + slash commands
+- [x] **Claude Code plugin** — `claude-plugin/` wires MCP + hooks + skill + slash commands (v0.2)
- [ ] **Plugin SDK** — stable `lib/fast_mempalace.h` C ABI for 3rd-party languages
### v0.6+ — Intelligence Layer
diff --git a/assets/demo.gif b/assets/demo.gif
new file mode 100644
index 0000000..a3eeb49
Binary files /dev/null and b/assets/demo.gif differ
diff --git a/build.zig b/build.zig
index 90f2852..f81e5ad 100644
--- a/build.zig
+++ b/build.zig
@@ -17,64 +17,7 @@ pub fn build(b: *std.Build) void {
}),
});
- // SQLite3 (system library)
- exe.root_module.linkSystemLibrary("sqlite3", .{});
-
- // sqlite-vec extension (compiled from source)
- exe.root_module.addCSourceFile(.{
- .file = b.path("lib/sqlite-vec.c"),
- .flags = &[_][]const u8{ "-O3", "-fomit-frame-pointer", "-DSQLITE_CORE" },
- });
-
- // Include paths for C headers
- exe.root_module.addIncludePath(b.path("lib"));
- exe.root_module.addIncludePath(b.path("lib/llama.cpp/include"));
- exe.root_module.addIncludePath(b.path("lib/llama.cpp/ggml/include"));
-
- // Llama.cpp library paths (cmake output)
- exe.root_module.addLibraryPath(b.path("lib/llama.cpp/build/src"));
- exe.root_module.addLibraryPath(b.path("lib/llama.cpp/build/ggml/src"));
- exe.root_module.addLibraryPath(b.path("lib/llama.cpp/build/ggml/src/ggml-cpu"));
- exe.root_module.addLibraryPath(b.path("lib/llama.cpp/build/ggml/src/ggml-blas"));
- exe.root_module.addLibraryPath(b.path("lib/llama.cpp/build/ggml/src/ggml-metal"));
-
- // Core llama.cpp + ggml (present on all platforms)
- exe.root_module.linkSystemLibrary("llama", .{});
- exe.root_module.linkSystemLibrary("ggml", .{});
- exe.root_module.linkSystemLibrary("ggml-base", .{});
- exe.root_module.linkSystemLibrary("ggml-cpu", .{});
-
- // C++ runtime: libc++ on both platforms. On Linux we build llama.cpp
- // with clang + -stdlib=libc++ in CI so the ABI matches here.
- exe.root_module.linkSystemLibrary("c++", .{});
-
- if (is_macos) {
- // macOS: link Metal backend + Accelerate BLAS, and the system frameworks
- // llama.cpp's Metal backend depends on.
- exe.root_module.linkSystemLibrary("ggml-blas", .{});
- exe.root_module.linkSystemLibrary("ggml-metal", .{});
-
- exe.root_module.addLibraryPath(.{ .cwd_relative = "/opt/homebrew/lib" });
- exe.root_module.addIncludePath(.{ .cwd_relative = "/opt/homebrew/include" });
-
- exe.root_module.linkFramework("Accelerate", .{});
- exe.root_module.linkFramework("Metal", .{});
- exe.root_module.linkFramework("MetalKit", .{});
- exe.root_module.linkFramework("MetalPerformanceShaders", .{});
- exe.root_module.linkFramework("Foundation", .{});
- }
-
- // ── Llama.cpp Static Libraries ──
- // These must be pre-built via cmake before running `zig build`.
- // First-time setup:
- // macOS: cmake -S lib/llama.cpp -B lib/llama.cpp/build \
- // -DCMAKE_BUILD_TYPE=Release -DGGML_METAL=ON \
- // -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_SERVER=OFF
- // Linux: cmake -S lib/llama.cpp -B lib/llama.cpp/build \
- // -DCMAKE_BUILD_TYPE=Release -DGGML_METAL=OFF -DGGML_BLAS=OFF \
- // -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_SERVER=OFF
- // cmake --build lib/llama.cpp/build -j
-
+ configureModule(b, exe.root_module, is_macos);
b.installArtifact(exe);
// ── Run step ──
@@ -96,16 +39,51 @@ pub fn build(b: *std.Build) void {
.link_libc = true,
}),
});
- unit_tests.root_module.linkSystemLibrary("sqlite3", .{});
- unit_tests.root_module.addCSourceFile(.{
+ configureModule(b, unit_tests.root_module, is_macos);
+ test_step.dependOn(&b.addRunArtifact(unit_tests).step);
+}
+
+/// Wire up SQLite, sqlite-vec, and the statically-linked llama.cpp/ggml stack
+/// for a module. We link the cmake-produced `.a` archives directly (by path)
+/// rather than via `linkSystemLibrary`, so we never accidentally pick up a
+/// Homebrew dylib and we end up with a genuinely static binary.
+fn configureModule(b: *std.Build, mod: *std.Build.Module, is_macos: bool) void {
+ // SQLite3 (system library) + sqlite-vec (compiled from source).
+ mod.linkSystemLibrary("sqlite3", .{});
+ mod.addCSourceFile(.{
.file = b.path("lib/sqlite-vec.c"),
.flags = &[_][]const u8{ "-O3", "-fomit-frame-pointer", "-DSQLITE_CORE" },
});
- unit_tests.root_module.addIncludePath(b.path("lib"));
+
+ // Headers.
+ mod.addIncludePath(b.path("lib"));
+ mod.addIncludePath(b.path("lib/llama.cpp/include"));
+ mod.addIncludePath(b.path("lib/llama.cpp/ggml/include"));
+
+ // ── llama.cpp + ggml static archives (cmake output) ──
+ // Built via: cmake -S lib/llama.cpp -B lib/llama.cpp/build \
+ // -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \
+ // -DGGML_METAL=ON(macOS)/OFF(linux) -DGGML_BLAS=ON(macOS)/OFF(linux) \
+ // -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF \
+ // -DLLAMA_BUILD_SERVER=OFF -DLLAMA_BUILD_TOOLS=OFF
+ // then: cmake --build lib/llama.cpp/build -j
+ mod.addObjectFile(b.path("lib/llama.cpp/build/src/libllama.a"));
+ mod.addObjectFile(b.path("lib/llama.cpp/build/ggml/src/libggml.a"));
+ mod.addObjectFile(b.path("lib/llama.cpp/build/ggml/src/libggml-base.a"));
+ mod.addObjectFile(b.path("lib/llama.cpp/build/ggml/src/libggml-cpu.a"));
+
+ // C++ runtime: libc++ on both platforms. On Linux we build llama.cpp with
+ // clang + -stdlib=libc++ in CI so the ABI matches here.
+ mod.linkSystemLibrary("c++", .{});
+
if (is_macos) {
- unit_tests.root_module.addLibraryPath(.{ .cwd_relative = "/opt/homebrew/lib" });
- unit_tests.root_module.addIncludePath(.{ .cwd_relative = "/opt/homebrew/include" });
+ // macOS: Metal + Accelerate BLAS backends and the frameworks they need.
+ mod.addObjectFile(b.path("lib/llama.cpp/build/ggml/src/ggml-blas/libggml-blas.a"));
+ mod.addObjectFile(b.path("lib/llama.cpp/build/ggml/src/ggml-metal/libggml-metal.a"));
+
+ mod.linkFramework("Accelerate", .{});
+ mod.linkFramework("Metal", .{});
+ mod.linkFramework("MetalKit", .{});
+ mod.linkFramework("Foundation", .{});
}
-
- test_step.dependOn(&b.addRunArtifact(unit_tests).step);
}
diff --git a/claude-plugin/.claude-plugin/plugin.json b/claude-plugin/.claude-plugin/plugin.json
new file mode 100644
index 0000000..af18db9
--- /dev/null
+++ b/claude-plugin/.claude-plugin/plugin.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
+ "name": "fast-mempalace",
+ "displayName": "Fast MemPalace — Local Memory",
+ "version": "0.2.0",
+ "description": "Persistent, private, local-first memory for Claude Code. Your agent remembers your code and decisions across sessions — one static binary, no cloud, nothing leaves your machine.",
+ "author": { "name": "MemPalace", "url": "https://github.com/debpalash" },
+ "homepage": "https://github.com/debpalash/fast-mempalace",
+ "repository": "https://github.com/debpalash/fast-mempalace",
+ "license": "MIT",
+ "keywords": ["memory", "mcp", "local-first", "claude-code", "embeddings", "privacy"],
+ "mcpServers": "./.mcp.json",
+ "hooks": "./hooks/hooks.json",
+ "skills": "./skills/",
+ "commands": "./commands/"
+}
diff --git a/claude-plugin/.mcp.json b/claude-plugin/.mcp.json
new file mode 100644
index 0000000..5e87955
--- /dev/null
+++ b/claude-plugin/.mcp.json
@@ -0,0 +1,12 @@
+{
+ "mcpServers": {
+ "memory": {
+ "command": "${HOME}/.fast-mempalace/bin/fast-mempalace",
+ "args": ["mcp"],
+ "env": {
+ "FAST_MEMPALACE_DB": "${HOME}/.fast-mempalace/palace.db",
+ "FAST_MEMPALACE_MODEL": "${HOME}/.fast-mempalace/lib/minilm.gguf"
+ }
+ }
+ }
+}
diff --git a/claude-plugin/README.md b/claude-plugin/README.md
new file mode 100644
index 0000000..b8945f5
--- /dev/null
+++ b/claude-plugin/README.md
@@ -0,0 +1,46 @@
+# Fast MemPalace — Claude Code plugin
+
+Gives Claude Code a persistent, **local-first** memory. Your agent remembers your
+codebase, your decisions, and your conventions across sessions — and nothing ever
+leaves your machine.
+
+## What it adds
+
+- **MCP server `memory`** with four tools: `memory_search`, `memory_store`,
+ `memory_wake_up`, `memory_stats`.
+- **SessionStart hook** — injects a compact "wake-up" brief of recent memory at the
+ start of every session (and again right after compaction).
+- **PreCompact hook** — saves the recent conversation *before* it's compacted away,
+ so context survives.
+- **Skill `using-memory`** — teaches Claude when to recall and when to persist.
+- **Slash commands** — `/remember ` and `/recall `.
+
+## Install
+
+1. Install the engine (single static binary + 45 MB embedding model, fully local):
+
+ ```bash
+ curl -fsSL https://raw.githubusercontent.com/debpalash/fast-mempalace/main/install.sh | bash
+ ```
+
+2. Add the plugin in Claude Code:
+
+ ```
+ /plugin marketplace add debpalash/fast-mempalace
+ /plugin install fast-mempalace
+ ```
+
+That's it. Memory lives in a single global palace at `~/.fast-mempalace/palace.db`.
+
+## Seed memory from a codebase (optional)
+
+```bash
+FAST_MEMPALACE_DB=~/.fast-mempalace/palace.db \
+FAST_MEMPALACE_MODEL=~/.fast-mempalace/lib/minilm.gguf \
+~/.fast-mempalace/bin/fast-mempalace mine . my-project
+```
+
+## Privacy
+
+Embeddings, storage, and search all run on-device. No API keys, no network calls at
+query time. Your code and memories stay on your machine.
diff --git a/claude-plugin/commands/recall.md b/claude-plugin/commands/recall.md
new file mode 100644
index 0000000..0a512b0
--- /dev/null
+++ b/claude-plugin/commands/recall.md
@@ -0,0 +1,12 @@
+---
+name: recall
+description: Search long-term local memory and answer from it
+---
+
+Use the `memory_search` tool to recall everything relevant to the query below, then
+answer using what you find. Cite the source of each memory you rely on. If memory has
+nothing relevant, say so plainly rather than guessing.
+
+Query:
+
+$ARGUMENTS
diff --git a/claude-plugin/commands/remember.md b/claude-plugin/commands/remember.md
new file mode 100644
index 0000000..5cef0f5
--- /dev/null
+++ b/claude-plugin/commands/remember.md
@@ -0,0 +1,14 @@
+---
+name: remember
+description: Save something to long-term local memory
+---
+
+Store the following in long-term memory using the `memory_store` tool, kept verbatim
+and concise. If it spans multiple distinct facts, store each as its own memory.
+
+What to remember:
+
+$ARGUMENTS
+
+If no text was given, summarize the most important decision or fact from the current
+conversation and store that instead. Confirm what you stored in one line.
diff --git a/claude-plugin/hooks/hooks.json b/claude-plugin/hooks/hooks.json
new file mode 100644
index 0000000..5ab989e
--- /dev/null
+++ b/claude-plugin/hooks/hooks.json
@@ -0,0 +1,28 @@
+{
+ "hooks": {
+ "SessionStart": [
+ {
+ "matcher": "*",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/hook.sh",
+ "timeout": 15
+ }
+ ]
+ }
+ ],
+ "PreCompact": [
+ {
+ "matcher": "*",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/hook.sh",
+ "timeout": 30
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/claude-plugin/scripts/hook.sh b/claude-plugin/scripts/hook.sh
new file mode 100755
index 0000000..7e89b85
--- /dev/null
+++ b/claude-plugin/scripts/hook.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+# Bridge a Claude Code hook event to fast-mempalace, pinning the global palace
+# and embedding model so memory is consistent across every project. The hook
+# JSON arrives on stdin and the response goes to stdout — we just pass through.
+export FAST_MEMPALACE_DB="${FAST_MEMPALACE_DB:-$HOME/.fast-mempalace/palace.db}"
+export FAST_MEMPALACE_MODEL="${FAST_MEMPALACE_MODEL:-$HOME/.fast-mempalace/lib/minilm.gguf}"
+BIN="${FAST_MEMPALACE_BIN:-$HOME/.fast-mempalace/bin/fast-mempalace}"
+
+# If the binary isn't installed, stay silent and let the session continue.
+[ -x "$BIN" ] || { echo '{}'; exit 0; }
+exec "$BIN" hook
diff --git a/claude-plugin/skills/using-memory/SKILL.md b/claude-plugin/skills/using-memory/SKILL.md
new file mode 100644
index 0000000..2e1077c
--- /dev/null
+++ b/claude-plugin/skills/using-memory/SKILL.md
@@ -0,0 +1,41 @@
+---
+name: using-memory
+description: Use the local Fast MemPalace memory to recall past decisions, code, and context, and to persist new ones. Invoke whenever the user refers to prior work ("what did we decide", "how did we", "last time", "remember when") or makes a decision worth keeping.
+---
+
+# Using Fast MemPalace memory
+
+You have a persistent, local memory palace via the `memory` MCP server. It survives
+across sessions and never leaves this machine. Four tools:
+
+- **`memory_search`** — semantic recall. Call this *before* answering any question
+ about prior work, past decisions, project conventions, or "how did we do X". Don't
+ assume you don't know — check memory first.
+- **`memory_store`** — persist something worth remembering: a decision and its
+ rationale, a non-obvious constraint, a key snippet, a fact about the user or project.
+ Store it verbatim and concise.
+- **`memory_wake_up`** — load the compact continuity brief (you also get this
+ automatically at session start).
+- **`memory_stats`** — how much is stored.
+
+## When to recall
+
+Before answering "what / why / how did we …", "last time", "remember when",
+"our convention for …", or anything that depends on history, call `memory_search`
+with a natural-language query. Cite what you find.
+
+## When to store
+
+After a real decision ("let's use X because Y"), a discovered constraint, a
+correction the user makes, or a fact you'd want next session, call `memory_store`.
+Prefer one crisp memory per fact. Good memories are specific and self-contained:
+
+> "Cart is capped at 37 items because the Brightwell ERP rejects larger orders (0x5C error)."
+
+not "we talked about the cart."
+
+## Principles
+
+- Memory is verbatim — never paraphrase a stored fact into something false.
+- Local-first: all recall/embedding happens on-device; nothing is uploaded.
+- When unsure whether something is remembered, search — it's cheap.
diff --git a/docs/launch/README.md b/docs/launch/README.md
new file mode 100644
index 0000000..abe4ee0
--- /dev/null
+++ b/docs/launch/README.md
@@ -0,0 +1,29 @@
+# Launch kit
+
+Assets for launching fast-mempalace.
+
+- **`show-hn.md`** — the launch post (Show HN / r/LocalLLaMA / r/mcp). Builder voice,
+ reproducible numbers, no superlatives. Includes title options.
+- **`demo.tape`** — [VHS](https://github.com/charmbracelet/vhs) script that renders
+ `../../assets/demo.gif`. Run from the repo root:
+
+ ```bash
+ brew install vhs # needs ttyd + ffmpeg (pulled in)
+ curl -fsSL .../install.sh | bash # the demo uses the installed binary + model
+ vhs docs/launch/demo.tape
+ ```
+
+- **`demo/sample-project/`** — the tiny project the demo mines (clear, recall-worthy
+ decisions). Self-contained and reproducible.
+
+## Launch sequence (from the strategy)
+
+1. Publish `show-hn.md` as a post on your own domain (the benchmark/demo *is* the launch).
+2. **Show HN**, Tue–Thu 9–11am ET. First comment = problem → why Zig → reproduce-it link. Camp the thread.
+3. Same day: **r/LocalLLaMA** + **r/mcp** (lead with the GIF, builder voice).
+4. Timed **X/Twitter** thread: GIF first, benchmark image second.
+5. PRs into **awesome-mcp-servers** ("Knowledge & Memory"), **awesome-claude-code**, **awesome-zig**.
+6. Newsletter pitches: Latent Space, TLDR AI, Simon Willison.
+
+Top 3 things that would sink it: unreproducible benchmarks, corporate/superlative voice,
+a broken `curl | sh` on a clean box during the spike. Test the installer on a fresh VM first.
diff --git a/docs/launch/demo.tape b/docs/launch/demo.tape
new file mode 100644
index 0000000..b27a489
--- /dev/null
+++ b/docs/launch/demo.tape
@@ -0,0 +1,55 @@
+# VHS tape for the fast-mempalace demo GIF.
+# brew install vhs && vhs docs/launch/demo.tape (run from repo root)
+# Requires the binary + model installed at ~/.fast-mempalace (curl installer).
+
+Output assets/demo.gif
+
+Set Shell "bash"
+Set FontSize 20
+Set Width 1180
+Set Height 680
+Set Padding 26
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 35ms
+
+# ── setup (hidden) ──
+Hide
+Type "export PATH=$HOME/.fast-mempalace/bin:$PATH" Enter
+Type "export FAST_MEMPALACE_DB=/tmp/demo-palace.db FAST_MEMPALACE_MODEL=$HOME/.fast-mempalace/lib/minilm.gguf" Enter
+Type 'ask() { fast-mempalace search "$1" 2>&1 | sed -n "1,7p"; }' Enter
+Type "cd docs/launch/demo && rm -f /tmp/demo-palace.db* && fast-mempalace init >/dev/null 2>&1 && clear" Enter
+Show
+
+# ── mine a project ──
+Type "# Your AI agent forgets your project between sessions. Give it a memory:" Enter
+Sleep 1200ms
+Type "fast-mempalace mine ./sample-project my-app" Enter
+Sleep 3s
+
+Hide
+Type "clear" Enter
+Show
+
+# ── semantic recall #1 ──
+Type "# Ask in plain English — semantic recall, not grep:" Enter
+Sleep 900ms
+Type "ask 'why is the file upload size limited?'" Enter
+Sleep 3500ms
+
+Hide
+Type "clear" Enter
+Show
+
+# ── semantic recall #2 ──
+Type "ask 'how do users sign in?'" Enter
+Sleep 3500ms
+
+Hide
+Type "clear" Enter
+Show
+
+# ── the pitch ──
+Type "# One static binary. Fully local. Nothing leaves your machine." Enter
+Sleep 800ms
+Type "# In Claude Code: /plugin install fast-mempalace → memory_search, hooks, auto-save." Enter
+Sleep 3s
diff --git a/docs/launch/demo/sample-project/auth.js b/docs/launch/demo/sample-project/auth.js
new file mode 100644
index 0000000..d0d0c61
--- /dev/null
+++ b/docs/launch/demo/sample-project/auth.js
@@ -0,0 +1,3 @@
+// Login is passwordless: users get a magic link by email. We dropped passwords
+// entirely after the 2024 credential-stuffing incident — fewer secrets to leak.
+export function sendMagicLink(email) { /* ... */ }
diff --git a/docs/launch/demo/sample-project/cache.py b/docs/launch/demo/sample-project/cache.py
new file mode 100644
index 0000000..0813a9c
--- /dev/null
+++ b/docs/launch/demo/sample-project/cache.py
@@ -0,0 +1,3 @@
+# We chose Redis over Memcached for the session cache because we need
+# pub/sub for live presence, which Memcached can't do.
+TTL_SECONDS = 3600
diff --git a/docs/launch/demo/sample-project/decisions.md b/docs/launch/demo/sample-project/decisions.md
new file mode 100644
index 0000000..dc65118
--- /dev/null
+++ b/docs/launch/demo/sample-project/decisions.md
@@ -0,0 +1,3 @@
+File uploads are capped at 25 MB because our CDN rejects any object larger than
+that with a 413 at the edge. Validate client-side before the upload starts.
+Money is stored as integer cents, never floats, to avoid rounding drift.
diff --git a/docs/launch/show-hn.md b/docs/launch/show-hn.md
new file mode 100644
index 0000000..74e31a8
--- /dev/null
+++ b/docs/launch/show-hn.md
@@ -0,0 +1,91 @@
+# Your AI coding agent forgets everything. I gave it a memory — in a single Zig binary.
+
+*Draft launch post for HN / r/LocalLLaMA / r/mcp. Builder voice, reproducible numbers, no superlatives.*
+
+---
+
+Every Claude Code session starts amnesiac. I re-explain the architecture, re-state why
+we chose SQLite over Postgres, and the agent still contradicts a decision we made last
+week. The fix everyone reaches for — a cloud "memory layer" — means shipping my
+proprietary code to someone else's server and paying per token to remember my own work.
+
+So I built **fast-mempalace**: a persistent, **local-first** memory for AI coding agents.
+It's one static binary. No Python, no Docker, no vector database to run, no API key, and
+no network call at query time. Your code and your memories never leave the machine.
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/debpalash/fast-mempalace/main/install.sh | bash
+```
+
+Then, in Claude Code:
+
+```
+/plugin marketplace add debpalash/fast-mempalace
+/plugin install fast-mempalace
+```
+
+That's the whole setup. The agent now gets four memory tools over MCP
+(`memory_search`, `memory_store`, `memory_wake_up`, `memory_stats`), a wake-up brief
+injected at the start of every session, and an auto-save of the conversation right
+before it gets compacted away.
+
+## Does it actually work?
+
+Here's the test I trust. I mined a small repo into memory, then asked Claude Code a
+question whose answer exists **only** in that code — with the file, search, and web
+tools *disabled*, so the only way to answer is to recall it:
+
+```
+$ claude -p "What is the max number of items in the ShopFast cart, and why?" \
+ --disallowedTools "Read,Bash,Grep,Glob"
+ShopFast caps the cart at 37 items because the Brightwell ERP rejects larger
+orders with a 0x5C error, so it's enforced client-side as a guard.
+```
+
+It pulled "37 items / Brightwell ERP / 0x5C" straight from `memory_search`. On a
+4-topic paraphrase test (queries that share no keywords with the stored text), top-1
+retrieval is 7/7.
+
+## Why a single binary matters
+
+Almost every agent-memory tool today is a Python or Node package sitting on top of a
+vector DB (Chroma, pgvector, Pinecone) or a managed cloud service. That's a dependency
+tree, a service to run, and — for the cloud ones — your data on someone else's box.
+
+fast-mempalace is `llama.cpp` + `sqlite-vec` statically linked into one ~6 MB
+executable. Embeddings (MiniLM-L6-v2, 384-dim) run on-device via Metal/CUDA. Storage is
+one SQLite file. There's nothing to stand up and nothing to phone home. Memories are
+stored **verbatim** — never silently rewritten or summarized by an LLM — which also
+means there's no graph-query surface to poison.
+
+## Numbers (Apple Silicon, Metal, honest)
+
+| Operation | Time | Notes |
+|--|--|--|
+| Cold start / session wake-up | **0.01 s** | no model load — runs every session |
+| Mine 15 files → 31 drawers | **~1.0 s** | real on-device embeddings |
+| Vector search (warm MCP server) | **sub-ms** | model loaded once, stays resident |
+| Binary size | **~6 MB** | statically linked, zero runtime deps |
+| Peak RAM (model loaded) | **~100 MB** | mostly the embedding model |
+
+Full method + a one-command reproduction is in
+[`BENCHMARK.md`](https://github.com/debpalash/fast-mempalace/blob/main/BENCHMARK.md).
+(I'll be upfront: an earlier version of this repo reported a `0.59 s` mine of 1,171
+drawers — that run used placeholder vectors. These are the real semantic engine.)
+
+## What it isn't
+
+It's not a cloud service, not an LLM, and not trying to replace your model — it's the
+memory substrate that feeds whatever model you already use. It's early (v0.2). The
+knowledge graph is there but populated manually for now. I'd love feedback on retrieval
+quality and on the hook UX.
+
+MIT licensed. Code, plugin, and reproduction:
+**https://github.com/debpalash/fast-mempalace**
+
+---
+
+### Title options
+- `Show HN: fast-mempalace – local-first memory for AI agents in a single Zig binary (no vector DB)`
+- `Show HN: I gave Claude Code a persistent memory that never leaves my machine`
+- *(r/LocalLLaMA)* `Local-first memory for coding agents: llama.cpp + sqlite-vec in one static binary, no cloud`
diff --git a/install.sh b/install.sh
index 61d5546..69cafeb 100755
--- a/install.sh
+++ b/install.sh
@@ -2,22 +2,27 @@
# Fast MemPalace installer — fetches prebuilt native binary + embedding model.
#
# Usage:
-# curl -fsSL https://raw.githubusercontent.com/MemPalace/fast-mempalace/main/install.sh | bash
+# curl -fsSL https://raw.githubusercontent.com/debpalash/fast-mempalace/main/install.sh | bash
#
# Env overrides:
# FAST_MEMPALACE_VERSION Release tag to install (default: latest)
# FAST_MEMPALACE_INSTALL Install prefix (default: $HOME/.fast-mempalace)
-# FAST_MEMPALACE_REPO GitHub repo (default: MemPalace/fast-mempalace)
+# FAST_MEMPALACE_REPO GitHub repo (default: debpalash/fast-mempalace)
# FAST_MEMPALACE_NO_MODEL Skip GGUF embedding model download (default: 0)
set -euo pipefail
-REPO="${FAST_MEMPALACE_REPO:-MemPalace/fast-mempalace}"
+TMP_DIR=""
+trap '[ -n "$TMP_DIR" ] && rm -rf "$TMP_DIR"' EXIT
+
+REPO="${FAST_MEMPALACE_REPO:-debpalash/fast-mempalace}"
VERSION="${FAST_MEMPALACE_VERSION:-latest}"
INSTALL_DIR="${FAST_MEMPALACE_INSTALL:-$HOME/.fast-mempalace}"
BIN_DIR="$INSTALL_DIR/bin"
LIB_DIR="$INSTALL_DIR/lib"
-MODEL_URL="https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.f16.gguf"
+# MiniLM-L6-v2, 384-dim (matches EMBEDDING_DIM and the vec_drawers schema).
+# Must stay a 384-dim model or fresh installs fail the dimension check.
+MODEL_URL="https://huggingface.co/leliuga/all-MiniLM-L6-v2-GGUF/resolve/main/all-MiniLM-L6-v2.F16.gguf"
C_RESET='\033[0m'; C_BOLD='\033[1m'; C_DIM='\033[2m'
C_BLUE='\033[34m'; C_GREEN='\033[32m'; C_RED='\033[31m'; C_YELLOW='\033[33m'
@@ -70,15 +75,13 @@ install_binary() {
local platform="$1"
local tarball="fast-mempalace-${platform}.tar.gz"
local url="https://github.com/$REPO/releases/download/$VERSION/$tarball"
- local tmp
- tmp=$(mktemp -d)
- trap 'rm -rf "$tmp"' EXIT
+ TMP_DIR=$(mktemp -d) # global so the EXIT trap can clean it after main returns
mkdir -p "$BIN_DIR"
- download "$url" "$tmp/$tarball"
- tar -xzf "$tmp/$tarball" -C "$tmp"
- [ -f "$tmp/fast-mempalace" ] || die "tarball missing 'fast-mempalace' binary"
- install -m 755 "$tmp/fast-mempalace" "$BIN_DIR/fast-mempalace"
+ download "$url" "$TMP_DIR/$tarball"
+ tar -xzf "$TMP_DIR/$tarball" -C "$TMP_DIR"
+ [ -f "$TMP_DIR/fast-mempalace" ] || die "tarball missing 'fast-mempalace' binary"
+ install -m 755 "$TMP_DIR/fast-mempalace" "$BIN_DIR/fast-mempalace"
ok "installed $BIN_DIR/fast-mempalace"
}
@@ -117,8 +120,9 @@ shell_hint() {
printf " source %s\n\n" "$shell_rc"
fi
printf "%bQuick check:%b\n\n fast-mempalace stats\n\n" "$C_BOLD" "$C_RESET"
- printf "%bOptional drop-in alias%b (keeps legacy scripts working):\n\n ln -s %s/fast-mempalace %s/mempalace\n\n" \
- "$C_BOLD" "$C_RESET" "$BIN_DIR" "$BIN_DIR"
+ printf "%bClaude Code:%b add persistent memory in two lines —\n\n /plugin marketplace add debpalash/fast-mempalace\n /plugin install fast-mempalace\n\n" \
+ "$C_BOLD" "$C_RESET"
+ printf "%bSeed memory from a repo (optional):%b\n\n fast-mempalace mine . my-project\n\n" "$C_BOLD" "$C_RESET"
printf "%bDocs:%b https://github.com/%s\n" "$C_DIM" "$C_RESET" "$REPO"
}
diff --git a/lib/llama.cpp b/lib/llama.cpp
index 45cac7c..23b8cc4 160000
--- a/lib/llama.cpp
+++ b/lib/llama.cpp
@@ -1 +1 @@
-Subproject commit 45cac7ca703fb9085eae62b9121fca01d20177f6
+Subproject commit 23b8cc49911d502d8e9710e98ad5dee5b637cf79
diff --git a/packaging/awesome-entries.md b/packaging/awesome-entries.md
new file mode 100644
index 0000000..b01b7a9
--- /dev/null
+++ b/packaging/awesome-entries.md
@@ -0,0 +1,41 @@
+# Awesome-list submissions
+
+Ready-to-submit entries for fast-mempalace. The MCP one is the strongest fit and
+worth submitting first; the rest land best right after the Show HN, when there's
+some traction (many awesome-lists expect a little maturity).
+
+## 1. punkpeye/awesome-mcp-servers → "🧠 Knowledge & Memory"
+
+Insert alphabetically (by `author/name`):
+
+```
+- [debpalash/fast-mempalace](https://github.com/debpalash/fast-mempalace) 🏠 🍎 🐧 - Local-first long-term memory for AI coding agents in a single static Zig binary (llama.cpp + sqlite-vec). On-device embeddings, semantic recall, verbatim storage — no cloud, no vector DB, nothing leaves your machine. Ships a Claude Code plugin (memory tools + session hooks).
+```
+
+Legend: 🏠 local service · 🍎 macOS · 🐧 Linux.
+
+## 2. hesreallyhim/awesome-claude-code → tooling / MCP section
+
+```
+- [fast-mempalace](https://github.com/debpalash/fast-mempalace) - Persistent, local-first memory for Claude Code: an MCP memory server plus SessionStart/PreCompact hooks that auto-load context at session start and auto-save before compaction. One static binary, fully on-device.
+```
+
+## 3. catppuccin/awesome-zig (or zigcc/awesome-zig) → Applications / Tools
+
+```
+- [fast-mempalace](https://github.com/debpalash/fast-mempalace) - Local-first long-term memory engine for AI coding agents. Statically links llama.cpp + sqlite-vec into one binary; MCP server + Claude Code plugin.
+```
+
+## How to submit (per list)
+
+```bash
+gh repo fork / --clone --remote
+cd
+# edit the README, add the entry in the right section
+git checkout -b add-fast-mempalace
+git commit -am "Add fast-mempalace (local-first agent memory)"
+git push -u origin add-fast-mempalace
+gh pr create --repo / --title "Add fast-mempalace" --body "..."
+```
+
+Timing: submit #1 now; submit #2 and #3 right after the Show HN front-pages.
diff --git a/packaging/homebrew/fast-mempalace.rb b/packaging/homebrew/fast-mempalace.rb
new file mode 100644
index 0000000..6c48496
--- /dev/null
+++ b/packaging/homebrew/fast-mempalace.rb
@@ -0,0 +1,66 @@
+# Homebrew formula for fast-mempalace.
+# Lives in the tap repo (debpalash/homebrew-fast-mempalace); this copy is kept
+# in-tree for reference. Binary sha256s are filled from the release .sha256
+# artifacts by scripts/update-tap.sh.
+class FastMempalace < Formula
+ desc "Local-first long-term memory for AI coding agents"
+ homepage "https://github.com/debpalash/fast-mempalace"
+ version "0.2.0"
+ license "MIT"
+
+ on_macos do
+ on_arm do
+ url "https://github.com/debpalash/fast-mempalace/releases/download/v0.2.0/fast-mempalace-darwin-aarch64.tar.gz"
+ sha256 "REPLACE_DARWIN_AARCH64"
+ end
+ on_intel do
+ url "https://github.com/debpalash/fast-mempalace/releases/download/v0.2.0/fast-mempalace-darwin-x86_64.tar.gz"
+ sha256 "REPLACE_DARWIN_X86_64"
+ end
+ end
+
+ on_linux do
+ on_arm do
+ url "https://github.com/debpalash/fast-mempalace/releases/download/v0.2.0/fast-mempalace-linux-aarch64.tar.gz"
+ sha256 "REPLACE_LINUX_AARCH64"
+ end
+ on_intel do
+ url "https://github.com/debpalash/fast-mempalace/releases/download/v0.2.0/fast-mempalace-linux-x86_64.tar.gz"
+ sha256 "REPLACE_LINUX_X86_64"
+ end
+ end
+
+ # MiniLM-L6-v2, 384-dim embedding model (must match the float[384] schema).
+ resource "model" do
+ url "https://huggingface.co/leliuga/all-MiniLM-L6-v2-GGUF/resolve/main/all-MiniLM-L6-v2.F16.gguf"
+ sha256 "797b70c4edf85907fe0a49eb85811256f65fa0f7bf52166b147fd16be2be4662"
+ end
+
+ def install
+ libexec.install "fast-mempalace"
+ resource("model").stage do
+ libexec.install "all-MiniLM-L6-v2.F16.gguf" => "minilm.gguf"
+ end
+ # Wrapper pins the bundled model so the binary works with zero config.
+ (bin/"fast-mempalace").write <<~SH
+ #!/bin/bash
+ export FAST_MEMPALACE_MODEL="${FAST_MEMPALACE_MODEL:-#{libexec}/minilm.gguf}"
+ exec "#{libexec}/fast-mempalace" "$@"
+ SH
+ end
+
+ def caveats
+ <<~EOS
+ Add persistent memory to Claude Code:
+ /plugin marketplace add MemPalace/fast-mempalace
+ /plugin install fast-mempalace
+
+ Memory lives in a single local palace; nothing leaves your machine.
+ EOS
+ end
+
+ test do
+ system bin/"fast-mempalace", "init"
+ assert_match "Memory Palace Stats", shell_output("#{bin}/fast-mempalace stats")
+ end
+end
diff --git a/scripts/update-tap.sh b/scripts/update-tap.sh
new file mode 100755
index 0000000..6008520
--- /dev/null
+++ b/scripts/update-tap.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# Fill the Homebrew formula's binary sha256s from a published release and copy it
+# into the tap repo. Usage: scripts/update-tap.sh [tap-checkout-dir]
+# e.g. scripts/update-tap.sh 0.2.0 ../homebrew-fast-mempalace
+set -euo pipefail
+VERSION="${1:?usage: update-tap.sh [tap-dir]}"
+TAP_DIR="${2:-../homebrew-fast-mempalace}"
+REPO="${FAST_MEMPALACE_REPO:-debpalash/fast-mempalace}"
+SRC="packaging/homebrew/fast-mempalace.rb"
+BASE="https://github.com/$REPO/releases/download/v$VERSION"
+
+tmp=$(mktemp); cp "$SRC" "$tmp"
+declare -A MAP=(
+ [REPLACE_DARWIN_AARCH64]=darwin-aarch64
+ [REPLACE_DARWIN_X86_64]=darwin-x86_64
+ [REPLACE_LINUX_AARCH64]=linux-aarch64
+ [REPLACE_LINUX_X86_64]=linux-x86_64
+)
+for ph in "${!MAP[@]}"; do
+ plat="${MAP[$ph]}"
+ sha=$(curl -fsSL "$BASE/fast-mempalace-$plat.tar.gz.sha256" | awk '{print $1}') \
+ || { echo "warn: no artifact for $plat (skipping)"; continue; }
+ sed -i '' "s/$ph/$sha/" "$tmp" 2>/dev/null || sed -i "s/$ph/$sha/" "$tmp"
+ echo " $plat -> $sha"
+done
+sed -i '' "s/version \"[^\"]*\"/version \"$VERSION\"/" "$tmp" 2>/dev/null || sed -i "s/version \"[^\"]*\"/version \"$VERSION\"/" "$tmp"
+
+mkdir -p "$TAP_DIR/Formula"
+cp "$tmp" "$TAP_DIR/Formula/fast-mempalace.rb"
+echo "Wrote $TAP_DIR/Formula/fast-mempalace.rb"
diff --git a/src/config.zig b/src/config.zig
index 3a76bf8..cd3e1fd 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -1,5 +1,10 @@
const std = @import("std");
+const c = @cImport({
+ @cInclude("stdlib.h");
+ @cInclude("unistd.h");
+});
+
pub const Config = struct {
default_wing: [:0]const u8 = "default",
database_path: [:0]const u8 = "fast-mempalace.db",
@@ -110,6 +115,52 @@ pub fn load(allocator: std.mem.Allocator, io: std.Io) !Config {
return cfg;
}
+/// Apply environment-variable overrides. These take precedence over the yaml
+/// and defaults, and are how the Claude Code plugin pins a single global palace
+/// and model regardless of which project directory the agent is launched in:
+/// FAST_MEMPALACE_DB → database_path
+/// FAST_MEMPALACE_MODEL → model_path
+/// FAST_MEMPALACE_WING → default_wing
+pub fn applyEnvOverrides(cfg: *Config, allocator: std.mem.Allocator) void {
+ overrideZ(&cfg.database_path, "fast-mempalace.db", "FAST_MEMPALACE_DB", allocator);
+ overrideZ(&cfg.model_path, "lib/minilm.gguf", "FAST_MEMPALACE_MODEL", allocator);
+ overrideZ(&cfg.default_wing, "default", "FAST_MEMPALACE_WING", allocator);
+ resolveModelPath(cfg, allocator);
+}
+
+fn fileExists(path: [:0]const u8) bool {
+ return c.access(path.ptr, 0) == 0; // F_OK
+}
+
+/// If the configured model isn't where it points, fall back to the curl
+/// installer's standard location so an installed binary finds its model with
+/// zero config: ~/.fast-mempalace/lib/minilm.gguf. (The Homebrew formula sets
+/// FAST_MEMPALACE_MODEL explicitly via a wrapper, so it never reaches here.)
+fn resolveModelPath(cfg: *Config, allocator: std.mem.Allocator) void {
+ if (fileExists(cfg.model_path)) return;
+
+ const home_raw = c.getenv("HOME") orelse return;
+ const home = std.mem.span(home_raw);
+ const tmp = std.fmt.allocPrint(allocator, "{s}/.fast-mempalace/lib/minilm.gguf", .{home}) catch return;
+ defer allocator.free(tmp);
+ const cand = allocator.dupeZ(u8, tmp) catch return;
+ if (fileExists(cand)) {
+ if (!std.mem.eql(u8, cfg.model_path, "lib/minilm.gguf")) allocator.free(cfg.model_path);
+ cfg.model_path = cand;
+ } else {
+ allocator.free(cand);
+ }
+}
+
+fn overrideZ(field: *[:0]const u8, default_lit: []const u8, env_key: [:0]const u8, allocator: std.mem.Allocator) void {
+ const raw = c.getenv(env_key.ptr) orelse return;
+ const val = std.mem.span(raw);
+ if (val.len == 0) return;
+ const dup = allocator.dupeZ(u8, val) catch return;
+ if (!std.mem.eql(u8, field.*, default_lit)) allocator.free(field.*);
+ field.* = dup;
+}
+
const String = []const u8;
fn extractYamlValue(input: []const u8) []const u8 {
diff --git a/src/embedder.zig b/src/embedder.zig
index a047e19..ea7b3ce 100644
--- a/src/embedder.zig
+++ b/src/embedder.zig
@@ -1,8 +1,13 @@
// ═══════════════════════════════════════════════════════════════════
-// fast-mempalace/embedder.zig — Vector Embedding
+// fast-mempalace/embedder.zig — Local Vector Embeddings (llama.cpp)
//
-// Placeholder for Phase 2: GGML / ONNX Runtime integration.
-// Currently returns a dummy uniform vector for testing.
+// Runs a GGUF sentence-embedding model (default: MiniLM-L6-v2, 384-dim)
+// fully on-device via statically-linked llama.cpp. Encoder models use
+// llama_encode + mean pooling; output vectors are L2-normalized so a
+// vec0 L2 distance is monotonic with cosine similarity.
+//
+// A single llama_context is shared process-wide and guarded by a mutex,
+// so the concurrent miner can call embed() from multiple tasks safely.
// ═══════════════════════════════════════════════════════════════════
const std = @import("std");
@@ -12,29 +17,72 @@ const c = @cImport({
@cInclude("llama.h");
});
-// Default MiniLM-L6-v2 embedding size
+// MiniLM-L6-v2 embedding width. The vec_drawers virtual table is declared
+// float[384], so the active model MUST match this. Validated in init().
pub const EMBEDDING_DIM = 384;
+// Hard cap on tokens per chunk. MiniLM's positional limit is 512; we keep a
+// little headroom for special tokens and simply truncate longer inputs.
+const MAX_TOKENS = 512;
+
pub const Embedder = struct {
model: *c.llama_model,
ctx: *c.llama_context,
+ vocab: *const c.llama_vocab,
+ n_embd: i32,
+ is_encoder: bool,
+ // Lightweight spinlock guarding the shared llama_context. Works under any
+ // IO threading model (unlike std.Io.Mutex it needs no `io` handle), and
+ // contention is brief since embed() is short and CPU-bound.
+ lock_flag: std.atomic.Value(bool) = .init(false),
+
+ fn acquire(self: *Embedder) void {
+ while (self.lock_flag.cmpxchgWeak(false, true, .acquire, .monotonic) != null) {
+ std.atomic.spinLoopHint();
+ }
+ }
+ fn release(self: *Embedder) void {
+ self.lock_flag.store(false, .release);
+ }
pub fn init(model_path: [:0]const u8) !Embedder {
+ // Silence llama.cpp's stderr chatter so it can never corrupt the JSON
+ // we emit on stdout for the MCP server and Claude Code hooks.
+ c.llama_log_set(quietLog, null);
c.llama_backend_init();
- var params = c.llama_model_default_params();
- params.n_gpu_layers = 99;
- params.use_mmap = false;
- const model = c.llama_model_load_from_file(model_path.ptr, params) orelse return error.ModelLoadFailed;
+ var mparams = c.llama_model_default_params();
+ mparams.n_gpu_layers = 99; // tiny model — offload fully where available (Metal/CUDA)
+ mparams.use_mmap = true;
+ const model = c.llama_model_load_from_file(model_path.ptr, mparams) orelse return error.ModelLoadFailed;
+ errdefer c.llama_model_free(model);
- const ctx_params = c.llama_context_default_params();
- // Since we only do embeddings, we don't need large context sizes typically, but respect defaults
- const ctx = c.llama_init_from_model(model, ctx_params) orelse {
- c.llama_model_free(model);
- return error.ContextInitFailed;
- };
+ const vocab = c.llama_model_get_vocab(model) orelse return error.VocabLoadFailed;
+ const n_embd = c.llama_model_n_embd(model);
+ if (n_embd != EMBEDDING_DIM) {
+ // Dimension mismatch would silently break vec0 inserts/search.
+ std.debug.print(
+ "fast-mempalace: model embedding dim {d} != expected {d}. Use a {d}-dim model.\n",
+ .{ n_embd, EMBEDDING_DIM, EMBEDDING_DIM },
+ );
+ return error.EmbeddingDimMismatch;
+ }
+
+ var cparams = c.llama_context_default_params();
+ cparams.n_ctx = MAX_TOKENS;
+ cparams.n_batch = MAX_TOKENS;
+ cparams.n_ubatch = MAX_TOKENS;
+ cparams.embeddings = true;
+ cparams.pooling_type = c.LLAMA_POOLING_TYPE_MEAN;
+ const ctx = c.llama_init_from_model(model, cparams) orelse return error.ContextInitFailed;
- return .{ .model = model, .ctx = ctx };
+ return .{
+ .model = model,
+ .ctx = ctx,
+ .vocab = vocab,
+ .n_embd = n_embd,
+ .is_encoder = c.llama_model_has_encoder(model),
+ };
}
pub fn deinit(self: *Embedder) void {
@@ -43,39 +91,81 @@ pub const Embedder = struct {
c.llama_backend_free();
}
- /// Generate a vector embedding for the given text.
- /// Memory must be freed by the caller.
+ /// Generate an L2-normalized embedding for `text`. Caller owns the result.
+ /// Thread-safe: the underlying llama_context is serialized via a mutex.
pub fn embed(self: *Embedder, text: []const u8, allocator: Allocator) ![]f32 {
- // Normally, tokenization happens here
- // var tokens = allocator.alloc(c.llama_token, text.len * 2) catch return error.OutOfMemory;
- // const n_tokens = c.llama_tokenize(self.model, text.ptr, @intCast(text.len), tokens.ptr, @intCast(tokens.len), true, false);
- // ... (llama_decode logic omitted for brevity, returns dummy representation to satisfy SQLite length requirements and prevent GPU crashes without proper prompt batches)
-
- // This validates the Metal pipeline functions natively
- _ = text;
- _ = self;
-
- const vec = try allocator.alloc(f32, EMBEDDING_DIM);
-
- // Fallback to random uniform mapping for testing database constraints natively
- var prng = std.Random.DefaultPrng.init(0);
- const random = prng.random();
-
+ self.acquire();
+ defer self.release();
+
+ // ── Tokenize ──
+ const tokens = try allocator.alloc(c.llama_token, MAX_TOKENS);
+ defer allocator.free(tokens);
+
+ var n = c.llama_tokenize(
+ self.vocab,
+ text.ptr,
+ @intCast(text.len),
+ tokens.ptr,
+ @intCast(MAX_TOKENS),
+ true, // add_special (BOS/CLS as configured by the model)
+ false, // parse_special
+ );
+ // Negative return = buffer too small; we intentionally truncate to MAX_TOKENS.
+ if (n < 0) n = MAX_TOKENS;
+ if (n == 0) return error.EmptyInput;
+ const n_tok: usize = @intCast(n);
+
+ // ── Build a single-sequence batch ──
+ var batch = c.llama_batch_init(@intCast(n_tok), 0, 1);
+ defer c.llama_batch_free(batch);
+ batch.n_tokens = @intCast(n_tok);
+
+ var i: usize = 0;
+ while (i < n_tok) : (i += 1) {
+ batch.token[i] = tokens[i];
+ batch.pos[i] = @intCast(i);
+ batch.n_seq_id[i] = 1;
+ batch.seq_id[i][0] = 0;
+ batch.logits[i] = 1; // request output for every token so pooling has data
+ }
+
+ // ── Run the model ──
+ const rc = if (self.is_encoder)
+ c.llama_encode(self.ctx, batch)
+ else
+ c.llama_decode(self.ctx, batch);
+ if (rc != 0) return error.EncodeFailed;
+
+ // ── Read the pooled (mean) embedding for sequence 0 ──
+ const emb_ptr = c.llama_get_embeddings_seq(self.ctx, 0) orelse return error.NoEmbedding;
+ const dim: usize = @intCast(self.n_embd);
+
+ const out = try allocator.alloc(f32, dim);
+ errdefer allocator.free(out);
+
var sum_sq: f32 = 0;
- for (vec) |*v| {
- v.* = random.float(f32) - 0.5;
- sum_sq += v.* * v.*;
+ i = 0;
+ while (i < dim) : (i += 1) {
+ const v = emb_ptr[i];
+ out[i] = v;
+ sum_sq += v * v;
}
-
- const inv_norm = 1.0 / @sqrt(sum_sq);
- for (vec) |*v| {
- v.* *= inv_norm;
+ if (sum_sq > 0) {
+ const inv_norm = 1.0 / @sqrt(sum_sq);
+ for (out) |*v| v.* *= inv_norm;
}
-
- return vec;
+ return out;
}
};
+fn quietLog(level: c.ggml_log_level, text: [*c]const u8, user_data: ?*anyopaque) callconv(.c) void {
+ _ = level;
+ _ = text;
+ _ = user_data;
+}
+
+// ── Process-global embedder ──
+
var global_emb: ?Embedder = null;
pub fn initGlobal(model_path: [:0]const u8) !void {
@@ -90,6 +180,12 @@ pub fn deinitGlobal() void {
}
}
+/// True once a model is loaded. Lets callers degrade gracefully (e.g. keyword
+/// search) instead of hard-failing when no model is configured.
+pub fn isReady() bool {
+ return global_emb != null;
+}
+
pub fn embed(text: []const u8, allocator: Allocator) ![]f32 {
if (global_emb) |*e| {
return e.embed(text, allocator);
diff --git a/src/hooks.zig b/src/hooks.zig
index 3df6c84..b3b2eaa 100644
--- a/src/hooks.zig
+++ b/src/hooks.zig
@@ -1,139 +1,255 @@
// ═══════════════════════════════════════════════════════════════════
-// fast-mempalace/hooks.zig — Claude Code / Codex Hook Protocol
+// fast-mempalace/hooks.zig — Claude Code hook protocol (real, 2026)
//
-// Reads JSON from stdin, outputs JSON to stdout.
-// Implements the session-start, stop, and precompact hooks
-// that enable AI clients to auto-save context into the palace.
+// Reads the hook event JSON on stdin, performs a side effect, and writes
+// the documented JSON response on stdout. Two events matter for memory:
//
-// Protocol: https://mempalaceofficial.com/guide/hooks.html
+// SessionStart → inject the wake-up brief into the session's context
+// (additionalContext). Fires again after compaction with
+// source="compact", so saved context comes right back.
+// PreCompact → context is about to be summarized away. Read the live
+// transcript and SAVE the recent tail as a memory so it
+// survives — genuine auto-save, not a nag.
+//
+// We accept both the real Claude Code field (`hook_event_name`) and the
+// legacy `hook` field, and match event names case/spelling-liberally.
// ═══════════════════════════════════════════════════════════════════
const std = @import("std");
const db = @import("db.zig");
+const palace = @import("palace.zig");
+const miner = @import("miner.zig");
+const embedder = @import("embedder.zig");
const wakeup = @import("wakeup.zig");
+const config = @import("config.zig");
const Allocator = std.mem.Allocator;
-// Use POSIX read/write via libc — works identically on macOS and Linux and
-// avoids macOS-only symbols like __stdinp/__stdoutp that BSD libc uses.
-const c_posix = @cImport({
+// POSIX read/write via libc — identical on macOS and Linux, and avoids the
+// macOS-only __stdinp/__stdoutp symbols that BSD libc uses.
+const c = @cImport({
@cInclude("unistd.h");
+ @cInclude("stdio.h");
});
const STDIN_FD: c_int = 0;
const STDOUT_FD: c_int = 1;
-const STOP_BLOCK_REASON =
- \\AUTO-SAVE checkpoint (Fast MemPalace). Save this session's key content:
- \\1. fast_mempalace_diary_write — session summary
- \\2. fast_mempalace_add_drawer — verbatim quotes, decisions, code snippets
- \\3. fast_mempalace_kg_add — entity relationships (optional)
- \\Continue conversation after saving.
-;
-
-const PRECOMPACT_BLOCK_REASON =
- \\COMPACTION IMMINENT (Fast MemPalace). Save ALL session content before context is lost:
- \\1. fast_mempalace_diary_write — thorough session summary
- \\2. fast_mempalace_add_drawer — ALL verbatim quotes, decisions, code, context
- \\3. fast_mempalace_kg_add — entity relationships (optional)
- \\Be thorough — after compaction, detailed context will be lost.
-;
-
-/// Process a hook invocation. Reads JSON from stdin, writes JSON to stdout.
-pub fn processHook(database: *db.Database, allocator: Allocator) !void {
- var input_buf: [1024 * 1024]u8 = undefined;
- const n = c_posix.read(STDIN_FD, &input_buf, input_buf.len);
- const bytes_read: usize = if (n > 0) @intCast(n) else 0;
+// Cap how much recent conversation we save before compaction.
+const MAX_SAVE_CHARS: usize = 6000;
+const MAX_TRANSCRIPT_BYTES: usize = 4 * 1024 * 1024;
- if (bytes_read == 0) {
- writeStdout("{\"error\":\"No input received on stdin\"}\n");
+pub fn processHook(database: *db.Database, cfg: *const config.Config, allocator: Allocator) !void {
+ var input_buf: [1024 * 1024]u8 = undefined;
+ const nread = c.read(STDIN_FD, &input_buf, input_buf.len);
+ if (nread <= 0) {
+ writeStdout("{}\n");
return;
}
+ const input = input_buf[0..@intCast(nread)];
- const input = input_buf[0..bytes_read];
-
- const parsed = std.json.parseFromSlice(std.json.Value, allocator, input, .{}) catch {
- writeStdout("{\"error\":\"Invalid JSON on stdin\"}\n");
+ var parsed = std.json.parseFromSlice(std.json.Value, allocator, input, .{}) catch {
+ writeStdout("{}\n");
return;
};
defer parsed.deinit();
-
+ if (parsed.value != .object) {
+ writeStdout("{}\n");
+ return;
+ }
const root = parsed.value.object;
- const hook_name = if (root.get("hook")) |h| (switch (h) {
- .string => |s| s,
- else => "unknown",
- }) else "unknown";
+ const event = eventName(root);
- if (std.mem.eql(u8, hook_name, "session-start")) {
+ if (isEvent(event, "SessionStart", "session-start")) {
try handleSessionStart(database, allocator);
- } else if (std.mem.eql(u8, hook_name, "stop")) {
- handleStop();
- } else if (std.mem.eql(u8, hook_name, "precompact")) {
- handlePrecompact();
+ } else if (isEvent(event, "PreCompact", "precompact")) {
+ handlePreCompact(database, cfg, root, allocator);
+ writeStdout("{}\n"); // don't block compaction
+ } else if (isEvent(event, "Stop", "stop")) {
+ // Saving on every Stop is noisy; we rely on PreCompact + the agent
+ // calling memory_store. Continue silently.
+ writeStdout("{}\n");
} else {
- writeStdout("{\"error\":\"Unknown hook type\"}\n");
+ writeStdout("{}\n");
}
}
fn handleSessionStart(database: *db.Database, allocator: Allocator) !void {
- const context = try wakeup.generate(database, null, allocator);
+ const context = wakeup.generate(database, null, allocator) catch {
+ writeStdout("{}\n");
+ return;
+ };
defer allocator.free(context);
const escaped = try jsonEscape(context, allocator);
defer allocator.free(escaped);
- const response = try std.fmt.allocPrint(allocator, "{{\"result\":\"continue\",\"context\":\"{s}\"}}\n", .{escaped});
+ // Inject via the documented SessionStart channel.
+ const response = try std.fmt.allocPrint(
+ allocator,
+ "{{\"hookSpecificOutput\":{{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"{s}\"}}}}\n",
+ .{escaped},
+ );
defer allocator.free(response);
-
writeStdout(response);
}
-fn handleStop() void {
- var buf: [4096]u8 = undefined;
- const msg = std.fmt.bufPrint(&buf, "{{\"result\":\"block\",\"reason\":\"{s}\"}}\n", .{STOP_BLOCK_REASON}) catch return;
- writeStdout(msg);
+fn handlePreCompact(database: *db.Database, cfg: *const config.Config, root: std.json.ObjectMap, allocator: Allocator) void {
+ const tpath = strField(root, "transcript_path") orelse return;
+
+ const tail = extractTranscriptTail(tpath, allocator) catch return orelse return;
+ defer allocator.free(tail);
+ if (tail.len < 40) return; // nothing worth saving
+
+ // Embeddings make the saved tail semantically searchable later. Load the
+ // model lazily here (PreCompact is rare) so SessionStart stays instant.
+ embedder.initGlobal(cfg.model_path) catch {};
+
+ var pal = palace.Palace.init(database, allocator);
+ _ = miner.storeMemory(&pal, tail, cfg.default_wing, "sessions", "precompact-autosave", allocator) catch return;
}
-fn handlePrecompact() void {
- var buf: [4096]u8 = undefined;
- const msg = std.fmt.bufPrint(&buf, "{{\"result\":\"block\",\"reason\":\"{s}\"}}\n", .{PRECOMPACT_BLOCK_REASON}) catch return;
- writeStdout(msg);
+// ── Transcript extraction ──
+
+/// Read the JSONL transcript and return the most recent ~MAX_SAVE_CHARS of
+/// human-readable conversation text (user prompts + assistant text blocks),
+/// oldest-to-newest. Caller owns the result.
+fn extractTranscriptTail(path: []const u8, allocator: Allocator) !?[]u8 {
+ const path_z = try allocator.dupeZ(u8, path);
+ defer allocator.free(path_z);
+
+ const file = c.fopen(path_z.ptr, "rb") orelse return null;
+ defer _ = c.fclose(file);
+
+ // Read up to a cap; transcripts grow but the tail is what matters.
+ const buf = try allocator.alloc(u8, MAX_TRANSCRIPT_BYTES);
+ defer allocator.free(buf);
+ const got = c.fread(buf.ptr, 1, buf.len, file);
+ if (got == 0) return null;
+ const data = buf[0..got];
+
+ var pieces: std.ArrayListUnmanaged(u8) = .empty;
+ defer pieces.deinit(allocator);
+
+ var lines = std.mem.splitScalar(u8, data, '\n');
+ while (lines.next()) |line| {
+ const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
+ if (trimmed.len == 0) continue;
+
+ var lp = std.json.parseFromSlice(std.json.Value, allocator, trimmed, .{}) catch continue;
+ defer lp.deinit();
+ if (lp.value != .object) continue;
+ const obj = lp.value.object;
+
+ const role = roleOf(obj) orelse continue;
+ const msg = obj.get("message") orelse continue;
+ if (msg != .object) continue;
+ const content = msg.object.get("content") orelse continue;
+
+ var text_buf: std.ArrayListUnmanaged(u8) = .empty;
+ defer text_buf.deinit(allocator);
+ collectText(content, &text_buf, allocator);
+ if (text_buf.items.len == 0) continue;
+
+ const header = if (std.mem.eql(u8, role, "user")) "User: " else "Assistant: ";
+ pieces.appendSlice(allocator, header) catch {};
+ pieces.appendSlice(allocator, text_buf.items) catch {};
+ pieces.appendSlice(allocator, "\n\n") catch {};
+ }
+
+ if (pieces.items.len == 0) return null;
+
+ // Keep only the tail.
+ const start = if (pieces.items.len > MAX_SAVE_CHARS) pieces.items.len - MAX_SAVE_CHARS else 0;
+ return try allocator.dupe(u8, pieces.items[start..]);
}
+fn roleOf(obj: std.json.ObjectMap) ?[]const u8 {
+ // Prefer top-level "type" (user/assistant); fall back to message.role.
+ if (obj.get("type")) |t| {
+ if (t == .string and (std.mem.eql(u8, t.string, "user") or std.mem.eql(u8, t.string, "assistant")))
+ return t.string;
+ }
+ if (obj.get("message")) |m| {
+ if (m == .object) {
+ if (m.object.get("role")) |r| {
+ if (r == .string) return r.string;
+ }
+ }
+ }
+ return null;
+}
+
+/// Append plain text from a message `content`, which may be a bare string or
+/// an array of blocks. Only text-bearing blocks are collected (tool calls and
+/// tool results are skipped to keep saved memories signal-dense).
+fn collectText(content: std.json.Value, out: *std.ArrayListUnmanaged(u8), allocator: Allocator) void {
+ switch (content) {
+ .string => |s| out.appendSlice(allocator, s) catch {},
+ .array => |arr| {
+ for (arr.items) |block| {
+ if (block != .object) continue;
+ const btype = block.object.get("type") orelse continue;
+ if (btype != .string or !std.mem.eql(u8, btype.string, "text")) continue;
+ if (block.object.get("text")) |txt| {
+ if (txt == .string) {
+ out.appendSlice(allocator, txt.string) catch {};
+ out.appendSlice(allocator, " ") catch {};
+ }
+ }
+ }
+ },
+ else => {},
+ }
+}
+
+// ── Event helpers ──
+
+fn eventName(root: std.json.ObjectMap) []const u8 {
+ if (strField(root, "hook_event_name")) |n| return n;
+ if (strField(root, "hook")) |n| return n;
+ return "unknown";
+}
+
+fn isEvent(event: []const u8, canonical: []const u8, legacy: []const u8) bool {
+ return std.ascii.eqlIgnoreCase(event, canonical) or std.ascii.eqlIgnoreCase(event, legacy);
+}
+
+fn strField(root: std.json.ObjectMap, key: []const u8) ?[]const u8 {
+ const v = root.get(key) orelse return null;
+ return switch (v) {
+ .string => |s| s,
+ else => null,
+ };
+}
+
+// ── Output ──
+
fn writeStdout(msg: []const u8) void {
- _ = c_posix.write(STDOUT_FD, msg.ptr, msg.len);
+ _ = c.write(STDOUT_FD, msg.ptr, msg.len);
}
fn jsonEscape(text: []const u8, allocator: Allocator) ![]u8 {
var result: std.ArrayListUnmanaged(u8) = .empty;
errdefer result.deinit(allocator);
-
for (text) |byte| {
switch (byte) {
- '"' => {
- try result.append(allocator, '\\');
- try result.append(allocator, '"');
+ '"' => try result.appendSlice(allocator, "\\\""),
+ '\\' => try result.appendSlice(allocator, "\\\\"),
+ '\n' => try result.appendSlice(allocator, "\\n"),
+ '\r' => try result.appendSlice(allocator, "\\r"),
+ '\t' => try result.appendSlice(allocator, "\\t"),
+ else => {
+ if (byte < 0x20) {
+ var b: [6]u8 = undefined;
+ const s = std.fmt.bufPrint(&b, "\\u{x:0>4}", .{byte}) catch continue;
+ try result.appendSlice(allocator, s);
+ } else {
+ try result.append(allocator, byte);
+ }
},
- '\\' => {
- try result.append(allocator, '\\');
- try result.append(allocator, '\\');
- },
- '\n' => {
- try result.append(allocator, '\\');
- try result.append(allocator, 'n');
- },
- '\r' => {
- try result.append(allocator, '\\');
- try result.append(allocator, 'r');
- },
- '\t' => {
- try result.append(allocator, '\\');
- try result.append(allocator, 't');
- },
- else => try result.append(allocator, byte),
}
}
-
return result.toOwnedSlice(allocator);
}
diff --git a/src/main.zig b/src/main.zig
index 3bce67a..d42b0d9 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -19,6 +19,7 @@ pub fn main(init: std.process.Init) !void {
const allocator = init.gpa;
var cfg = config.load(allocator, init.io) catch config.Config{};
defer cfg.deinit(allocator);
+ config.applyEnvOverrides(&cfg, allocator);
var args_it = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator);
defer args_it.deinit();
@@ -60,10 +61,26 @@ pub fn main(init: std.process.Init) !void {
try cmdWakeUp(wing, &cfg, allocator);
} else if (std.mem.eql(u8, command, "hook")) {
try cmdHook(&cfg, allocator);
+ } else if (std.mem.eql(u8, command, "embdbg")) {
+ try embedder.initGlobal(cfg.model_path);
+ defer embedder.deinitGlobal();
+ const a = args_it.next() orelse "the cat sat on the mat";
+ const b = args_it.next() orelse "a feline rested upon the rug";
+ const va = try embedder.embed(a, allocator);
+ defer allocator.free(va);
+ const vb = try embedder.embed(b, allocator);
+ defer allocator.free(vb);
+ var dot: f32 = 0;
+ for (va, vb) |x, y| dot += x * y;
+ std.debug.print("dim={d} |a|first3=[{d:.4} {d:.4} {d:.4}] |b|first3=[{d:.4} {d:.4} {d:.4}] cos={d:.4}\n", .{
+ va.len, va[0], va[1], va[2], vb[0], vb[1], vb[2], dot,
+ });
} else if (std.mem.eql(u8, command, "instructions")) {
cmdInstructions();
} else if (std.mem.eql(u8, command, "mcp")) {
- try embedder.initGlobal(cfg.model_path);
+ // The model loads lazily on the first semantic search (see mcp.zig), so
+ // `initialize` answers instantly and the client never times us out
+ // waiting on cold Metal shader compilation. We still free it on exit.
defer embedder.deinitGlobal();
try mcp.serve(allocator, &cfg, init.io);
@@ -115,13 +132,21 @@ fn cmdMine(io: std.Io, path: [:0]const u8, wing_name: []const u8, cfg: *const co
var stats: miner.MineStats = undefined;
- // We can't easily stat via std.Io yet, so try opening as a file
- const file = std.Io.Dir.cwd().openFile(io, path, .{}) catch null;
- if (file != null) {
- file.?.close(io);
- stats = try miner.mineConversation(&pal, path, wing_name, io, allocator);
- } else {
+ // Discriminate directory vs file by trying to open it AS a directory.
+ // (openFile succeeds on directories in this IO model, so it can't be the
+ // discriminator — that was the v0.1 "mine finds 0 files" bug.)
+ if (std.Io.Dir.cwd().openDir(io, path, .{})) |dir_handle| {
+ var d = dir_handle;
+ d.close(io);
stats = try miner.mineDirectory(&pal, path, .{ .wing_name = wing_name }, io, allocator);
+ } else |_| {
+ // It's a single file. JSON/JSONL → conversation export; otherwise document.
+ const ext = std.fs.path.extension(path);
+ if (std.ascii.eqlIgnoreCase(ext, ".json") or std.ascii.eqlIgnoreCase(ext, ".jsonl")) {
+ stats = try miner.mineConversation(&pal, path, wing_name, io, allocator);
+ } else {
+ stats = try miner.mineFile(&pal, path, wing_name, io, allocator);
+ }
}
std.debug.print(
@@ -227,30 +252,36 @@ fn cmdHook(cfg: *const config.Config, allocator: std.mem.Allocator) !void {
defer database.close();
database.createPalaceSchema();
- try hooks.processHook(&database, allocator);
+ // PreCompact auto-save lazily loads the embedder; free it before exit so
+ // ggml-metal's static destructor doesn't abort on un-released resources.
+ defer embedder.deinitGlobal();
+
+ try hooks.processHook(&database, cfg, allocator);
}
fn cmdInstructions() void {
const instructions =
- \\# Fast MemPalace — Skill Instructions
+ \\# Fast MemPalace — Memory Instructions
+ \\
+ \\You have a persistent, local-first memory palace. It survives across
+ \\sessions and never leaves this machine. Content is organized into Wings
+ \\(projects/domains) → Rooms (topics) → Drawers (verbatim chunks).
\\
- \\You have access to a local-first AI memory system called Fast MemPalace.
- \\It stores verbatim content organized into Wings (projects/people),
- \\Rooms (topics), and Drawers (content chunks).
+ \\## MCP tools (preferred, inside an agent)
+ \\- `memory_search` — semantic recall; call BEFORE answering about prior work.
+ \\- `memory_store` — persist a decision, constraint, or snippet (verbatim).
+ \\- `memory_wake_up`— load the compact continuity brief.
+ \\- `memory_stats` — palace statistics.
\\
- \\## Available Tools
- \\- `fast-mempalace search ""` — Semantic search across all memories
- \\- `fast-mempalace wake-up` — Load L0+L1 context (~600-900 tokens)
- \\- `fast-mempalace wake-up --wing ` — Scoped wake-up for a project
- \\- `fast-mempalace mine ` — Ingest files into the palace
- \\- `fast-mempalace kg ` — Query the knowledge graph
- \\- `fast-mempalace stats` — Show palace statistics
+ \\## CLI (scripting / seeding)
+ \\- `fast-mempalace mine [wing]` — ingest a file or directory.
+ \\- `fast-mempalace search ""` — semantic search.
+ \\- `fast-mempalace wake-up [--wing X]` — print the wake-up context.
\\
- \\## Best Practices
- \\1. Always run `fast-mempalace wake-up` at session start
- \\2. Use `fast-mempalace search` before answering questions about past work
- \\3. Store important decisions with `fast-mempalace mine`
- \\4. Keep the knowledge graph updated for entity relationships
+ \\## Best practices
+ \\1. Recall before you answer anything about past decisions or conventions.
+ \\2. After a real decision or a correction, store it as one crisp memory.
+ \\3. Keep memories specific and self-contained.
\\
\\Nothing leaves the local machine. No API keys required.
\\
diff --git a/src/mcp.zig b/src/mcp.zig
index 9a6af59..2058a2b 100644
--- a/src/mcp.zig
+++ b/src/mcp.zig
@@ -1,135 +1,310 @@
+// ═══════════════════════════════════════════════════════════════════
+// fast-mempalace/mcp.zig — Model Context Protocol server (stdio JSON-RPC)
+//
+// Exposes the memory palace to any MCP-capable agent (Claude Code, Cursor,
+// Zed, …) over newline-delimited JSON-RPC on stdin/stdout. Four real tools:
+//
+// memory_search — semantic recall across everything mined/stored
+// memory_store — persist a decision / snippet / fact as a memory
+// memory_wake_up — load the compact session-start context
+// memory_stats — palace statistics
+//
+// All work happens locally; nothing leaves the machine.
+// ═══════════════════════════════════════════════════════════════════
+
const std = @import("std");
const config = @import("config.zig");
+const db = @import("db.zig");
const palace = @import("palace.zig");
+const miner = @import("miner.zig");
+const searcher = @import("searcher.zig");
+const embedder = @import("embedder.zig");
+const wakeup = @import("wakeup.zig");
+
+const Allocator = std.mem.Allocator;
const RpcRequest = struct {
- jsonrpc: []const u8,
+ jsonrpc: []const u8 = "2.0",
id: ?std.json.Value = null,
method: []const u8,
params: ?std.json.Value = null,
};
-const RpcResponse = struct {
- jsonrpc: []const u8 = "2.0",
- id: std.json.Value,
- result: ?std.json.Value = null,
- error_payload: ?std.json.Value = null,
-};
+const TOOLS_LIST =
+ \\{
+ \\ "tools": [
+ \\ {
+ \\ "name": "memory_search",
+ \\ "description": "Semantic search across the local memory palace (mined code, decisions, notes, past sessions). Use this BEFORE answering questions about prior work, past decisions, or anything that might already be remembered. Returns the most relevant stored memories with their source.",
+ \\ "inputSchema": {
+ \\ "type": "object",
+ \\ "properties": {
+ \\ "query": { "type": "string", "description": "What to recall, in natural language." },
+ \\ "limit": { "type": "integer", "description": "Max results (default 5).", "default": 5 }
+ \\ },
+ \\ "required": ["query"]
+ \\ }
+ \\ },
+ \\ {
+ \\ "name": "memory_store",
+ \\ "description": "Persist an important memory for future sessions: a decision and its rationale, a key code snippet, a constraint, a fact about the project or user. Store anything you'd want to remember next time. Content is kept verbatim.",
+ \\ "inputSchema": {
+ \\ "type": "object",
+ \\ "properties": {
+ \\ "content": { "type": "string", "description": "The exact text to remember." },
+ \\ "wing": { "type": "string", "description": "Project/domain bucket (default: current project)." },
+ \\ "room": { "type": "string", "description": "Topic within the wing (default: notes)." }
+ \\ },
+ \\ "required": ["content"]
+ \\ }
+ \\ },
+ \\ {
+ \\ "name": "memory_wake_up",
+ \\ "description": "Load the compact wake-up context (identity + most relevant recent memories, ~600-900 tokens). Call at the start of a session to recover continuity.",
+ \\ "inputSchema": {
+ \\ "type": "object",
+ \\ "properties": {
+ \\ "wing": { "type": "string", "description": "Limit to one project/domain (optional)." }
+ \\ }
+ \\ }
+ \\ },
+ \\ {
+ \\ "name": "memory_stats",
+ \\ "description": "Report palace statistics: how many wings, rooms, drawers, and entities are stored.",
+ \\ "inputSchema": { "type": "object", "properties": {} }
+ \\ }
+ \\ ]
+ \\}
+;
+
+pub fn serve(allocator: Allocator, cfg: *const config.Config, io: std.Io) !void {
+ var database = db.Database.open(cfg.database_path.ptr) catch {
+ writeLine(io, "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32603,\"message\":\"failed to open palace database\"}}");
+ return;
+ };
+ defer database.close();
+ database.createPalaceSchema();
+
+ var pal = palace.Palace.init(&database, allocator);
-pub fn serve(allocator: std.mem.Allocator, cfg: *const config.Config, io: std.Io) !void {
- _ = cfg;
const stdin = std.Io.File.stdin();
- const stdout = std.Io.File.stdout();
-
var line_buf = std.ArrayListUnmanaged(u8).empty;
defer line_buf.deinit(allocator);
-
- var chunk: [4096]u8 = undefined;
-
+ var chunk: [8192]u8 = undefined;
+
while (true) {
- const bytes_read = stdin.readStreaming(io, &.{chunk[0..]}) catch |err| {
+ const n = stdin.readStreaming(io, &.{chunk[0..]}) catch |err| {
if (err == error.EndOfStream) break;
return err;
};
-
- try line_buf.appendSlice(allocator, chunk[0..bytes_read]);
-
- // Check for newline
- while (std.mem.indexOfScalar(u8, line_buf.items, '\n')) |nl_index| {
- const line = line_buf.items[0..nl_index];
-
- if (line.len > 0) {
- var parsed = std.json.parseFromSlice(RpcRequest, allocator, line, .{
- .ignore_unknown_fields = true,
- .allocate = .alloc_always,
- }) catch {
- // Remove processed bytes even if parse failed
- line_buf.replaceRangeAssumeCapacity(0, nl_index + 1, &.{});
- continue;
- };
-
- const req = parsed.value;
- const id = req.id orelse std.json.Value{ .null = {} };
-
- if (std.mem.eql(u8, req.method, "initialize")) {
- const result_str =
- \\{
- \\ "protocolVersion": "2024-11-05",
- \\ "capabilities": {
- \\ "tools": {}
- \\ },
- \\ "serverInfo": {
- \\ "name": "fast-mempalace",
- \\ "version": "1.0.0"
- \\ }
- \\}
- ;
- var result_val = try std.json.parseFromSlice(std.json.Value, allocator, result_str, .{});
- defer result_val.deinit();
- try sendResponse(allocator, stdout, io, id, result_val.value);
- } else if (std.mem.eql(u8, req.method, "tools/list")) {
- const tools_str =
- \\{
- \\ "tools": [
- \\ {
- \\ "name": "fast_mempalace_search",
- \\ "description": "Searches the AI memory palace for relevant context.",
- \\ "inputSchema": {
- \\ "type": "object",
- \\ "properties": {
- \\ "query": {"type": "string"}
- \\ },
- \\ "required": ["query"]
- \\ }
- \\ }
- \\ ]
- \\}
- ;
- var result_val = try std.json.parseFromSlice(std.json.Value, allocator, tools_str, .{});
- defer result_val.deinit();
- try sendResponse(allocator, stdout, io, id, result_val.value);
- } else if (std.mem.eql(u8, req.method, "tools/call")) {
- const result_str =
- \\{
- \\ "content": [
- \\ {
- \\ "type": "text",
- \\ "text": "Call executed successfully natively in Zig!"
- \\ }
- \\ ]
- \\}
- ;
- var result_val = try std.json.parseFromSlice(std.json.Value, allocator, result_str, .{});
- defer result_val.deinit();
- try sendResponse(allocator, stdout, io, id, result_val.value);
+ if (n == 0) break;
+ try line_buf.appendSlice(allocator, chunk[0..n]);
+
+ while (std.mem.indexOfScalar(u8, line_buf.items, '\n')) |nl| {
+ const line = line_buf.items[0..nl];
+ if (line.len > 0) handleLine(allocator, &pal, &database, cfg, io, line) catch {};
+ // advance past the newline
+ const remaining = line_buf.items.len - (nl + 1);
+ std.mem.copyForwards(u8, line_buf.items[0..remaining], line_buf.items[nl + 1 ..]);
+ line_buf.shrinkRetainingCapacity(remaining);
+ }
+ }
+}
+
+fn handleLine(
+ allocator: Allocator,
+ pal: *palace.Palace,
+ database: *db.Database,
+ cfg: *const config.Config,
+ io: std.Io,
+ line: []const u8,
+) !void {
+ var parsed = std.json.parseFromSlice(RpcRequest, allocator, line, .{
+ .ignore_unknown_fields = true,
+ .allocate = .alloc_always,
+ }) catch return;
+ defer parsed.deinit();
+
+ const req = parsed.value;
+ const id = req.id orelse std.json.Value{ .null = {} };
+ const method = req.method;
+
+ if (std.mem.eql(u8, method, "initialize")) {
+ // Echo the client's requested protocol version for maximum compatibility.
+ var version: []const u8 = "2024-11-05";
+ if (req.params) |pp| {
+ if (pp == .object) {
+ if (pp.object.get("protocolVersion")) |pv| {
+ if (pv == .string) version = pv.string;
}
-
- parsed.deinit();
}
-
- // Advance buffer past the newline
- const remaining = line_buf.items.len - (nl_index + 1);
- if (remaining > 0) {
- std.mem.copyForwards(u8, line_buf.items[0..remaining], line_buf.items[nl_index + 1 ..]);
- }
- line_buf.shrinkRetainingCapacity(remaining);
}
+ const info = try std.fmt.allocPrint(allocator,
+ \\{{"protocolVersion":"{s}","capabilities":{{"tools":{{}}}},"serverInfo":{{"name":"fast-mempalace","version":"0.2.0"}}}}
+ , .{version});
+ defer allocator.free(info);
+ try sendRawResult(allocator, io, id, info);
+ } else if (std.mem.eql(u8, method, "notifications/initialized")) {
+ // notification — no response
+ } else if (std.mem.eql(u8, method, "ping")) {
+ try sendRawResult(allocator, io, id, "{}");
+ } else if (std.mem.eql(u8, method, "tools/list")) {
+ try sendRawResult(allocator, io, id, TOOLS_LIST);
+ } else if (std.mem.eql(u8, method, "tools/call")) {
+ const text = dispatchTool(allocator, pal, database, cfg, io, req.params) catch |err|
+ try std.fmt.allocPrint(allocator, "Error: {s}", .{@errorName(err)});
+ defer allocator.free(text);
+ try sendToolText(allocator, io, id, text);
+ } else {
+ try sendError(allocator, io, id, -32601, "method not found");
+ }
+}
+
+fn dispatchTool(
+ allocator: Allocator,
+ pal: *palace.Palace,
+ database: *db.Database,
+ cfg: *const config.Config,
+ io: std.Io,
+ params: ?std.json.Value,
+) ![]u8 {
+ const p = params orelse return error.MissingParams;
+ if (p != .object) return error.MissingParams;
+ const name = (p.object.get("name") orelse return error.MissingToolName).string;
+ const args: ?std.json.Value = p.object.get("arguments");
+
+ // Semantic tools need the embedding model; load it lazily on first use so
+ // server startup (initialize/tools/list) stays instant.
+ if (std.mem.eql(u8, name, "memory_search") or std.mem.eql(u8, name, "memory_store")) {
+ if (!embedder.isReady()) embedder.initGlobal(cfg.model_path) catch {};
+ }
+
+ if (std.mem.eql(u8, name, "memory_search")) {
+ const query = getStr(args, "query") orelse return error.MissingQuery;
+ const limit: i32 = @intCast(getInt(args, "limit") orelse 5);
+ return toolSearch(allocator, pal, io, query, limit);
+ } else if (std.mem.eql(u8, name, "memory_store")) {
+ const content = getStr(args, "content") orelse return error.MissingContent;
+ const wing = getStr(args, "wing") orelse cfg.default_wing;
+ const room = getStr(args, "room") orelse "notes";
+ const id = try miner.storeMemory(pal, content, wing, room, "agent", allocator);
+ return std.fmt.allocPrint(allocator, "Stored memory #{d} in {s}/{s}.", .{ id, wing, room });
+ } else if (std.mem.eql(u8, name, "memory_wake_up")) {
+ const wing = getStr(args, "wing");
+ return wakeup.generate(database, wing, allocator);
+ } else if (std.mem.eql(u8, name, "memory_stats")) {
+ return pal.stats(allocator);
}
+ return std.fmt.allocPrint(allocator, "Unknown tool: {s}", .{name});
}
-fn sendResponse(allocator: std.mem.Allocator, writer: std.Io.File, io: std.Io, id: std.json.Value, result: std.json.Value) !void {
- const json_str = try std.json.Stringify.valueAlloc(allocator, .{
+fn toolSearch(allocator: Allocator, pal: *palace.Palace, io: std.Io, query: []const u8, limit: i32) ![]u8 {
+ if (!embedder.isReady()) return allocator.dupe(u8, "Memory model not loaded; semantic search unavailable.");
+
+ const q_vec = try embedder.embed(query, allocator);
+ defer allocator.free(q_vec);
+
+ const results = try searcher.searchHybrid(pal, query, q_vec, .{ .limit = limit }, allocator, io);
+ defer {
+ for (results) |r| {
+ allocator.free(r.content);
+ allocator.free(r.source_path);
+ allocator.free(r.wing_name);
+ allocator.free(r.room_name);
+ }
+ allocator.free(results);
+ }
+
+ if (results.len == 0) return allocator.dupe(u8, "No relevant memories found.");
+
+ var out: std.ArrayListUnmanaged(u8) = .empty;
+ errdefer out.deinit(allocator);
+
+ const header = try std.fmt.allocPrint(allocator, "Found {d} relevant memor{s}:\n\n", .{
+ results.len, if (results.len == 1) "y" else "ies",
+ });
+ defer allocator.free(header);
+ try out.appendSlice(allocator, header);
+
+ for (results, 0..) |r, i| {
+ const block = try std.fmt.allocPrint(allocator, "{d}. [{s}/{s}] {s}\n{s}\n\n", .{
+ i + 1, r.wing_name, r.room_name, r.source_path, r.content,
+ });
+ defer allocator.free(block);
+ try out.appendSlice(allocator, block);
+ }
+ return out.toOwnedSlice(allocator);
+}
+
+// ── JSON-RPC output helpers ──
+
+/// Send {"jsonrpc","id","result":} where is already-valid JSON.
+fn sendRawResult(allocator: Allocator, io: std.Io, id: std.json.Value, raw_result: []const u8) !void {
+ var parsed = std.json.parseFromSlice(std.json.Value, allocator, raw_result, .{}) catch {
+ return sendError(allocator, io, id, -32603, "internal serialization error");
+ };
+ defer parsed.deinit();
+ const out = try std.json.Stringify.valueAlloc(allocator, .{
+ .jsonrpc = "2.0",
+ .id = id,
+ .result = parsed.value,
+ }, .{});
+ defer allocator.free(out);
+ writeLine(io, out);
+}
+
+/// Send a tools/call result wrapping plain text in the MCP content envelope.
+fn sendToolText(allocator: Allocator, io: std.Io, id: std.json.Value, text: []const u8) !void {
+ const out = try std.json.Stringify.valueAlloc(allocator, .{
.jsonrpc = "2.0",
.id = id,
- .result = result
+ .result = .{
+ .content = .{
+ .{ .@"type" = "text", .text = text },
+ },
+ .isError = false,
+ },
}, .{});
- defer allocator.free(json_str);
-
- var out = try allocator.alloc(u8, json_str.len + 1);
defer allocator.free(out);
-
- @memcpy(out[0..json_str.len], json_str);
- out[json_str.len] = '\n';
-
- _ = try writer.writeStreamingAll(io, out);
+ writeLine(io, out);
+}
+
+fn sendError(allocator: Allocator, io: std.Io, id: std.json.Value, code: i32, message: []const u8) !void {
+ const out = try std.json.Stringify.valueAlloc(allocator, .{
+ .jsonrpc = "2.0",
+ .id = id,
+ .@"error" = .{ .code = code, .message = message },
+ }, .{});
+ defer allocator.free(out);
+ writeLine(io, out);
+}
+
+fn writeLine(io: std.Io, json: []const u8) void {
+ const stdout = std.Io.File.stdout();
+ _ = stdout.writeStreamingAll(io, json) catch {};
+ _ = stdout.writeStreamingAll(io, "\n") catch {};
+}
+
+// ── std.json.Value argument accessors ──
+
+fn getStr(args: ?std.json.Value, key: []const u8) ?[]const u8 {
+ const a = args orelse return null;
+ if (a != .object) return null;
+ const v = a.object.get(key) orelse return null;
+ return switch (v) {
+ .string => |s| if (s.len > 0) s else null,
+ else => null,
+ };
+}
+
+fn getInt(args: ?std.json.Value, key: []const u8) ?i64 {
+ const a = args orelse return null;
+ if (a != .object) return null;
+ const v = a.object.get(key) orelse return null;
+ return switch (v) {
+ .integer => |n| n,
+ .float => |f| @intFromFloat(f),
+ .string => |s| std.fmt.parseInt(i64, s, 10) catch null,
+ else => null,
+ };
}
diff --git a/src/miner.zig b/src/miner.zig
index 3054e98..c7e259a 100644
--- a/src/miner.zig
+++ b/src/miner.zig
@@ -139,6 +139,62 @@ fn processFileConcurrently(
_ = @atomicRmw(u32, &stats.files_processed, .Add, 1, .monotonic);
}
+/// Mine a single document file (source code, markdown, notes — anything that
+/// isn't a chat export). Chunks, embeds, and inserts it under a room named for
+/// the file. This is the path a lone `mine ` takes.
+pub fn mineFile(
+ palace: *Palace,
+ file_path: []const u8,
+ wing_name: []const u8,
+ io: std.Io,
+ allocator: Allocator,
+) !MineStats {
+ var stats = MineStats{};
+
+ const wing_id = try palace.createWing(wing_name, "", "auto-mined");
+ const room_name = std.fs.path.basename(file_path);
+ const room_id = try palace.createRoom(wing_id, room_name, "auto-mined");
+
+ const dir = std.Io.Dir.cwd();
+ const content = readFileContent(dir, io, file_path, allocator) orelse return stats;
+ defer allocator.free(content);
+ if (content.len == 0) return stats;
+
+ stats.bytes_processed = content.len;
+
+ const chunks = try chunkContent(content, 2048, 256, allocator);
+ defer {
+ for (chunks) |chunk| allocator.free(chunk);
+ allocator.free(chunks);
+ }
+
+ for (chunks, 0..) |chunk, i| {
+ const embedding = embed.embed(chunk, allocator) catch continue;
+ defer allocator.free(embedding);
+ _ = palace.insertDrawer(room_id, chunk, file_path, "file", @intCast(i), embedding) catch continue;
+ stats.drawers_created += 1;
+ }
+ stats.files_processed = 1;
+ return stats;
+}
+
+/// Store a single in-memory snippet (a decision, quote, or code block) as one
+/// drawer. Used by the MCP `store` tool and the auto-save hooks — no file I/O.
+pub fn storeMemory(
+ palace: *Palace,
+ content: []const u8,
+ wing_name: []const u8,
+ room_name: []const u8,
+ source: []const u8,
+ allocator: Allocator,
+) !i64 {
+ const wing_id = try palace.createWing(wing_name, "", "memory");
+ const room_id = try palace.createRoom(wing_id, room_name, "");
+ const embedding = embed.embed(content, allocator) catch &[_]f32{};
+ defer if (embedding.len > 0) allocator.free(embedding);
+ return palace.insertDrawer(room_id, content, source, "memory", 0, embedding);
+}
+
/// Mine a conversation file (JSON/JSONL chat export)
pub fn mineConversation(
palace: *Palace,
@@ -277,6 +333,13 @@ fn shouldSkipFile(basename: []const u8) bool {
if (std.ascii.eqlIgnoreCase(ext, skip)) return true;
}
+ // Skip our own database + its SQLite sidecars (WAL/SHM) so the palace never
+ // mines itself.
+ if (std.mem.indexOf(u8, basename, ".db-wal") != null) return true;
+ if (std.mem.indexOf(u8, basename, ".db-shm") != null) return true;
+ if (std.mem.indexOf(u8, basename, "fast-mempalace.db") != null) return true;
+ if (std.mem.indexOf(u8, basename, "mempalace.db") != null) return true;
+
// Skip known directories in filenames
if (std.mem.indexOf(u8, basename, "node_modules") != null) return true;
if (std.mem.indexOf(u8, basename, "__pycache__") != null) return true;
diff --git a/src/palace.zig b/src/palace.zig
index f7b82b4..cc46452 100644
--- a/src/palace.zig
+++ b/src/palace.zig
@@ -15,6 +15,22 @@ const db = @import("db.zig");
const Allocator = std.mem.Allocator;
+// Serializes write sequences that depend on lastInsertRowId(). The concurrent
+// miner inserts a drawer row then reads the new rowid to attach its embedding;
+// without this lock two threads interleave and bind embeddings to the WRONG
+// drawer id, silently corrupting semantic search. The lock makes the
+// content-insert → rowid → vector-insert sequence atomic.
+var write_lock: std.atomic.Value(bool) = .init(false);
+
+fn lockWrites() void {
+ while (write_lock.cmpxchgWeak(false, true, .acquire, .monotonic) != null) {
+ std.atomic.spinLoopHint();
+ }
+}
+fn unlockWrites() void {
+ write_lock.store(false, .release);
+}
+
// ── Data Types ──
pub const Wing = struct {
@@ -192,6 +208,12 @@ pub const Palace = struct {
var hash_hex: [64]u8 = undefined;
const hex = std.fmt.bufPrint(&hash_hex, "{s}", .{std.fmt.bytesToHex(hash_buf, .lower)}) catch return error.HashFailed;
+ // Hold the write lock across dedup-check → insert → rowid → vec-insert so
+ // the rowid we read is unambiguously the row we just inserted, even when
+ // the miner calls this concurrently from multiple tasks.
+ lockWrites();
+ defer unlockWrites();
+
// Check dedup
{
const check = self.database.prepare("SELECT id FROM drawers WHERE content_hash = ?") orelse return error.PrepareFailed;
@@ -325,6 +347,11 @@ pub const Palace = struct {
\\║ Drawers: {d:>8} ║
\\║ Entities: {d:>8} ║
\\╚═══════════════════════════════════╝
- , .{ wing_n, room_n, drawer_n, entity_n });
+ , .{
+ @as(u64, @intCast(@max(0, wing_n))),
+ @as(u64, @intCast(@max(0, room_n))),
+ @as(u64, @intCast(@max(0, drawer_n))),
+ @as(u64, @intCast(@max(0, entity_n))),
+ });
}
};
diff --git a/src/searcher.zig b/src/searcher.zig
index 612ee9a..8854154 100644
--- a/src/searcher.zig
+++ b/src/searcher.zig
@@ -58,27 +58,30 @@ pub fn searchHybrid(
}
}
- // 2. Base Re-rank
+ // 2. Base Re-rank.
+ //
+ // Convention: LOWER score = better match (we sort ascending below).
+ // The vector distance is the dominant signal; recency and exact-keyword
+ // matches apply small *discounts* that pull a result up the list. Crucially
+ // we do NOT clamp to 0 here — clamping would collapse distinct distances
+ // into ties and destroy the ranking the vector index just computed.
for (results) |*res| {
- var score = @max(0.0, 1.0 - res.distance);
+ var score = res.distance;
const age_secs = @max(0, current_time - res.created_at);
const age_ratio = @min(1.0, @as(f64, @floatFromInt(age_secs)) / 7776000.0);
- score += options.recency_boost * (1.0 - age_ratio);
+ score -= options.recency_boost * (1.0 - age_ratio); // recent → smaller → better
var matches: usize = 0;
for (keywords.items) |kw| {
- if (std.mem.indexOf(u8, res.content, kw) != null) {
- matches += 1;
- }
+ if (containsIgnoreCase(res.content, kw)) matches += 1;
}
-
if (keywords.items.len > 0) {
const kw_ratio = @as(f64, @floatFromInt(matches)) / @as(f64, @floatFromInt(keywords.items.len));
- score -= options.keyword_boost * kw_ratio;
+ score -= options.keyword_boost * kw_ratio; // keyword hit → smaller → better
}
-
- res.score = @max(0.0, score);
+
+ res.score = score;
}
// 3. Optional LLM HTTP Reranking
@@ -139,3 +142,14 @@ pub fn searchHybrid(
return results;
}
+
+/// Case-insensitive substring search (ASCII). Used for the small keyword
+/// discount on top of vector ranking.
+fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool {
+ if (needle.len == 0 or needle.len > haystack.len) return false;
+ var i: usize = 0;
+ while (i + needle.len <= haystack.len) : (i += 1) {
+ if (std.ascii.eqlIgnoreCase(haystack[i .. i + needle.len], needle)) return true;
+ }
+ return false;
+}