@bastra-recall/daemon
v1.1.0
Published
MCP server over a markdown vault: recall, load_memory, save_memory, edit_memory, archive_memory and document tools — plus the REST gateway, the quiet hook lanes and the bastra CLI.
Readme
@bastra-recall/daemon
The MCP server + HTTP gateway behind bastra-recall. Watches a markdown vault, indexes it with BM25 + optional embeddings, and exposes a stable tool surface over both stdio MCP and HTTP REST.
For project-level docs (vision, install, REST API, roadmap), see the top-level README and PLAN.md.
What it does
- Watches a directory of
.mdfiles with YAML frontmatter (schema in../../docs/memory-schema.md). - Indexes via
minisearchBM25;recall_whenis the highest-weighted field because it's authored for triggering. - Hybrid recall: optional embeddings (Ollama or OpenAI) via Reciprocal Rank Fusion on top of BM25 — see
BASTRA_EMBEDDING_PROVIDER. - Save path: writes
.mdfiles, validates frontmatter (zod), force-reindexes so save+recall in the same turn are consistent. - Auto-related enricher: for each new save, fills
related_viawith cosine ≥0.7 neighbors. - Memory graph: multi-hop recall (
expand_hops: 1) returns 1-hop neighbors viarelated_via. - Sensitivity filter:
privatememories aren't visible to external MCP/REST callers — and since #464 not writable by them either. The permission is bound to the transport, not passed as a tool argument, and a refused read and a refused write answer alike, so neither is an existence oracle. - Staleness re-ranking: memories with
valid_until/expires_after_days/last_reviewed_atget demoted (or excluded if expired) at recall time.
Surfaces
| Surface | Entry point | Use case |
|---|---|---|
| MCP (stdio, standalone) | dist/index.js | Single-client setup; each session spawns its own embedded daemon |
| MCP forwarder (stdio → loopback HTTP) | dist/mcp-forwarder.js | Default for multi-client setups. Starts a missing daemon on demand, first giving an installed managed macOS service up to ten seconds. All sessions share one vault state, one index, one telemetry stream |
| HTTP REST | http://127.0.0.1:6723/api/v1/{tool} | Non-MCP clients (ChatGPT Custom GPT Actions, web apps, scripts). Bearer auth + CORS supported |
| Hooks | dist/hook.js (PreToolUse), dist/session-hook.js (SessionStart) | Both POST to the daemon's /hook/recall |
| CLI | dist/cli.js (bastra bin) | Install / uninstall / doctor across every supported AI client |
Tools exposed (MCP + REST symmetric)
| Tool | Purpose |
|---|---|
| recall(query, k?, scope?, type?, expand_hops?) | Search the vault; hybrid BM25 + embeddings when enabled |
| load_memory(id) | Fetch full frontmatter + body |
| save_memory({title, type, body, …}) | Write a new memory with schema validation + force-reindex |
| edit_memory({id, str_replace?, append?, frontmatter?, expected_revision?}) | Patch one existing memory through the same save path (#519) |
| find_document(query, k?) | Search documents (PDFs, photos, contracts) |
| read_document(id) | Load extracted text + metadata for a document |
| open_document(id) | macOS-only: open in the system handler |
| find_code(query, mode?, repo?, depth?) | Locate a symbol or file in the code graph; one hop of dependents (#576) |
| save_document / recategorize_document / move_document | Document write path (Pro Mac-app uses this; OSS callers may need BASTRA_DOCUMENT_WRITE=1) |
Install + register
See the top-level README. Paths, in order of friction:
curl -fsSL https://bastra.io/install | bash— installs Homebrew + tap + binary + runs the guided setup. The recommended non-developer path.Install Bastra.commanddoubleclick — same steps, but a browser download is quarantined and mode 644, so it needs right-click → Open (#320).bastra install all— single CLI call that registers MCP + Skill + the default quiet Hooks across Claude Code, Claude Desktop, Codex/ChatGPT Desktop, and Cursor. On a first run with no vault configured, an interactive install offers to create~/BastraVaultfor you (non-interactive/--yes/--dry-runruns keep the deterministic error).- Fully manual JSON snippets — fallback.
All paths end with the client configs patched. Whether the daemon is already running differs: the interactive Map wizard starts it, and the MCP forwarder starts it on the first tool call — bastra install all only registers, it neither starts nor health-checks the daemon. bastra doctor is what probes the endpoint (#525).
Use bastra doctor --fix to repair stale paths, missing required hooks, or a
stale Skill copy after an update. The quiet Stop save-eval hook is enabled by default by the CLI. Opt out with
bastra install claude-code --no-stop-hook; deliberately disabling this optional
hook does not make Doctor fail.
Daemon process check
Don't grep for daemon/dist/index.js — the daemon is often launched with a relative path (node dist/index.js) and won't match. Use the port instead:
lsof -i :6723 -P -n # who owns the daemon port
curl -sS http://127.0.0.1:6723/healthExactly one PID should be listed. Two means a stale daemon is running in parallel — the HTTP port goes to whichever bound first, and the loser exits silently (see the EADDRINUSE handler in http-listen.ts).
Daemon startup
The MCP forwarder uses one shared daemon, giving an installed managed macOS service up to ten seconds before fallback spawn. For REST clients that need the daemon before an MCP client connects, start it explicitly:
bastra-recall &Dev workflow
npm run dev # ts-watch via tsx (no compile step)
npm run build # tsc + chmod +x all dist binaries
npm run check:types # type-check only
npm run smoke # smoke-test recall against fixtures/sample-vault
npm run smoke:telemetry # smoke-test telemetry append
npm run backfill:related # populate related_via on legacy memoriesConfiguration (environment variables)
Every on/off switch reads 0, false, off or no as off and 1, true, on or yes as on, in any case; the table shows the canonical spelling.
| env var | required | default | meaning |
|---|---|---|---|
| BASTRA_VAULT_PATH | yes | — | absolute path to the vault root (memories are auto-discovered) |
| BASTRA_HTTP_PORT | no | 6723 | loopback HTTP port for REST + hooks; read only when neither URL var below is set |
| BASTRA_HTTP_URL | no | derived | full URL override (for non-loopback testing); read only when BASTRA_DAEMON_URL is unset |
| BASTRA_API_TOKEN | no | unset | the bearer REST /api/v1/* requires from every caller that is not a direct local one; unset/empty does not open the API — such callers then get a 401 nothing can satisfy until a token is minted (#526) |
| BASTRA_AUTH_LOOPBACK_SKIP | no | 1 | the token-free path needs a loopback peer and a present, loopback Host header — a missing header counts as foreign (#526); set to 0 to require the bearer even for direct 127.0.0.1 callers |
| BASTRA_CORS_ORIGIN | no | unset (deny all) | comma-separated browser-origin allowlist; unset = no browser origin allowed; * = explicit permissive opt-in (tunnel/dev) |
| BASTRA_EMBEDDING_PROVIDER | no | unset | ollama or openai; without it the daemon stays BM25-only |
| BASTRA_EMBEDDING_MODEL | no | provider default | e.g. embeddinggemma (ollama) or text-embedding-3-small (openai) |
| BASTRA_OLLAMA_URL | no | http://localhost:11434 | ollama provider endpoint |
| BASTRA_ALLOW_REMOTE_OLLAMA | no | unset | allow a non-loopback BASTRA_OLLAMA_URL; without it the daemon refuses a remote endpoint, so a mistyped URL never sends memory text off-box (guards both the embedding provider and the reranker) |
| BASTRA_OLLAMA_KEEP_ALIVE | no | 10m | per-request keep_alive window — how long Ollama keeps the embedding model in RAM after each embed |
| BASTRA_OLLAMA_IDLE_UNLOAD_MS | no | 600000 (10 min) | unload the embedding model from Ollama RAM after this long without an embed (battery saver); 0 disables |
| BASTRA_BATTERY_SAVER | no | unset (→ battery.saver, default off) | battery mode on macOS (#632): 1 / 0 override the settings file. On battery, doc2query waits for AC, warm-ups are skipped and the idle unload fires after 60 s — see USAGE |
| OPENAI_API_KEY | no | unset | required when BASTRA_EMBEDDING_PROVIDER=openai |
| BASTRA_FORWARDER_SPAWN | no | 1 | when 0, the MCP forwarder will not auto-spawn the daemon. This remains a workaround for service-only starts, with BASTRA_DAEMON_URL naming that owner. macOS managed services now get a ten-second chance automatically; a headless service behind a forwarder waits for its idle exit (#758). Linux managed setup follows in #566 |
| BASTRA_DAEMON_URL | no | http://127.0.0.1:6723 | which daemon this machine means — highest-precedence input to the single endpoint resolver every surface uses since #531 (CLI, doctor, map, hooks, forwarder, bridge, and the daemon's own bind), and what bastra install writes into a client registration |
| BASTRA_HOOK_TIMEOUT_MS | no | per lane | overrides the lane's wall-clock budget before fail-silent — the per-lane defaults are in docs/hooks.md (#305) |
| BASTRA_VECTOR_DEADLINE_MS | no | 150 | hook path only: how long a recall waits for the dense arm before serving BM25-only. Bounds that stage, not the call — measured warm the arm costs 87–96ms (total 106–113ms), cold 668ms (total 694ms), so 150ms passes every warm call and caps a cold one near 180ms. The embed is abandoned, not cancelled, so the model still finishes loading and the next call is warm. Degradations are visible as degraded: "vector-arm-timeout"; 0 disables (kill switch) |
| BASTRA_HOOK_MAX_SHOW | no | 1 | how often the same memory may appear in <recall-hints> per session (4h window); a load_memory of that id resets the counter |
| BASTRA_HOOK_CONTENT_RECALL | no | off | set to 1 to run the opt-in edit-content recall arm (#282) |
| BASTRA_DOCUMENT_WRITE | no | unset | set to 1 to expose document write tools |
| BASTRA_LOG_PATH | no | ~/.bastra/logs | telemetry JSONL output directory (out-of-vault on purpose) |
| BASTRA_TELEMETRY | no | on | set to off to disable telemetry writes entirely |
Telemetry
Every recall, load_memory, save_memory and hook call appends one JSON line to events-YYYY-MM-DD.jsonl in the log dir. Each daemon process gets a fresh session_id; recalls get a recall_id and any load_memory / save_memory within 5 minutes references that id as follows_recall.
That's enough to compute:
- recalls per session, hit counts, top-score distribution, latency p50/p95
- recall→load_memory follow-through rate (proxy for "was the hint useful?")
- saves per session, type/scope distribution, % overwrite vs. new
- save→follows_recall rate (was a duplicate-check done?)
- per-hook latency and hit-quality (where do PreToolUse / SessionStart actually help?)
Logs live outside the vault on purpose so the file watcher doesn't index them. Tail one to watch live: tail -f ~/.bastra/logs/events-$(date +%F).jsonl.
Search ranking
Field boosts (in src/search.ts):
recall_when_flat: 5 ← authored for triggering, highest weight
title: 4
tags_flat: 3
recall_when_expanded_flat: 2 ← doc2query paraphrases (#117): machine-generated, below the author's words
topic_path_flat: 2
summary: 2
body: 1Fuzzy distance 0.2, prefix matching enabled, combineWith: "OR". Hybrid mode combines BM25 with embedding cosine via Reciprocal Rank Fusion (RRF); the embedding query goes to the provider once per recall and is cached in-memory.
Schema reference
See ../../docs/memory-schema.md for the full memory frontmatter spec. The daemon enforces required fields via zod on load — files that fail validation are skipped with a warning to stderr.
Limitations
- Shared process. The forwarder starts the daemon on demand. On macOS,
bastra autostart onmanages an optional LaunchAgent for keeping it running; usebastra autostart offto return to on-demand operation. - Cloud-storage mounts (Google Drive, iCloud, Dropbox) need polling-mode in chokidar — enabled by default, but watcher latency can be a few seconds.
- Cursor rules are per project.
bastra install cursorregisters MCP. Runbastra rules cursorinside each project to install the shared memory guidance.
