Local hybrid index for things you intentionally collect — code, docs, web pages, PDFs, raw text. FTS5 + vector embeddings + AST knowledge graph in a single s...
---
name: cairn
version: "1.2.2"
description: Local hybrid index for things you intentionally collect — code, docs, web pages, PDFs, raw text. FTS5 + vector embeddings + AST knowledge graph in a single sqlite file. v1.2 ships explicit safety gates for ingestion (path allowlist, size caps, offline mode); v1.2.1 adds startup warning when no allowlist is set + published GGUF SHA256s for pre-cached air-gapped deployment. MCP server exposes search / graph / ask / path / tags so models can both query and maintain a curated local knowledge base.
license: MIT
disable-model-invocation: true
requires:
env:
- name: CAIRN_RUNTIME
required: false
- name: CAIRN_CHAT_MODEL
required: false
- name: CAIRN_CPU_ONLY
required: false
- name: CAIRN_DEBUG_DOC
required: false
- name: CAIRN_OFFLINE
required: false
- name: CAIRN_ALLOWED_ROOTS
required: false
- name: CAIRN_MAX_INGEST_FILES
required: false
- name: CAIRN_MAX_INGEST_BYTES
required: false
metadata:
clawdbot:
requires:
env:
- name: CAIRN_RUNTIME
required: false
- name: CAIRN_CHAT_MODEL
required: false
- name: CAIRN_CPU_ONLY
required: false
- name: CAIRN_DEBUG_DOC
required: false
- name: CAIRN_OFFLINE
required: false
- name: CAIRN_ALLOWED_ROOTS
required: false
- name: CAIRN_MAX_INGEST_FILES
required: false
- name: CAIRN_MAX_INGEST_BYTES
required: false
openclaw:
requires:
env:
- name: CAIRN_RUNTIME
required: false
- name: CAIRN_CHAT_MODEL
required: false
- name: CAIRN_CPU_ONLY
required: false
- name: CAIRN_DEBUG_DOC
required: false
- name: CAIRN_OFFLINE
required: false
- name: CAIRN_ALLOWED_ROOTS
required: false
- name: CAIRN_MAX_INGEST_FILES
required: false
- name: CAIRN_MAX_INGEST_BYTES
required: false
install:
- id: npm-cairn-index
kind: npm
package: cairn-index@1.2.1
flags: []
label: "Install cairn (npm, optional — bundled in lib/cairn/ on clawhub)"
author: mrsirg97-rgb
version: "1.2.2"
clawhub: https://clawhub.ai/mrsirg97-rgb/cairn
source: https://github.com/mrsirg97-rgb/cairn
npm: https://www.npmjs.com/package/cairn-index
compatibility: >-
Node ≥ 20. Native deps build on install (better-sqlite3, sqlite-vec, three tree-sitter grammars).
Default runtime requires ollama at http://127.0.0.1:11434 with `nomic-embed-text` pulled.
Set CAIRN_RUNTIME=embedded to run embed + chat in-process via node-llama-cpp (auto-downloads GGUFs to ~/.cairn/models on first use, ~785 MB; set CAIRN_OFFLINE=1 to block the download path and require pre-cached models).
Network egress: (a) explicit `cairn add <url>` (user-initiated web ingest), (b) localhost ollama only when CAIRN_RUNTIME=ollama, (c) Hugging Face GGUF download on first use only when CAIRN_RUNTIME=embedded and the model isn't pre-cached. CAIRN_OFFLINE=1 blocks (a) and (c); (b) stays available because it's localhost.
No API keys, no telemetry, no accounts.
---
# cairn
Local hybrid index for the things *you* intentionally collect — codebases, design docs, audit notes, web pages, PDFs, raw text. Curate, ingest, retrieve. One sqlite file, no daemons (embedded) or one daemon (ollama).
## What cairn is for
Local-first retrieval grounding for an LLM. You curate what's indexed (no automatic crawling), `cairn add` brings it in, and either you or a model running over MCP can query the result. Five query surfaces:
- **Hybrid chunk search** (`search`) — FTS5 + vector embeddings fused via reciprocal rank fusion. Returns ranked text chunks.
- **Knowledge graph** (`graph`) — entities (functions, structs, concepts) and edges (`calls`, `depends_on`, `mitigates`, `references`, `verifies`) extracted from code (tree-sitter, AST-based) and markdown (LLM, hash-gated, optional).
- **Composed retrieval** (`ask`) — hybrid search + per-hit entity context in one call. Replaces a search-then-graph round trip.
- **Shortest path** (`path`) — BFS between two entities through the edge graph. Batched layer fetch — one SQL per BFS layer, not per node.
- **Tag-filtered retrieval** (`tags`, `--tag`) — concept entities carry free-form LLM-emitted tags (`attack`, `invariant`, `mev`, etc). Filter `search` / `ask` / `graph` by tag; discover the in-use tag vocabulary via `tags`.
Cross-source linking (`cairn link sdk program`) resolves names across two related sources — an SDK calling its on-chain program is the canonical case. Soft-delete + FK cascades keep the graph clean across refreshes and removals.
## Quick start
Library:
```ts
import { Cairn } from 'cairn-index'
const cairn = new Cairn() // defaults to ~/.cairn, ollama @ 127.0.0.1:11434
await cairn.ingest.add({ kind: 'code', path: './src', label: 'my-project' })
const hits = await cairn.retrieve.search('how does the chunker handle overlap', { k: 5 })
cairn.close()
```
CLI:
```bash
cairn add ./src --label my-project
cairn search "how does the chunker handle overlap" -k 5
cairn graph "fee invariant" --tag invariant
cairn ask "what mitigates pool squatting" --tag attack
cairn path 1:engine.rs:swap 1:math.rs:calc_swap_fee
cairn tags
```
MCP (stdio):
```bash
cairn-mcp # exposes search / list / add / graph / ask / path / tags / refresh
```
## Configuration & safety (v1.2+)
Cairn is a curated index — you trust what you put in, and you control the surface around ingestion via env vars. None are required (defaults are sensible for a single-user developer setup), but every one is meaningful in shared, agent-driven, or compliance-sensitive deployments.
### Trust model — read this first
- **Autonomous model invocation is disabled** (`disable-model-invocation: true`). Tool calls require explicit user invocation through the host — the model can't decide on its own to call `CAIRN_ADD` or `CAIRN_SEARCH` without being asked. Matches the conservative default used by other side-effect-bearing skills. User-initiated flows ("index this repo for me", "find related online files") still work because the user's request to the agent IS the explicit invocation context; what's blocked is silent grounding (model autonomously calling cairn before answering, without being asked to).
- **You trust what you index.** Cairn doesn't auto-crawl. Every source enters via an explicit `cairn add` (CLI, library, or MCP) by you or by an agent you've authorized for that call. Indexed content is queryable later, including by future MCP-connected agents — that is the point. Ingesting untrusted web pages or sensitive code into a long-lived shared index is your call to make, and you can isolate sensitive content by running cairn against a different `dbPath`.
- **MCP gives connected agents full read + ingest access when invoked.** That's what MCP *is*. The host (Claude Desktop, OpenCode, etc.) controls which agents connect AND now (with `disable-model-invocation: true`) gates each call behind explicit user approval. Mutating ops `remove` / `link` / `unlink` / `reindex` are CLI-only — destructive or topology-changing actions require a human at the terminal.
- **Network egress is bounded.** See the network-egress note in the frontmatter. Localhost ollama is not blocked under `CAIRN_OFFLINE`; only outbound (web fetch, Hugging Face GGUF download) is.
### Defense-in-depth env vars
| Env var | Default | Purpose |
|---|---|---|
| `CAIRN_OFFLINE` | unset | When `1` or `true`, blocks `fetchWeb` (no `cairn add <url>`) and blocks non-local model resolution (no Hugging Face GGUF auto-download). Pre-cache models and pass `modelPath` for embedded runtime. Localhost ollama still allowed. |
| `CAIRN_ALLOWED_ROOTS` | unset (no restriction) | Comma-separated absolute paths. When set, `cairn add` rejects any local path (`code`, `file`, `pdf` kinds) outside these roots. Trailing slashes normalized. Defense-in-depth for MCP-connected agents that might be prompt-influenced into indexing the wrong place. Real protection is host-side per-call approval — this is the belt. |
| `CAIRN_MAX_INGEST_FILES` | `10000` | Pre-check on `addCode` directory walks. Aborts before any chunking/embedding work if the file count exceeds the limit. Bypassable via CLI `--force` flag (MCP intentionally does not expose `force`). |
| `CAIRN_MAX_INGEST_BYTES` | `524288000` (500 MB) | Pre-check on `addCode` directory walks. Aborts if total bytes exceed the limit. Same bypass model as the file cap. |
| `CAIRN_RUNTIME` | `ollama` | Switch between `ollama` and `embedded`. Embedded runs in-process via node-llama-cpp; first use auto-downloads GGUFs unless `CAIRN_OFFLINE` is set. |
| `CAIRN_CPU_ONLY` | unset | Force CPU-only inference on the embedded runtime. |
| `CAIRN_CHAT_MODEL` | Qwen3-0.6B Q8 | Override the doc-extraction chat model. |
| `CAIRN_DEBUG_DOC` | unset | Log per-doc extraction counts during ingest. |
### Air-gapped / offline-only deployment
```bash
# Pre-cache the embed and chat GGUFs once on a connected machine,
# verify the SHA256s match the published values (docs/setup.md
# "Verifying pre-cached models"), copy ~/.cairn/models/* to the
# air-gapped host, then:
export CAIRN_RUNTIME=embedded
export CAIRN_OFFLINE=1
export CAIRN_ALLOWED_ROOTS=/var/cairn/sources
cairn-mcp
```
Under this configuration, cairn makes zero network calls. Web ingestion is blocked outright; model resolution refuses anything that isn't an absolute path. Published SHA256s for the two cacheable GGUFs are in `docs/setup.md` so you can verify the bytes you ship to the air-gapped host match the bytes cairn was developed against.
### Startup warning
`cairn-mcp` logs a single warning line on boot when `CAIRN_ALLOWED_ROOTS` is unset, surfacing the path-allowlist call to operators who didn't read the docs. Set the env var to silence it (and confine ingestion); leave unset for a single-user developer setup where any-path ingestion is the intended behavior.
### MCP-connected-agent deployment
```bash
# Confine ingestion to a curated tree; everything else rejected at the gate.
export CAIRN_ALLOWED_ROOTS=/var/cairn/repos,/var/cairn/docs
# Lower the size cap if your sources are typically small
export CAIRN_MAX_INGEST_FILES=2000
cairn-mcp
```
The MCP host should still gate `add` / `refresh` calls per-invocation if the connected agent is partially-trusted. The env-var caps are belt-and-suspenders for the case where host gating is misconfigured or bypassed.
## Runtimes
Two interchangeable backends behind one `Cairn` class:
| Runtime | Daemon? | Embeds | Chat | First-run cost |
|---|---|---|---|---|
| `ollama` (default) | yes (localhost) | ollama `nomic-embed-text` | ollama Qwen3-0.6B Q8 (optional) | `ollama pull` once |
| `embedded` (set `CAIRN_RUNTIME=embedded`) | no | in-process via node-llama-cpp | in-process Qwen3-0.6B Q8 (optional) | ~785 MB GGUF download to `~/.cairn/models` (blocked if `CAIRN_OFFLINE=1`; pre-cache and use `modelPath`) |
Switching runtimes is one line — they implement the same `EmbedRuntime` / `ChatRuntime` contracts behind `EmbedProvider` / `ChatProvider`.
## Schema
Single baseline (`SCHEMA_VERSION = 2`, additive in v1.1). Tables: `sources`, `files`, `chunks` (+ `chunks_fts`, `chunks_vec`), `entities` (+ `entities_vec`), `edges`, `entity_tags`, `source_links`, `meta`. FK cascades from `sources` through entities into edges/tags; triggers keep `chunks_vec` and `entities_vec` in sync. v1 to v1.1 upgrade is automatic via `CREATE TABLE IF NOT EXISTS` — no migration runtime. v1.2 added no schema changes (safety gates only).
## MCP tools
Exposed by `cairn-mcp` over stdio. Read + ingest. Mutating ops `remove` / `link` / `unlink` / `reindex` are CLI-only — destructive actions require explicit user intent.
| Tool | Purpose |
|------|---------|
| `search` | Hybrid chunk search. Params: `query`, `k?`, `kind?`, `source?`, `tag?`. |
| `list` | List indexed sources. Params: `kind?`. |
| `graph` | Entity-level retrieval. Params: `query?` xor `entity_id?`, `k?`, `tag?`. |
| `ask` | Search + per-hit entity + 1-hop edges. Params: `query`, `k?`, `kind?`, `source?`, `tag?`, `maxEntitiesPerHit?`, `maxEdgesPerEntity?`. |
| `path` | Shortest path between two entities. Params: `from`, `to`, `maxDepth?`, `directed?`. |
| `tags` | List every tag in use across active entities + count. Discovery surface for the `--tag` filter. |
| `add` | Ingest a new source. Params: `kind?` (auto-detects), `target`, `label?`, `include?`, `exclude?`. Subject to `CAIRN_ALLOWED_ROOTS` and the size caps; `--force` is CLI-only. |
| `refresh` | Re-index existing source. Params: `ref` (id, uri, or `'all'`). |
## Verification
- 17 tests passing locally on the v1.2 baseline (7 pure, 10 live including LLM doc-extraction and embedded-runtime end-to-end). Live tests cover the actual ollama and node-llama-cpp paths, not mocks. New `tests/safety.ts` covers all three v1.2 gates (CAIRN_OFFLINE blocks/allows the right things; ALLOWED_ROOTS multi-root + trailing-slash + per-kind enforcement; size caps fire and force=true bypasses).
- The doc-extraction LLM pass uses ollama's `format` (or llama.cpp's grammar) for JSON-Schema-enforced output — even the sub-1B default chat model emits shape-valid concepts/edges/tags.
- Hash-gated re-extraction. Concepts re-emerge on refresh; doc-derived edges rebuild from scratch per doc; parse edges (AST) rebuild source-wide.
## Links
- [github.com/mrsirg97-rgb/cairn](https://github.com/mrsirg97-rgb/cairn)
- npm: [`cairn-index`](https://www.npmjs.com/package/cairn-index) (bins: `cairn`, `cairn-mcp`)
- SDK bundled in `lib/cairn/`
don't have the plugin yet? install it then click "run inline in claude" again.
local-first retrieval grounding for an llm. you curate what gets indexed (no auto-crawl), cairn add brings it in, and either you or a model running over mcp can query the result. one sqlite file, zero telemetry, no accounts.
cairn builds a hybrid search index over codebases, design docs, audit notes, web pages, pdfs, and raw text you deliberately ingest. use this when you need retrieval-augmented generation (rag) with full control over what's indexed, zero network egress except on your terms, and queryable knowledge graphs extracted from code (ast-based tree-sitter) and markdown (llm-extracted entities and edges). five query surfaces cover chunk search, entity graphs, composed retrieval, shortest-path finding between concepts, and tag-filtered discovery. deploy embedded (in-process, ~785 mb models) or pointed at a local ollama daemon. safe for mcp-connected agents because model invocation is disabled by default and ingestion accepts allowlists and size caps.
code kind), file path (file or pdf kind), or explicit url for web ingestion. tree-sitter-supported languages auto-parse on ingest; unsupported formats fall back to chunking + embedding only.~/.cairn/cairn.db. created on first use. one file per logical index; separate instances for sensitive / shared / test contexts.code, file, pdf, web) , ingest type. auto-detected for local paths (dir = code, .pdf = pdf, others = file). explicit for urls (web).addCode. e.g., include: ['**/*.rs'] restricts to rust; exclude: ['**/node_modules/**'] skips dependencies.attack, invariant, mev). discover in-use tags via the tags tool.nomic-embed-text on ollama or auto-cached gguf on embedded runtime. switching models requires re-indexing.CAIRN_CHAT_MODEL or param) , llm for doc-extraction concept/edge/tag inference. defaults to qwen3-0.6b q8 (sub-1b, local, cheap). only used during ingest if markdown/pdf/web content is present.| name | default | impact |
|---|---|---|
CAIRN_RUNTIME |
ollama |
switch backend: ollama (daemon @ 127.0.0.1:11434) or embedded (in-process node-llama-cpp, ~785 mb gguf auto-download on first use). |
CAIRN_OFFLINE |
unset | when 1 or true, blocks web ingestion (cairn add <url>) and blocks hugging face gguf auto-download (embedded runtime only). pre-cache models to ~/.cairn/models/ first. localhost ollama is not blocked. |
CAIRN_ALLOWED_ROOTS |
unset (any path allowed) | comma-separated absolute paths. when set, cairn add rejects any local file/code/pdf kind outside these roots. trailing slashes normalized. defense-in-depth for mcp-connected agents. |
CAIRN_MAX_INGEST_FILES |
10000 |
pre-flight check: abort ingest if directory walk exceeds this count. bypassable via cli --force (mcp does not expose force). |
CAIRN_MAX_INGEST_BYTES |
524288000 (500 mb) |
pre-flight check: abort if total bytes in source tree exceed this. bypassable via cli --force (mcp does not expose force). |
CAIRN_CPU_ONLY |
unset | force cpu-only inference on embedded runtime (no cuda/metal). |
CAIRN_DEBUG_DOC |
unset | log per-document extraction counts during ingest (concept/edge/tag yields). noise for large ingests but useful for tuning. |
nomic-embed-text model pre-pulled. set CAIRN_RUNTIME=ollama and ensure ollama is running at http://127.0.0.1:11434. embedding requests made per-chunk; respects ollama timeout (network error causes ingest to fail and roll back).CAIRN_OFFLINE=1. downloads go to ~/.cairn/models/. sha256s published in docs/setup.md for air-gapped verification. zero downloads if models are pre-cached.CAIRN_ALLOWED_ROOTS allowlist if set.step 1: initialize cairn instance
dbPath (string, defaults to ~/.cairn/cairn.db), optional runtime ('ollama' or 'embedded'), optional environment variables already setnew Cairn({ dbPath, runtime }) in library or invoke cairn-mcp for mcp mode. on first run, creates sqlite schema (version 2). on embedded runtime, auto-downloads models to ~/.cairn/models/ unless CAIRN_OFFLINE=1 (fails fast if offline but models not pre-cached).CAIRN_ALLOWED_ROOTS is unset and you're using mcp (operator notification that path allowlisting is available but not configured).step 2: validate ingest target against safety gates
CAIRN_ALLOWED_ROOTS allowlist if set. if allowlist is configured and target is outside any root, abort with clear error message naming the rejected path and listing allowed roots. if CAIRN_OFFLINE=1 and kind is web, abort with message that web ingestion is blocked. continue to step 3 if all gates pass.step 3: pre-flight size checks
CAIRN_MAX_INGEST_FILES, CAIRN_MAX_INGEST_BYTES, optional force flag (cli only, mcp does not support)CAIRN_MAX_INGEST_FILES and force is not set, abort with message listing the count and the limit. if bytes exceed CAIRN_MAX_INGEST_BYTES and force is not set, abort with message listing the byte size and the limit. force=true bypasses both checks (cli only; mcp ingest ops always respect limits).step 4: chunk the source
code kind, walk directory respecting include/exclude globs, parse each supported language via tree-sitter (rust, python, javascript, go, solidity, etc.; unsupported formats chunked as raw text), extract code chunks with line boundaries and ast context. for file kind, read raw bytes and chunk by semantic breaks (paragraphs, newlines, size heuristics). for pdf, extract text per page or per section if metadata available, then chunk. for web, fetch url (fails if CAIRN_OFFLINE=1), strip html, chunk text. all chunks tagged with source id, file path, line range (if code), and raw content.{ id, sourceId, filePath, lineStart, lineEnd, kind, content, language }. one sqlite transaction queues all chunks for embedding and fts5 indexing.step 5: embed chunks (call embedding runtime)
nomic-embed-text or embedded node-llama-cpp). requests made sequentially or batched depending on runtime (ollama batches in its own thread, embedded batches in process). each chunk gets a 768-dim float32 embedding. on network timeout (ollama), retry up to 2 times with exponential backoff, then fail and roll back transaction. on embedded runtime, cpu-only flag respected; first run downloads model unless CAIRN_OFFLINE=1.chunks_vec table (sqlite-vec fts + vector similarity).step 6: extract entities and edges from markdown/pdf/web (llm doc-extraction, optional)
CAIRN_DEBUG_DOC flag{ concepts: [{ name, kind, description }], edges: [{ from, to, relation }], tags: [{ entity, tag }] }. json is schema-validated (ollama format or llama.cpp grammar enforces validity). if CAIRN_DEBUG_DOC=1, log per-doc yields (concept count, edge count). on llm error or invalid json despite schema, skip document and log warning (continue with next).step 7: extract entities and edges from code (ast-based, tree-sitter)
{ id, sourceId, name, kind, scope, filePath, lineStart, lineEnd }. edge records: { fromEntityId, toEntityId, relation, confidence }. all written to sqlite in the ingest transaction.step 8: embed entities (call embedding runtime)
entities_vec table.step 9: index full-text search (fts5)
chunks_fts, entities_fts). fts5 tokenizes and indexes; no additional work needed in code.step 10: commit transaction and finalize
sources table with source metadata (label, kind, uri, file count, ingest timestamp, content hash for doc-extraction gating). if a source with same uri and label already exists, soft-delete the old version (mark deleted=1, keep for rollback) and insert new version (or update if refresh). fk cascades and triggers auto-clean stale entities/edges/tags from the old source.search (hybrid chunk search)
chunks_fts for prefix/phrase match, collect top-k fts scores. (b) embed query text (same model as chunks), vector search chunks_vec for cosine sim top-k. (c) reciprocal rank fusion (rrf) merge fts + vec rankings. (d) if filters set (kind/source/tag), post-filter merged results by those criteria. (e) return top-k merged results with content, file path, line range, source label, confidence score.{ id, content, filePath, lineStart, lineEnd, sourceLabel, score }.graph (entity-level retrieval)
entities_vec for top-k entity matches (no fts on entities; vec only). if entity_id set, skip search and use that entity directly. fetch entity by id and recurse: (a) all entities connected by 1-hop edges (in/out degree). (b) if k > 1, fetch k-1 adjacent edges (highest confidence first). (c) if tag filter set, keep only entities with that tag. return entity details + immediate neighbors.{ id, name, kind, filePath, description, edges: [{ toEntity, relation, confidence }] }.ask (search + entity context, composed)
{ chunk: {...}, entities: [{ id, name, kind, edges: [...] }] }.path (shortest path via bfs)
[id1, id2, ..., idN], plus array of edges connecting them. if no path found, empty array.tags (discover tag vocabulary)
entity_tags table, count entities per tag, optionally filter by entity kind. return sorted by count descending.[{ tag, count }, ...].startup
cairn-mcp binary. initialize cairn instance. expose stdio jsonrpc 2.0 interface with tools: search, list, graph, ask, path, tags, add, refresh. log startup warning if CAIRN_ALLOWED_ROOTS unset.add and refresh checked against allowed-roots and size caps; mutating ops remove, link, unlink, reindex not exposed (cli only).example invocations
cairn add ./src --label my-project , ingest code directory.cairn add https://example.com/docs --label docs-web , ingest web page (blocked if CAIRN_OFFLINE=1).cairn search "how does chunking work" -k 5 , hybrid search, return top-5 chunks.cairn graph "fee invariant" --tag invariant , find entities tagged invariant matching query.cairn ask "what mitigates pool squatting" --tag attack , search + entity context, filtered by attack tag.cairn path 1:engine.rs:swap 1:math.rs:calc_swap_fee , shortest path between two named entities.cairn tags , list all tags and counts.cairn refresh my-project , re-index by label (or use source id or uri).cairn list --kind code , list indexed sources