RiskRails 3.1.0
Storage

Git keeps the record. SQLite keeps it quick.

A closed task leaves its who, what, how and why as plain JSON lines in the repository. An optional SQLite file on your machine indexes those lines so an agent can recall them fast. The index is derived: delete it and the next read builds it again. It is never the only copy of anything.

Who owns what

One authority per piece of data. Never two.

There are never two independently edited copies. The task in flight lives in the task state; a closed task lives in the record in git; the index only ever holds a copy it can rebuild.

RR301 · memoryfiles mode is the default; packages mode is state.py memory enable, yours
7.1Authority per mode
DataFiles modePackages modeSQLite index
The task in flightstate.json, .ai/reports/<id>/the same, unchangednever
A closed task.ai/reports/<id>/, committedentries in .ai/memory/*.jsonl, in gitderived only
Logs and the archived state.ai/state/logs/<id>/, localthe samederived only
Risks.ai/project/known-risks.mdrisk entriesderived only

The index file is .ai/local/memory/index.sqlite3, inside the git-ignored .ai/local/. The pre-push check refuses a push whose commits carry any SQLite file under .ai/.

How it flows

Refresh on every read. Fall back on any error.

Before it answers, the index compares the size and modification time of every source with what it imported last. Only a changed source is hashed and imported again, all in one transaction. If the index is busy or broken, recall and search read the files directly and say so in their header.

Three sources in git feed refresh, which writes index.sqlite3; recall and search read the index, or scan the files when the index is busy or broken. .ai/memory/*.jsonlpackages · in git .ai/reports/T-*legacy tasks · in git known-risks.mdfiles mode risks refresh()size + mtime_ns per sourcechanged → BEGIN IMMEDIATE index.sqlite3.ai/local/ · git-ignoredWAL · busy_timeout 5000FTS5 when SQLite has ituser_version 1 recall · memory searchsame items either way busy or broken index: scan the files
Layout change

Rebuild

A user_version other than the one the code knows deletes the file and builds it again from the sources.

A source disappeared

Rebuild

Its entries may also belong to another package, so the whole index is rebuilt rather than patched.

An import failed

Stale

The index is marked stale. The next read refreshes it, or falls back to the files.

No FTS5 in this SQLite

Scan

Search scans every entry. The results are identical, only slower, and memory status says so.

Why SQLite

Already on every machine that runs python3.

The plugin uses the Python standard library only, offline, with no account, server or external model. Those constraints chose SQLite before any feature did.

No dependency

sqlite3 ships with Python. Nothing to install, nothing to pin, nothing that can be missing on a teammate's laptop.

One local file

The database and its WAL files sit in .ai/local/, outside git. Each developer's index is their own choice and their own copy.

Safe under concurrency

WAL lets readers work while one writer imports. Writers take BEGIN IMMEDIATE, so a crash leaves the old index or the new one, never half of each.

Full-text search

FTS5 is detected at run time. It only prefilters candidates; the same Python matcher decides in both backends, so results never differ.

Off the hot path

No per-tool-call hook imports the memory code or opens SQLite. The guard's latency budget is untouched.

A clone rebuilds it

A fresh clone builds the index from the packages alone, and shows every task exactly as the clone that exported it.

RR301 · design recorddocs/sdlc/specs/compact-project-memory.md
7.3Designs rejected for the record
DesignWhy not
SQLite as the record, committedBinary files do not merge; two writable copies would drift.
One JSON line per taskA 20–60 KB line is unreviewable in a PR, and loss becomes all or nothing.
Markdown as the recordReadable, but a reviewer's edit can silently break the parse of verbatim answers.
JSONL plus a committed rendered .mdTwo copies that drift, and twice the files.
git notesNot fetched by default, hidden by hosting UIs, and a plain clone would lose the history.
An orphan branchNeeds a second push, is not reviewed with the PR, is absent from a default clone.
A vector or graph databaseA non-goal: recall must be deterministic and work offline with the standard library.

What was chosen instead: one canonical JSON object per line, ordered, with a seal per task carrying the digest of its entries. Each branch appends to its own batch, so a merge only adds files.

Schema

Five tables. One virtual.

packages remembers each source's signature. tasks and entries hold what the record says. entry_files answers "what touched this path", which is what recall asks first. conflicts keeps two seals of one task side by side; nothing is merged silently.

-- .ai/local/memory/index.sqlite3 · user_version 1
PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;

CREATE TABLE packages(path TEXT PRIMARY KEY, size, mtime_ns, sha256,
                      author, period, entries);
CREATE TABLE tasks(key TEXT PRIMARY KEY, task_id, started_at,
                   closed_at, result, mode, w, workflow, goal, seal_rid);
CREATE TABLE entries(rid TEXT PRIMARY KEY, key, seq, kind, ts,
                     actor, by, runtime, status, superseded,
                     text, json, package, line, origin);
CREATE TABLE entry_files(rid REFERENCES entries ON DELETE CASCADE,
                         path, own, PRIMARY KEY(rid, path));
CREATE TABLE conflicts(key, rid_a, rid_b, reason);
CREATE TABLE meta(key TEXT PRIMARY KEY, value);  -- stale, fts5, keep_rules

-- only when this SQLite has FTS5
CREATE VIRTUAL TABLE entries_fts USING fts5(text,
  content='entries', tokenize='unicode61 remove_diacritics 0');
Measured

The files are fast enough. The index is faster.

The index was to be built only if recall over the files passed 300 ms, or search passed 1 s, at 1 000 tasks. The files stayed under both. It ships anyway, opt-in, by the developer's decision of 28 October 2026, for projects that keep years of history.

RR301 · tools/benchmark-memory.pyseed 1 · 4 KB journals · packages layout
filesSQLite index

1 000 tasks, in ms

recall · files129
recall · index65
search · files118
search · index24

10 000 tasks, in seconds

recall · files1.4
recall · index0.73
search · files1.3
search · index0.31
RR301 · baselinethe full table
7.5Packages layout
TasksRecall, filesSearch, filesIndex buildRecall, indexSearch, index
10012 ms11 ms24 ms6.5 ms2.8 ms
1 000129 ms118 ms245 ms65 ms24 ms
10 0001.4 s1.3 s2.8 s0.73 s0.31 s

Storage at 10 000 tasks: 25 MB of packages in git, plus 73 MB of local index. Recall output stays within its token budget at every size. The legacy one-directory-per-task layout is slower still: 1.6 s for recall at 10 000.

Turn it on

Yours to switch, in your terminal.

Packages mode and the index are switched on by the human route: run from an agent, enable exits 5. The mode is per project; the author and the index are per developer, in the git-ignored .ai/local/memory/config.json.

Limits: the FTS5 prefilter is used for ASCII query tokens only, other queries scan. Without fcntl, two closes in one clone are last-writer-wins on the batch. Recall estimates tokens; it does not run a tokenizer.

# packages mode, with the local index
state.py memory enable --period quarter --index sqlite
git add .ai/policies/memory.json
git commit -m "memory: packages mode"

# what an agent asks before it edits
state.py recall --files "src/Payment/**" --budget 1500
state.py memory search "rounding" --kind risk

# the index is disposable
state.py memory rebuild
state.py memory status   # index, fts5, packages, pre-push