@squirrelnotes.app/mcp-embeddings
v2.5.0
Published
Opt-in local semantic memory search for the Squirrel Notes MCP (stdio). The embedding model runs on your machine; no memory text is sent anywhere to be embedded.
Readme
Squirrel Notes MCP: local embeddings (opt-in)
@squirrelnotes.app/mcp-embeddings is the Squirrel Notes stdio MCP server
with semantic memory search. search_memory finds a memory by its meaning
as well as its words, so "food allergies" finds "Is allergic to peanuts",
which shares no word with it.
It is the same server as @squirrelnotes.app/mcp:
the same tools, the same inputs and the same output shape. It is built from the
same source and published from the same commit, at the same version. The
only differences are the order search_memory returns, its ranking (hybrid) and each hit's
semanticScore, which the base package sends as null. If you do not install it,
nothing changes for you.
What stays on your machine
- The model runs locally. It is gte-small, a small English sentence-embedding model (all-MiniLM-L6-v2 until feature 0059). It runs in this process, on your CPU, through ONNX Runtime. No memory text is sent anywhere to be embedded: not to Squirrel Notes, not to HuggingFace, and not to any embedding API.
- The model ships inside the package. Nothing is downloaded when the server runs, so embeddings work offline.
- ONNX Runtime's own telemetry is turned off. The runtime's official
macOS and Linux builds report usage to Microsoft by default. This package
sets
ORT_DISABLE_TELEMETRY=1before the runtime loads, whatever your environment says. On Windows the runtime reports through Windows' own event tracing (ETW) instead, which that setting does not affect; whether those events leave your machine depends on your Windows diagnostic-data settings. - Vectors stay local and encrypted. Embedding vectors can reveal roughly
what a text said, so they are treated like the text itself:
- they are never sent to the server, never logged and never returned by a tool;
- they are kept in one file per account in your OS cache directory
(
~/Library/Caches/squirrel-notes/on macOS,$XDG_CACHE_HOMEor~/.cacheon Linux,%LOCALAPPDATA%on Windows); - that file is encrypted with AES-256-GCM, under a key derived (HKDF-SHA256)
from your data key, and written with mode
0600. Windows ignores Unix modes; there the file has your user profile's permissions.
- What still leaves your machine is what always did. The server stores your
memories as ciphertext only.
search_memory's results (the memory text) go to the agent that asked, and so to that agent's model provider, exactly as with the base package.
What the encryption does not protect against: anyone who can read your MCP client config. That file holds your passphrase, which unlocks the data key, which unlocks the vector file. The encryption protects the vector file when it leaks on its own: in a backup, a synced folder or a support bundle.
Install (opt-in)
Change args in your MCP client config from the base package to this one. The
env block stays the same; add ONNXRUNTIME_NODE_INSTALL as shown.
{
"mcpServers": {
"squirrel-notes": {
"command": "npx",
"args": ["-y", "@squirrelnotes.app/mcp-embeddings"],
"env": {
"SQUIRREL_API_BASE_URL": "https://api.squirrelnotes.app",
"SQUIRREL_API_KEY": "sqn_<your_api_key>",
"SQUIRREL_PASSPHRASE": "<your_passphrase>",
"ONNXRUNTIME_NODE_INSTALL": "skip"
}
}
}
}Why ONNXRUNTIME_NODE_INSTALL=skip. The runtime, onnxruntime-node, has an
install script. On Linux x64 only, that script downloads the NVIDIA CUDA
add-ons from nuget.org by default, checking no hash. This
package does not use the GPU, so the download is never needed. The setting turns
it off. On macOS, Windows and Linux arm64, the script downloads nothing.
Pinned dependencies. The package ships an npm-shrinkwrap.json, so npm
installs exactly the dependency versions that were audited and licence-checked
for the release.
Size. The package is about 77 MB to download (134 MB unpacked), most of
it the full-precision model (about 17 MB with all-MiniLM-L6-v2 until feature
0059).
onnxruntime-node adds about 301 MB (287 MiB) unpacked, because it ships
binaries for every platform it supports. That is why embeddings are a separate
package.
Intel Macs. onnxruntime-node 1.30.0 ships no binary for Intel Macs (darwin/x64):
its native bindings cover macOS on Apple silicon, Linux x64 and arm64, and
Windows x64 and arm64. On an Intel Mac the install should still succeed (the
runtime's install script has nothing to fetch there), but the runtime cannot
load, so the server logs memory: local embeddings were not loaded (runtime);
memory search is lexical only once on stderr and search_memory stays lexical,
exactly as with the base package. This is read from the runtime's published
files and its loader; it has not been tested on an Intel Mac.
To go back to lexical search, change args back to @squirrelnotes.app/mcp.
The vector file can be deleted at any time; it is rebuilt when needed.
Recommended: the agent skill
Recommended: give your agent the Squirrel Notes skill from github.com/squirrel-notes/skills. It teaches the agent to start each session with get_context, search before answering (with a near-miss retry), save memories that will be found again, and correct them without losing history. In Claude Code, copy skills/squirrel-notes into ~/.claude/skills/; the repository's README covers other agents.
How search ranks
- Without this package,
search_memoryis lexical: BM25 over the words of each memory's text, entity, tags and keywords, times a recency decay per category. - Each memory is embedded as its entity, text, tags and keywords
(
entity: text (tags, keywords); feature 0061). A memory with no keywords is embedded from the same text as before, but the vector file now records how the text is built, so the first searches after updating re-embed every memory once (an older companion may have embedded a memory without the keywords the app gave it, at the same revision). After that, a memory whose keywords change is re-embedded because the change is a new revision. - With it, the two scores each hit returns are fused (feature 0050, in place
of reciprocal rank fusion; feature 0054, "words win, age on meaning").
The fused value is 0.3 ×
lexicalScore+ 0.7 × the semantic part × the recency decay, where the semantic part is 0 at or below the model's floor (minSimilarity, 0.837), 1 at the floor plus its span (semanticSpan, 0.086, so a cosine of 0.923) or above, and linear between. Both values are the model's, inmodel-manifest.json. The decay is applied once, to the semantic part only, so age does not fade a memory's word match. A hit that holds every query word (lexicalScore1) scores 0.5 + half the fused value, any other hit half of it: so those hits come first, andscorenever rises down the list. Of two hits on the same side of 0.5 with the same recency weight (the same age and category), one at least as high on bothlexicalScoreandsemanticScore, and higher on one, ranks first; ties go to the higher cosine. The weight 0.3 was chosen after measuring on the recall gate's corpus (any weight between 0 and 1 keeps that order). The span belongs to the model's cosine range, so it is inmodel-manifest.jsonwith the floor: 0.086 for gte-small, whose cosines sit between about 0.75 and 0.95, calibrated to keep the same share of relevant pairs as all-MiniLM-L6-v2's 0.35 and 0.4 did (feature 0059). - The members are the memories whose words match and those at or above the floor. Below that, a memory is returned only if its words match.
- An old memory matched by words keeps its word part whatever its age: only the meaning part is discounted. One that holds every query word ranks above any memory matched by meaning alone, and one that holds some of them can still rank above a fresh memory matched by meaning alone (feature 0054, settling proposal 0074's open question 2). Lexical search is unchanged: BM25 times the decay.
scoreis on a 0–1 scale here, and unbounded BM25 in the base package. It orders one response only. The keys are the same in both.- To judge a hit, use the absolute scores:
semanticScore(the cosine similarity, reported for every hit, also those below the floor found by words) andlexicalScore(the share of the query's content words, stopwords aside). Hits come in rank order, and the tool suggests no cut-off: a low score means lower confidence, not a wrong answer. With all-MiniLM-L6-v2 the recall gate's synthetic data put a cut atsemanticScore0.45 (ADR-0444), but correct hits on real memories scored 0.416 and 0.426, so agents are told to check low-scoring hits against the question before dismissing them (ADR-0444, amended 2026-10-13).semanticScoreis a cosine, −1 to 1; with gte-small most memories score between about 0.75 and 0.95, related or not, so it is compared between the hits of one response.rankingishybridwhen the semantic order ran,lexicalwhile the model loads or after it fails. - Accents on Latin letters are ignored by word matching in both packages
(
cremefinds "crème",strassefinds "Straße"). Word matching works in any script in both packages: Greek, Cyrillic or Korean by words, and Chinese, Japanese, Thai, Lao, Khmer and Myanmar, written without spaces, by pairs of characters (a one-character query counts half for each pair holding it). Other forms of a word are not matched (тёмнаяdoes not find "тёмную", and a two-syllable Korean query such as서울does not find "서울에", where a particle is attached), and accents on letters of other scripts still count. The model reads English: embeddings add little for other languages. - A hyphenated word matches whole (
piholeorPi-hole), and each of its parts counts half; a query word of one or two letters matches only that whole word, or a hyphenated word's part of the same spelling (half). - A query with no letter or digit in any script (most emoji, or punctuation, alone)
always comes back with a
note. It says the query cannot be searched, unless the semantic ranking actually ran, in which case it says the results were found only by meaning.
Near misses (opt in)
search_memory with nearMisses: true also returns up to 3 of the closest
memories that did not match (no word in common, meaning under the floor),
after the matches, each with nearMiss: true, score 0 and lexicalScore 0;
total does not count them (feature 0060, ADR-0478). Default search is
unchanged, so a question with nothing stored still comes back empty. Agents
are told to use it once, on a retry, and to treat a near miss as a candidate
to check, never an answer. Without this package none are returned.
When it is lexical anyway
Search falls back to the base package's lexical order, and says so once on stderr, never per call, when:
SQUIRREL_MEMORY_EMBEDDINGS=0is set;- a model file's size or SHA-256 differs from
model-manifest.json, or a model file is missing (the model is refused, never loaded); - ONNX Runtime cannot load on your platform (an Intel Mac, for one: see Intel Macs above);
- the runtime fails during a search (lexical for the rest of the session).
While the model is still loading at start-up (a fraction of a second), search is lexical too.
First searches. Each memory is embedded once, then only again when it
changes (its revision moves). A search spends at most 8 seconds embedding
memories that have no vector yet. Until a later search embeds them, the rest
have no semanticScore: they are returned only if their words match, and
their fused value is at most 0.3, so during this warm-up a partial word
match not yet embedded can rank below a memory found by meaning alone (a
match on every query word still ranks first).
Measured with all-MiniLM-L6-v2 on an Apple M1 Pro: 500 memories in 4.4 s,
5,000 in 26.7 s. gte-small at full precision takes about 3.5 times as long per memory
(7.5 ms against about 2.1 on an AMD Ryzen 7 5800X; feature 0059), so expect
about 3.5 times these; not measured on Apple silicon.
Model integrity
model-manifest.json pins the model to a commit of
thenlper/gte-small
(17e1f347…, the full-precision onnx/model.onnx; MIT, see
LICENSE-MODEL.txt). Full precision, not the repository's int8 build: int8
kernels differ by CPU family, and with gte-small's narrow cosine band the int8
build ranked differently on an Intel runner than on AMD (feature 0059 round
2); float arithmetic gives the same vectors everywhere to within 1e-5. It records each file's size and
SHA-256, and the model's floor and span. Until feature 0059 it pinned
sentence-transformers/all-MiniLM-L6-v2; the vector file records the model,
so the first searches after an upgrade embed every memory again (ADR-0443).
- The release workflow fetches the files from that commit, never from a branch. It refuses any file that does not match, and checks them again before packing.
- Every start reads each file, checks it, and gives the runtime the same bytes it checked, so a file replaced after the check is never loaded.
SQUIRREL_MEMORY_MODEL_DIRcan point at a copy you placed yourself. It goes through the same check.
The model is Apache-2.0; see LICENSE-MODEL.txt. This package is MIT.
Recall
The release is gated on a recall test (npm run test:recall). It compares
recall@5 of lexical and hybrid search on 500 memories and 160 queries.
- The corpus is synthetic and written by an AI agent, not user data, which zero-knowledge encryption makes unreadable.
- Hybrid search must gain at least 15 points on paraphrased queries, and must not lose on exact-word queries.
- Since feature 0050 it also gates, by words alone, a small ranking set of stand-ins for reported misses (hyphenated words, short fragments) and a ten-query set in other scripts (lexical recall@5 at least 0.8), and reports recall@1 and MRR beside recall@5.
- The latest results are in ADR-0444 and the feature 0050 notes in the Squirrel Notes repository.
Checking recall on your own memories
A local diagnostic shows, for questions you choose, where the memory you expected ranks and how close it came to the floor (feature 0060). It runs the same unlock, model and ranking as this package, on your machine, from a checkout of the repository (it is not in the published package):
npm --prefix mcp-embeddings ci && npm --prefix mcp-embeddings run fetch-model
# queries.json: [{ "query": "DNS ad blocking", "expect": "Pi-hole" }, ...]
export SQUIRREL_API_BASE_URL=https://… # the same value as in your MCP config
npm --prefix mcp-embeddings run diagnose -- queries.jsonIt asks for your API key and passphrase on the terminal, without showing
them, unless SQUIRREL_API_KEY and SQUIRREL_PASSPHRASE are already set.
Do not type them on the command line (SQUIRREL_PASSPHRASE=… npm …): the
shell saves that line, with the values, to its history file.
expect is a piece of the memory you mean (its text, entity or tags), matched
ignoring case and Latin accents. Each line reports the memory's id, its rank
as a match or a near miss, its rank by meaning, its cosine and how far under
the floor it is. It prints ids, ranks, scores and your own queries only:
never memory text, entities or tags, and it writes no log. Its vectors go to a
temporary directory that is deleted when it finishes or is interrupted
(Ctrl-C); your companion's own vector file is not touched.
Environment variables
Everything in the base package's README applies, plus:
| Variable | Default | Description |
|---|---|---|
| SQUIRREL_MEMORY_EMBEDDINGS | on | 0 turns semantic search off (lexical only). |
| SQUIRREL_MEMORY_MODEL_DIR | the package's model/ | Directory holding the model files. They are checked against the manifest. |
| SQUIRREL_MEMORY_CACHE_DIR | the OS cache directory | Where squirrel-notes/memory-vectors-<id>.bin is kept. |
| ONNXRUNTIME_NODE_INSTALL | — | Set to skip so the runtime's install script fetches no GPU add-ons (Linux x64). |
What is logged
Everything in the base package's list, plus one line at start-up: either
local embeddings ready, or were not loaded, followed by a reason (manifest,
missing, integrity or runtime) and, for the first three, a file name.
After that, a line appears only if the runtime fails or the vector file cannot
be written, and then only once. No line contains memory text, a query or a
vector.
