@synthryn/sypi-memory
v0.6.0-beta.20260816.6a00fcae
Published
Optional extension: Save notes locally, retrieve them by meaning, and auto-inject a bounded set of relevant memories into each turn. Use it when useful project or personal context should survive sessions without leaving the machine. Primary capability: th
Maintainers
Readme
sypi-memory
Local vector memory. Notes survive across sessions, and retrieval never leaves the machine after the embedding model is on disk.
Surface
One memory tool with four ops:
op:addsaves a note.scopeis project-local by default, orglobalacross every project.op:searchrecalls notes by meaning.op:forgetdeletes a note by id.op:whyshows where a recalled note came from: session, timestamp, capture trigger.
A context hook auto-retrieves the top-k relevant notes each turn and prepends
them to the conversation. It is bounded to top-k, never the whole store.
Memory aggressiveness
Set memory.aggressiveness to choose the amount of automatic recall and
maintenance. The default preserves the behavior described below. Explicit
reSearch, autoCapture, and dream switches override the selected level.
low: inject one high-confidence note; keep capture, re-search, and dream off.default: inject up to three notes above the0.35score floor; failure memory stays on, while durable capture and maintenance remain opt-in.high: inject up to five notes, enable boundary capture, re-search after compaction, and gated dream consolidation.max: inject up to eight notes from a wider candidate pool, use a lower relevance floor, and allow dream consolidation after one hour and one session.
Every level remains bounded by the host's optional context-append budget and
keeps validation, deduplication, namespace boundaries, failure-safe hooks, and
the injection-time prompt-injection scan. The level does not change explicit
memory tool searches or manual writes.
A human-facing /memory command opens a centered board for inspecting and
pruning stored notes. Bare /memory shows the most recent notes, compact and
newest first; /memory <query> runs the same semantic search as the tool. Use
a or the add memory row to save a note from the board. /memory forget <id>
deletes one as a direct fast path. The command and the tool share one store
closure, so they never diverge.
Where notes live
Notes are embedded with bge-small-en-v1.5 (384-dim) via
fastembed and stored in a single
sqlite-vec file at
~/.sypi/memory/memory.db, opened through Node's built-in node:sqlite and
keyed by a per-project namespace plus a shared global bucket. Nothing is ever
written into the project repository.
How a note is ranked
Retrieval does not use raw cosine alone. A vector candidate pool is reranked by
a fully local hybrid score: 0.7·vector + 0.3·BM25. BM25 runs in memory
over the candidate set and adds no dependency. The product is
multiplied by a per-source weight, so project notes edge out the shared
global bucket, and by a session-scoped recency decay with a 7-day
half-life on project notes. global facts are long-lived and undecayed.
On top of that sits a write-time importance nudge,
+ IMPORTANCE_BONUS·(importance - 0.5). The term is additive and centered on
the neutral default, so a fact rated above neutral is lifted, one below is
demoted, and an unrated fact contributes exactly zero and keeps its prior
order. Importance is rated once, at write time, inside the same cheap-LLM
capture pass, so it costs no extra call and retrieval stays fully local.
Injection keeps only notes above a 0.35 floor. A growing store therefore
yields fewer, more-relevant memories per turn, at zero per-turn token cost. An
opt-in MMR diversity rerank (λ=0.7, off by default) drops near-duplicates
when requested.
Post-compaction re-search (opt-in memory.reSearch)
A lossy compaction summary can drop facts that remain in the durable store.
When enabled, a session_compact handler runs the same local retrieve()
against the fresh summary. The summary becomes the leading context. The handler
injects recovered notes through pi.sendMessage for the next turn.
It fires only at compaction time, never per turn, reuses the existing retrieve
machinery, and adds nothing to the default per-turn path. This half lives
entirely in sypi-memory. It never imports sypi-compaction, which owns the
separate segments half.
Boundary auto-capture (opt-in memory.autoCapture)
The store previously grew only when the model called op:add. Boundary capture
adds the other write path.
A rolling buffer records the transcript that the context hook assembles each
turn, at zero extra token cost. At a session boundary, either
session_shutdown or session_before_compact, it extracts the transcript
before context is discarded. It then runs one cheap-LLM pass through the host's
bounded query seam (ctx.cheapQuery, the cheapest currently-authed provider)
to distil a bounded list of durable facts:
project conventions, commands, decisions, stable preferences. It skips
ephemeral chatter.
The model output is UNTRUSTED. It is fence-stripped, JSON-parsed,
Typebox-validated, and hard-capped at 12 facts of at most 240 characters each
before anything is stored. Each survivor is embedded once and semantically
de-duplicated against the target namespace. A cosine score at or above 0.92
means that the fact is already known, so it is skipped. This also catches
intra-batch repeats. The remaining facts are written through the same bge-small
and sqlite-vec store with the project or global scope.
Every failure path is a safe no-op. This includes an unavailable authenticated provider, a timeout, malformed output, and an empty transcript. Capture never breaks shutdown or compaction.
Dream consolidation (opt-in memory.dream)
Capture grows the store. Dream curates it as volume increases. A gated
session_shutdown pass clusters near-duplicate facts, merges each cluster into
one consolidated fact with an optional topic heading, re-embeds the merged text
through the same bge-small store, and prunes the originals.
Two gates keep it cheap and rare, and both are required: at least minHours of
wall-clock (default 4) since the last run, and at least minSessions sessions
(default 3). A lock file under ~/.sypi/memory/ with a 30-minute
stale-takeover TTL means two passes never overlap, and a crashed run self-heals.
The clustering call goes through the host's bounded query seam
(ctx.cheapQuery). Its output is UNTRUSTED. Before anything is written, it is
fence-stripped, JSON-parsed, and Typebox-validated. Cluster and member caps,
in-range fact indices, a one-merge limit per fact, and a merged length limit of
240 are enforced.
No data loss is invariant. A merged fact is written forward before its originals are forgotten, so a crash mid-merge leaves a harmless duplicate and never a hole. A "cluster" of fewer than two known facts is dropped, so a lone fact is never deleted. Cross-namespace clusters (project to global) are skipped.
Provenance (op:why)
Every write stamps a source on the fact: a trigger (manual for a tool add,
capture:shutdown or capture:before_compact for the boundary capture pass)
and the sessionId, which is the session file's basename, alongside the write
timestamp. op:why <id> reports that origin before a recalled fact is acted on.
It serves ask-never-assume and feeds /doctor introspection.
The importance and source columns are back-compatible. An older DB is
migrated with ALTER TABLE ADD COLUMN on open. Older rows read back with no
importance, treated as neutral, and unknown provenance. Nothing in the prior
schema breaks.
Failure memory (always on)
Alongside durable facts, the extension remembers durable mistakes. A
tool_result hook logs each tool call's normalized command and whether it
errored (isError). At the same session boundary the capture pass already
owns, it deterministically pairs each failed command with the fix that followed
(the next successful call of the same tool) and stores a terse lesson keyed by a
hash of the failing command in a distinct JSON store (failures.json,
project-namespaced). The vector facts are untouched.
When that exact command recurs in a later session, a tool_call hook injects
one short Reflexion-style hint through pi.sendMessage: "this repeats
X, which failed before; last time Y worked". It is deduped to once per
command per session and never blocks the call. A novel command injects nothing.
Standing cost is about zero. Detection is deterministic and uses no LLM. Each call normalizes the command, computes a sha256, and checks a Map. Every path is best-effort. A corrupt store is treated as empty rather than throwing.
Injection-time prompt-injection scan (always on)
Recalled text enters model context, so poisoned memory can act as a
prompt-injection vector. Every recalled entry from the per-turn context hook,
post-compaction re-search, or op:search is scanned by scan.ts just before
rendering, against a small and deliberately tight pattern list:
instruction-override, role-impersonation, exfil and C2 shapes such as
curl-pipe-sh and secret-carrying URLs, and unicode obfuscation (bidi controls,
zero-width smuggling, Latin/Cyrillic homoglyph mixing).
A hit is replaced by a [BLOCKED: <class>] placeholder and the event is
appended to ~/.sypi/memory/blocked.log. The on-disk store is never modified,
and the human-facing /memory surface still shows the raw note so it can be
inspected and forgotten.
The bias is conservative. The patterns may miss an exotic attack to avoid blocking a legitimate memory. Code snippets, URLs, documentation placeholders, and non-Latin prose therefore pass.
Testing and seams
The store and the embedder are dependency-injected, so the whole surface - tool,
hook, capture pipeline, dream consolidation, gate and lock lifecycle, real
cosine KNN, provenance and migration, failure pairing and recurrence hints - is
tested without a model download, using a pure lexical hashEmbedder and a fake
cheapQuery. The real model downloads once on first use at runtime.
The package imports only @synthryn/sypi-coding-agent/extension-api and
@synthryn/sypi-tui: the host's ctx.cheapQuery bounded-query host, the
ctx.readScopedSettings effective-profile reader, the
extensionStateDir/projectStateKey state paths, the
mountBoard/clampLines/getSelectListTheme UI helpers, and the tool-card
helpers. sqlite, vec0, and onnxruntime probing is owned by the package
(natives.ts). Runtime deps: fastembed, sqlite-vec.
Optional local embedder + reranker (research + gated scaffold)
Status: off by default, adds no new hard dependency. fastembed is "local" only after it downloads the bge-small ONNX model once from a CDN on first use; that initial fetch is the network dependency. A gated seam lets a fully-network-free local embedder be swapped in without sypi taking on a required dep, and without risking an 8GB machine's RAM. The seam is resolveEmbedder(opts?) in embed.ts: with no opts (the default) it returns the existing fastembed embedder and does zero extra work; with opts it tries a gated local path and falls back to fastembed on any miss. It never throws: an absent peer, an oversized model, a build error, or a wrong vector width all resolve to a clean fallback, never a crash and never a silent wrong-dimension write.
Two hard gates protect the ceiling. (1) RAM footprint: localEmbedderFits() requires the model's estimated resident size to be under an absolute cap (LOCAL_EMBEDDER_MAX_FOOTPRINT_BYTES = 512 MiB, so a large 1B+ embedding model is always rejected) and to have 2x its footprint free right now; the check is deliberately conservative because falling back is always safe. (2) Vector width: the local model must produce the same 384-dim vector as the on-disk store, or it is rejected (a different width would corrupt the sqlite-vec table). Availability is feature-detected: the peer is dynamically import()-ed inside a try/catch and its shape validated before use.
How to opt in (user's choice, not shipped). Set memory.localEmbedder in your profile to true (defaults: peer module sypi-local-embedder, 256 MiB footprint) or to { "module": "<specifier>", "footprintMb": <n> }. The named module must export createEmbedder(): Embedder (a tiny library-agnostic shim you provide, wrapping your chosen runtime). Absent the module, the import fails and the store keeps fastembed. The key is declared in the core profile schema and read defensively at runtime.
Compatible peer options for an 8GB machine (budget ~1-1.5 GB for the model layer):
- Same model, fully offline:
bge-small-en-v1.5(384-dim) via Transformers.js (@huggingface/transformers+onnxruntime-node). ~150-300 MB resident, 384-dim so the existing store is unchanged and the width gate passes. - Higher quality, needs a store migration:
nomic-embed-text-v1.5(768-dim, ~275 MB Q4) ormxbai-embed-large(1024-dim, ~670 MB) via node-llama-cpp (GGUF, Metal-accelerated). Their width isn't 384, so the seam rejects them until the store is rebuilt at the new dim. - Reranker: the dependency-free BM25 hybrid (
score.ts) is the default. A local cross-encoder (bge-reranker-base, ~280 MB, via Transformers.js) is a real quality gain but roughly doubles resident model memory alongside an embedder, so enable it only behind the same footprint gate and only as a small/int8 variant.
fastembed stays the default. It is already a local ONNX bge-small embedder: the only "network" step is the one-time model fetch on first use, after which it runs fully offline, so it needs no external app and no extra dependency. Ollama is ruled out on purpose: requiring users to install and run a separate app is a line this setup won't cross. The gated resolveEmbedder(opts) scaffold above ships no new dependency: it is strictly opt-in and does nothing unless a user wires their own peer module.
