A file-based memory with a SQLite vector index for KISS Sorcar agents

Prototype of the "memoryfield" pattern, plus a recall evaluation on 300 real past Sorcar tasks. Generated 2026-09-13. Code: src/kiss/core/memoryfield/; tests: src/kiss/tests/core/memoryfield/.

In one paragraph. The memory is a flat directory of short Markdown pages with a small YAML header, and one SQLite file next to them that caches an embedding per page. An agent gets six tools (search, pull, read, write, list, delete). On 300 pages built from real past tasks and 55 probe questions, semantic search put the right page first 62% of the time and in the top 3 85% of the time; if repeated tasks are counted as one topic (which is what a curated memory would contain), top-3 recall is 91% and top-5 is 95%. A plain keyword search (SQLite FTS5) reached 76% top-3, so the vector index earns its place, and the two combined do slightly better still. The whole thing is standard library plus PyYAML: no vector database, no native extension, no background service.

1. What was built

The design follows Cal Paterson's memoryfield specification: memory is a data format, not a pipeline. The canonical data is the pages; the index is a derived cache that can be deleted at any time and rebuilt from the pages.

memories/ (flat directory, git-friendly) carbon-fibre-woks.md --- title / uuid / summary / created / updated --- + Markdown body (<= 8 KB) postgres-agent-auth-flow.md one topic per page, cite sources worktree-merge-lessons.md agent- or human-written, editable in vim text-embedding-3-small.sqlite3 (cache) pages(filename PK, frontmatter JSON, mtime, sha256, embedding float32 BLOB) + meta(model_code) VectorIndex sync(): hash every page; embed only changed ones; drop rows of deleted pages search(q): embed q, cosine over all rows (math.sumprod), top-k above a score floor 300 pages x 1536 dims: ~19 ms scan MemoryTools memory_search(query, k) memory_pull(query, k) memory_read(name) memory_write(name, ...) memory_list() memory_delete(name) bound methods passed to KISSAgent.run(tools=...) + MEMORY_PROTOCOL prompt
Figure 1. Pages are the source of truth; the SQLite file is a per-model cache; the tools are what the agent sees. Every search first runs an incremental sync, so pages written by a human, another agent, or a git pull are searchable immediately.

Design choices

ChoiceWhy
Flat directory of Markdown pages with YAML frontmatter (title, uuid, summary, created, updated)Agents are already fluent in files and Markdown; the memory can be read with cat, edited in an editor, versioned with git, and carried between models. Filenames are restricted to [a-z0-9-] per the spec; symlinks, sub-directories and editor debris are ignored.
One SQLite file per embedding model, named after itIndexes from different models never mix; the file records its model_code and refuses to serve a different model. Deleting the file loses nothing.
Exhaustive cosine scan in Python (math.sumprod) instead of sqlite-vecThe spec itself notes a full scan is fine at memory scale, and it measured at 19 ms for 300 pages of 1536 dimensions. The macOS system Python cannot load SQLite extensions, so avoiding sqlite-vec keeps the prototype dependency-free and portable.
Incremental sync keyed by sha256Only new or edited pages are re-embedded. The 300-page corpus embedded once in 64 s for about one cent; subsequent syncs are a few milliseconds.
Embedding text-embedding-3-small through the framework's existing Model.get_embeddingNo new client code; any catalogued embedding model (Gemini, BGE via Together) works by name. An offline feature-hashing embedder is included as a fallback and as a baseline.
Embed the title, summary and body, capped at 8 KBTitle and summary act as "key expansion" for retrieval (the same trick LongMemEval found helpful); the cap matches the spec and the model's 8192-token input. (The prototype embedded the whole file including frontmatter; the volatile uuid and timestamps were later excluded because they made vectors nondeterministic and added noise.)

Using it from an agent

from kiss.core.memoryfield import MEMORY_PROTOCOL, MemoryTools
from kiss.core.kiss_agent import KISSAgent

tools = MemoryTools("~/.kiss/memories")          # default embedder: text-embedding-3-small
agent = KISSAgent("assistant")
agent.run(
    model_name="claude-fable-5-1",
    prompt_template="Set up the postgres channel agent for the staging database.",
    system_prompt=MEMORY_PROTOCOL + "\n" + your_system_prompt,
    tools=[*tools.tools(), *other_tools],
)

MEMORY_PROTOCOL is a nine-line instruction block: search memory before starting, record durable lessons (one topic per page, with sources) while working, never store secrets, delete pages that turn out wrong. In the live test, a Haiku agent given only these tools stored a fact in one session and a fresh agent recalled it by paraphrased question in the next.

2. How recall was measured

The question a memory has to answer is: given something the user asks later, does search surface the page that holds the answer? To test that on real material rather than toy pages, the evaluation script (kiss.core.memoryfield.evaluate) turns finished tasks from ~/.kiss/sorcar.db into memory pages, one per task, containing the request and the final result as plain text, and then fires probe questions at the index.

