Local semantic search over your AI memory files: recall by meaning, cited by source.
Ask "how do we keep users logged in" and Munin finds the note that says "session cookies over JWT", even though no words overlap. Every result cites its file, heading, and date.
Named after Muninn, Odin's raven of memory, who flies out over the world each day and returns to report everything he has seen.
Not related to the Munin monitoring tool. This raven only watches your memory files.
On npm as @malinfossum/munin.
Node (ESM), one dependency: @huggingface/transformers for local embeddings. Biome lints and
formats the code (dev dependency only). No server, no API keys, no telemetry. The only network
request Munin ever makes is the one-time model download (~30 MB); once the model is cached, remote
lookups are disabled in code and it runs fully offline.
Embedding model: Xenova/all-MiniLM-L6-v2, the ONNX build of
sentence-transformers/all-MiniLM-L6-v2. Apache-2.0, open weights, and the training data is
disclosed: the model card lists the sentence-pair datasets it was fine-tuned on (S2ORC,
StackExchange, MS MARCO, Natural Questions and others). munin.config.json pins it to a commit
hash so the weights cannot change underneath you.
npm install -g @malinfossum/munin
munin index
The first index run downloads the embedding model (~30 MB) once; after that Munin is fully
offline. Working on Munin itself instead? Clone the repo and use node src/cli.js in place of
munin. The two are the same entry point:
git clone https://github.com/malinfossum/munin.git
cd munin
npm install
node src/cli.js index
Point Munin at your memory folders. The shipped munin.config.json defaults to
~/.claude/memory; to change it, put your own munin.config.json in the config directory
below. In a checkout, create munin.config.local.json next to the committed one instead
(gitignored). Either way your personal paths never enter the repo.
In a git checkout (that is, whenever munin.config.local.json sits next to
munin.config.json), your override and the index stay in the repo, under data/. Working on
Munin therefore never touches an installed copy's index.
Installed as a package, both move out of the package directory, because npm update replaces
its contents and would otherwise destroy an index that takes minutes to rebuild:
| Windows | macOS / Linux | |
|---|---|---|
Your config (munin.config.json) |
%APPDATA%\munin |
$XDG_CONFIG_HOME/munin, else ~/.config/munin |
| Index and imported text | %LOCALAPPDATA%\munin |
$XDG_DATA_HOME/munin, else ~/.local/share/munin |
sources: array of folders to index. Each entry is either"~/path"or{ "path": "~/path", "weight": 0.5 }; lower-weight sources rank below curated memory. Changes to a source's weight take effect after the nextmunin indexrun. An entry may also name a single.mdfile, which is how you index a project's own docs without pulling in the rest of its repo. Pointing a source at a repo root would sweep up build output,node_modules, and any imported transcripts sitting under it. A source folder that is itself a link is followed; links found inside one are not.topK: max results returned (default 5).minScore: confidence threshold below which Munin says "No confident match." (default 0.3).semanticWeight: weight of cosine similarity in the final ranking (default 0.7).keywordWeight: weight of keyword/IDF matching in the final ranking (default 0.3).recencyWeight: weight of the recency boost in the final ranking (default 0.05).recencyHalfLifeDays: how fast the recency boost decays with age, in days (default 90).modelRevision: pinned commit hash of the embedding model, so upstream changes can't silently swap weights (default"main"when omitted).importSources: folders scanned bymunin importfor session transcripts (default[], opt-in; see below).importedWeight: source weight for imported sessions, the lowest in the index (default 0.25).importedMinScore: the confidence bar imported chunks must clear, above the curatedminScorebecause transcript prose matches loosely (default 0.5; never belowminScore).importedTopK: extra result slots for imported chunks, appended after thetopKcurated hits (default 2). Set to 0 to leave imported content out of results entirely.contextMinScore: confidence threshold for proactive-recall injection, higher than search'sminScore(default 0.45; see below).contextMaxChunks: max chunks proactive recall injects per prompt (default 3).contextIncludeImported: opt-in to let imported transcript text be injected by proactive recall (defaultfalse; see below).
munin search "how do we keep users logged in"
munin status
Results are ranked by a hybrid of meaning, keywords, and recency. Keyword terms are lightly
stemmed, so "injecting" still matches a note that says "injection". search --json emits raw
results for tooling. When nothing clears the confidence threshold, Munin says "No confident
match." instead of guessing.
skills/recall/ is a Claude Code skill that answers questions from the
index with citations. Install it by copying the folder into your personal
skills directory and putting munin on your PATH:
cp -r skills/recall ~/.claude/skills/recall
npm link # run once in this repo; creates the global `munin` bin
The skill treats retrieved text as quoted data, never as instructions, and passes "No confident match." through verbatim.
munin import converts Claude Code session transcripts (*.jsonl) into dated markdown
under data/imported/, which then indexes at the lowest source weight. Add the folders
to importSources (nothing is imported by default):
"importSources": ["~/.claude/projects"],
"importedWeight": 0.25
Only conversation text is imported. Tool output, tool calls, and Claude's thinking are
dropped, and a secret scrubber replaces key-shaped strings with [scrubbed] before
anything is written (long opaque tokens like git SHAs are scrubbed too, by design).
It also scrubs Norwegian fødselsnummer, D-nummer and kontonummer, and card numbers,
when they pass their checksum (mod 11 or Luhn). Older imports keep the scrubbing they
were written with: delete data/imported/.sentinel.json and re-run munin import to
re-scrub them.
Re-runs are incremental; the import ledger is written only after a fully successful run.
Note: transcripts contain other people's words too (collaborators, quoted web content).
Imported text stays in gitignored data/ and ranks below your curated memory.
Weight ranks imported text down; it no longer hides it. minScore is judged on the
unweighted relevance, so a strong imported match survives the gate and then sorts below
every curated hit that also cleared it. Sorting alone still left it unreachable (curated
hits fill every slot), so up to importedTopK imported results are appended after the
curated ones instead of competing with them. Imported text must clear the higher importedMinScore
to appear at all, which is what keeps a question your notes cannot answer answered with
"No confident match." rather than with transcript chatter.
A UserPromptSubmit hook can pipe every prompt you type to munin context, which searches
the index and (only on a confident match) injects up to 3 chunks as labeled background
context before the prompt reaches Claude. No lookup, no citations to type by hand.
There's no switch in munin.config.json. Enabling proactive recall means registering the hook in
a project's .claude/settings.json. Hook presence is the toggle:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node C:\\path\\to\\munin\\src\\cli.js context",
"timeout": 5
}
]
}
]
}
}Invoke node …\src\cli.js directly. Never use the munin command or the npm .cmd shim, which
force a shell on Windows, exactly what raw prompt text must never touch. For a global install
the file sits under the npm prefix, e.g.
%APPDATA%\npm\node_modules\@malinfossum\munin\src\cli.js;
npm root -g prints the folder.
Keep the explicit timeout so a hang can never ride the default 60 s.
Imported transcript text (see above) never auto-injects. That's enforced twice: an explicit
imported flag set on the chunk at index time excludes it from injection regardless of
weight, and (belt and braces) imported chunks also carry the lowest source weight by
default, well below the injection threshold. Set "contextIncludeImported": true in
munin.config.json to opt in explicitly; it's off by default because third-party words (a
collaborator's message, quoted web content) shouldn't silently steer a future session.
munin context always exits 0 and prints nothing on any failure: a missing index, a cold
model cache, a config error, or a pre-M5 index that predates provenance tracking. It never
downloads the model and never blocks or delays the prompt: worst case, proactive recall stays
silent and the prompt goes through untouched, same as if the hook weren't registered at all.
Measured with a warm model and index, median added latency was 0.803 s per prompt.
Registering the hook makes the configured memory folders ambient in that project's sessions. Recalled text can surface on any prompt. The injected block itself instructs that recalled text is private context: never to be quoted or paraphrased into public artifacts (commits, PRs, READMEs).
contextMinScore: confidence threshold for injection, higher than search'sminScore(default 0.45).contextMaxChunks: max chunks injected per prompt (default 3).- Prompts under ~15 words and slash commands (
/…) are always skipped (too little signal to search on).
Munin is read-only over your memory folders. Its index (data/) contains the raw text of those
files. It is gitignored and never leaves your machine.
Wrap anything you never want indexed in <private> tags, in a memory file or in a prompt
that may later be imported:
Rent goes to <private>the landlord's account</private> on the 1st.
The block is dropped before chunking, so it never reaches the index, search results or
proactive recall. An unclosed <private> drops everything after it.