CorpusThe 300 most recent successful top-level tasks (results of at least 300 characters; failed, interrupted and stopped tasks excluded). Each page is capped at 8 KB. 64 of the 300 pages are repeats of another task in the corpus ("run all tests", "update the README", "run update_models.py", the same reviewer prompt on different papers).
Probes55 questions, each with exactly one gold page. 15 were written by hand to be phrased differently from the original request ("how do I put the car through an emissions readiness drive cycle" for the BMW 330i task). 40 were written by claude-fable-5-1, which was shown a sampled page and asked for the question a user might ask weeks later, without copying distinctive phrases or identifiers.
Retrieversvector: the memoryfield index with text-embedding-3-small. BM25: SQLite FTS5 with the Porter stemmer, query words OR-ed (the "just grep it" baseline). hashed: the same index with the offline feature-hashing embedder. hybrid: reciprocal-rank fusion of vector and BM25.
MetricsRecall@k: the gold page is within the top k. MRR: the mean of 1/rank of the gold page. Family recall additionally counts a hit when a page whose request text is identical (after case and whitespace normalisation) to the gold page's request is in the top k; that is what a curated memory would contain after merging repeated tasks into one page per topic.

3. Results

Strict recall of the exact past-task page (55 probes, 300 pages) 0.00 0.25 0.50 0.75 1.00 0.62 0.69 0.58 0.33 R@1 0.85 0.85 0.76 0.53 R@3 0.91 0.87 0.84 0.67 R@5 0.74 0.77 0.68 0.44 MRR vector (text-embedding-3-small) hybrid (vector + BM25, RRF) BM25 (SQLite FTS5) hashed bag-of-words (offline)
Figure 2. Strict recall and MRR of the exact gold page, all 55 probes, 300-page corpus.
Family recall: any page of the same repeated task counts (55 probes) 0.00 0.25 0.50 0.75 1.00 0.75 0.78 0.62 0.36 R@1 0.91 0.93 0.80 0.58 R@3 0.95 0.93 0.89 0.75 R@5 0.83 0.85 0.72 0.49 MRR vector (text-embedding-3-small) hybrid (vector + BM25, RRF) BM25 (SQLite FTS5) hashed bag-of-words (offline)
Figure 3. The same probes scored at the level of task families. The gap to Figure 2 is the cost of keeping repeated tasks as separate pages.
RetrieverR@1R@3R@5MRRFamily R@1Family R@3Family R@5Mean latency
vector (text-embedding-3-small)0.620.850.910.740.750.910.95223 ms
hybrid (vector + BM25, RRF)0.690.850.870.770.780.930.93224 ms
BM25 (SQLite FTS5)0.580.760.840.680.620.800.891 ms
hashed bag-of-words (offline)0.330.530.670.440.360.580.7513 ms

Vector latency is almost entirely the embedding API round trip; the local cosine scan over 300 pages takes 19 ms. The index file is 2.5 MB; the whole memory directory is 5.4 MB.

By probe source

ProbesRetrieverR@1R@3R@5MRR
15 hand-writtenvector0.801.001.000.90
BM250.731.001.000.86
hybrid0.871.001.000.93
40 LLM-paraphrasedvector0.550.800.880.68
BM250.530.680.780.61
hybrid0.620.800.820.71

The LLM-written probes are harder on purpose: they ask about a detail of the outcome ("were there actually any containers running to stop?", "which failures were test bugs versus real code bugs?") rather than restating the request, and they were sampled uniformly, so they land on repeated tasks in proportion to how common repeats are in the corpus.

The hand-written probes, one by one

Question askedPage it had to find (original request)vectorBM25hybrid
which memory architecture did we conclude is best for an AI agentwhat is the simplest and most powerful memory system that I can use wi111
how do I put the car through an emissions readiness drive cyclehow do I complete drive cycle on BMW 2017 330i in the easiest way?111
nonstick cookware brand comparison for safety and durabilityCompare SENSARTE with Carote, GreenPan, and Caraway on safety certific121
is that Chinese cookware brand safe and where is it madeis SENSARTE an american brand? Where do they manufacture? Are they s111
how to authenticate the google docs agentauthenticate gdocs111
implement the whatsapp channel agent like the other messaging agentscan you implement ./src/kiss/agents/third_party_agents/whatsapp_agent.111
add a green border around the settings panelcan you add a green border (using a theme color) around the settings p111
fix race conditions, hangs and deadlocks across the codebaseCan you precisely and thoroughly find and fix all race conditions, han111
why did the previous run stop responding and hang the web appWhy did the last task fail? It stopped responding and the remote weba212
show images from tool results inline in the event panelwhen an image is generated by you or is the result of a tool call, can121
publish website changes to the github pages repoPush the updated website content to the kisssorcar.github.io GitHub Pa111
task classifier should use structured output and not be agenticcan you make the KISSAgent("Task Classifier") non-agentic and use stru211
merge conflict helpCan you check the following message for a merge conflict and help me f131
rename SEA methods to drop the get_ prefixcan you remove the get_ prefix from all methods in SEAs? also add the111
MCP servers for brave search, notion, postgres, firecrawl and google workspaceImplement the brave-search, notion, postgres, firecrawl, github, and G222

Numbers are the rank of the gold page in each retriever's top 5.

Where it misses, and why

Where the gold page landed (vector retriever) 34 rank 1 11 rank 2 2 rank 3 3 rank 4-5 5 not in top 5
Figure 4. Rank of the gold page for the vector retriever over all 55 probes.

Of the 21 probes where the vector retriever did not put the gold page first, seven had a page from the same repeated task on top (for example "What changed the last time we refreshed the model catalog?" matched a different run of update_models.py, and both "run all tests" probes matched a sibling run). No retriever can separate those from the question alone; the fix belongs in the memory, not the index: an agent following the protocol updates one "model catalog refresh" page instead of leaving four run logs. Three probes were missed by every retriever (no family page in the top 5): one about a git branch, phrased very differently from the request ("which older commit did I base that clean fallback branch on"), one asking what was outdated in a README sync when several README pages compete, and one asking about a code review's conclusion in vocabulary the review itself did not use. Keyword search ranked first in a few cases where the embedding did not (the install.sh walkthrough: rank 1 by BM25, rank 5 by vector), which is why the hybrid edges ahead at rank 1.

4. Independent review and what it changed

After the implementation and tests were complete, a second model (gpt-5.6-sol) did a strictly read-only review of the five source files and four test files, with instructions to report only verified problems. It reproduced 15 defects, none of which the 44 tests at that point had caught. All were fixed and each now has a regression test; the suite is 51 tests.

SeverityFindingFix
Highwith sqlite3.connect(...) as conn commits but does not close; every search leaked a file descriptor until garbage collection.All connections wrapped in contextlib.closing; tests run with ResourceWarning as an error.
HighOpening the index ran an INSERT OR IGNORE, so even a search held a write transaction; sync() then held that lock across every embedding API call.Schema setup commits immediately; sync() computes embeddings with no transaction open and writes all rows in one short transaction; 30 s busy timeout.
HighIf the index filename was a symlink, sync wrote tables into whatever database it pointed at.A symlinked index path is refused.
HighThe evaluation reused a stale page directory when --limit or the database changed, and then crashed on a missing gold page.The corpus is reconciled exactly on every run: missing pages added, extra pages removed, existing pages untouched.
MediumTwo model names that sanitise to the same filename (provider/a:b, provider/a/b) silently shared one index.The stored meta.model_code is checked on every open and a mismatch raises.
MediumA symlinked page inside the directory was listed, and deleting the alias deleted its target.Symlinked entries are neither listed nor readable, writable or deletable.
Mediummemory_pull only enforced its 24,000-character cap from the second hit on; one large page produced a 60 KB tool result.An oversized first hit is truncated with a pointer to memory_read.
MediumSync decoded invalid UTF-8 with replacement but reading used strict decoding, so such a page appeared in search and then crashed memory_pull.One tolerant decoder is used everywhere.
MediumA page edited while its embedding was being computed was stored with the old vector and the old hash.The file is re-hashed after embedding; if it changed it is left for the next sync.
MediumThe cached LLM probes were reused regardless of probe count, seed, model or corpus.The cache records those four values and is regenerated on any mismatch.
MediumTask families were derived from the 48-character filename slug, so unrelated long requests with a shared prefix counted as duplicates.Families come from the full normalised request text (64 repeated pages, down from 100 under the slug rule).
LowHybrid latency included the unrelated hashed search and excluded fusion.Timed as vector + BM25 + fusion.
LowTop-level tasks with NULL parent_task_id were skipped.Uses Sorcar's own predicate, IS NULL OR = ''.
LowFrontmatter supplied inside a page body could overwrite the stored uuid and created on update.Identity keys are protected on update (still honoured on create, for imports).
LowThe read-only database URI broke on paths containing ? or #.Built with Path.as_uri().

The review also confirmed what was right: every exported name exists, the JSON schemas generated for the six tools have correct required fields and integer typing for k, plain ../ traversal is rejected, and the sha256-based add/update/remove logic behaves as documented.

5. Verification

6. What this does and does not show

The evaluation says the retrieval layer is good enough to build on: for a question that a user would plausibly ask about past work, the right page is in the top three about five times out of six, and in the top five 19 times out of 20 once repeated tasks are merged. It does not measure whether an agent writes good pages; the corpus here was generated mechanically from task logs, which are longer and noisier than the distilled notes the protocol asks for. Better pages should make retrieval easier, not harder.

Two limits are worth stating plainly. First, the corpus is 300 pages; the exhaustive scan scales linearly, so at tens of thousands of pages the 19 ms becomes a second and an approximate index (sqlite-vec's vec0, or pgvector) would be the next step. Second, search quality is bounded by the embedding model; the offline hashed embedder is a fallback for machines without an API key, not a substitute (R@3 0.53 versus 0.85).

Reproducing the numbers

uv run python -m kiss.core.memoryfield.evaluate --limit 300 --llm-probes 40
# offline variant, no API keys needed:
uv run python -m kiss.core.memoryfield.evaluate --limit 300 --llm-probes 0 --embedding-model hashed-bow-v1
uv run pytest src/kiss/tests/core/memoryfield -q

Pages, indexes, probes and the per-probe JSON are written under ./tmp/memoryfield-eval/; the memory built from task history contains private task text and is deliberately not committed.

7. Sources