The agent6 of 15
Memory
On this page
Five tools in the memory toolset, and three separate stores behind them. All three live on your
disk as files or rows you can open, edit and delete.
| Store | Where | What it holds |
|---|---|---|
| Long-term facts | ~/.kotoba/memory/ — Markdown | Durable facts about you and your projects |
| Conversation log | kotoba.db — SQLite + FTS5 | Every turn, searchable |
| Visual memory | ~/.kotoba/visual-memory/ — images + index.json | Images she chose to keep |
Every path is overridable: KOTOBA_MEMORY_DIR, KOTOBA_VISUAL_MEMORY_DIR, KOTOBA_HOME.
The database lands in ~/.kotoba/ on a new install. From a clone it lands there too — unless a
kotoba.db already sits beside the package in api/, in which case that one wins. That direction is
deliberate: preferring the home whenever a file happened to be there was measured switching a live
install onto a two-turn stray while 561 conversations and two saved keys went invisible. Moving to
the home is something you do on purpose, by moving the file.
The long-term store, on disk
~/.kotoba/memory/
USER.md the index: ## Recent + ## Topics
topics/
pets.md
work.md
…
USER.md is the index. ## Recent holds the last 25 facts and is the part that goes into every
prompt. ## Topics lists each topic file with its fact count. The detail lives in
topics/<slug>.md, one Markdown bullet per fact, free to grow without bloating the prompt.
The file says so itself, at the top: "You can edit any of these."
A topic slug is [a-z0-9-] only — no dots, no slashes — so a topic file can never escape topics/.
What actually reaches the prompt
Measured, on a store with two facts:
Has a dog named Luna [pets]
Has a cat named Luna [pets]
(This list is the 2 most recent of 2 facts, across 1 topics: pets (2).
Use memory_recall for anything not shown — it is saved, just not inline.)
Each recent fact is tagged with the topic file it lives in, and the pointer line says how many facts it stands for. Without the total it read as "this is everything", which is the opposite of true.
memory_write — save one fact
Takes: fact and an optional free-form topic (defaults to general).
Two rules, and they are enforced in code, not only asked for in the description:
- English only. Measured — writing
"Tiene un perro llamado Luna"returns:"NOT SAVED — memory is stored in ENGLISH; a fact saved in another language can never be matched or corrected later. Translate it into one short third-person English statement and call memory_write again."
- One atomic fact.
"Likes jasmine tea and the colour indigo"returns:"NOT SAVED — that reads as more than one fact, and a compound fact can never be corrected later without destroying its other half."
Nothing is translated or split automatically. The store once ended up with six contradictory favourite colours, one of them in Spanish and two with a second fact bolted on, and every one of them permanently uncorrectable.
Duplicates. De-duplication runs across all topics, not within one, because the model invents synonym topics and a per-topic check let the same fact pile up under each. A duplicate reply quotes the fact it matched, so she can never say "I already knew that" about a new value she just failed to save.
Corrections. A fact that supersedes an older one retires the old version and the reply states what it replaced and what still contradicts it — the caller needs both, or she says "I don't use the old one any more" about a fact still in the store and still read back.
Facts are capped at 400 characters and forced onto one line: multi-line text opens a stray #
section in USER.md and truncates the Recent scan, permanently dropping every later index entry.
Retiring is a permanent delete with no archive, so every judgement in this module is biased toward keeping.
memory_recall — read it back
Takes: query (search everything), or topic (read one), or neither (list the topics).
A search is keyword-based, accent-normalised and stemmed, ranked by how much of the query it covers. That is what makes recall work when the model guesses the wrong topic name.
Reading a topic returns that topic's own facts plus up to 4 related hits from other topics. It
used to short-circuit on a topic hit, which made the topic name the whole answer: on a real store,
57 of 79 topics held facts that a search on the same word finds under a different slug — so "where do
I live" was decided by which name she guessed. personal returned a stale Barcelona, location
returned Madrid, and neither branch could see the other.
Read-only and jailed to her own memory store, so it is companion-safe.
session_search — search past conversations
Takes: query. Runs SQLite FTS5 over the turns table.
Tokens of 4 characters or more become prefix queries, because Spanish inflects by suffix: latencia
found 0 turns while the word sat inside latencias in two of them. Below 4 characters the wildcard
costs far more than it buys — "la" matches 212 turns, "la" * matches 937.
Every token is quoted, so AND, NEAR, ( and * are literal strings and never FTS5 operators.
What is NOT remembered
This matters more than the list above.
Her history remembers words, not deeds. The turns table has an emotion column and a
tools_used column. Both are NULL in every row. Every insert_turn call site passes role and
content only, and nothing reads either column back. The schema says so in a comment beside them:
"
emotionandtools_usedare both DEAD: every insert_turn call site passes role and content only, so both are NULL in every row and no query reads either."
The practical consequence: when she searches her own history, she finds what she said, not what she did. Asked "did you already check that?", the record she reads back is her own previous answer. Dropping the columns would be a migration over live data for zero behaviour change, so they are documented rather than removed.
Nothing else survives the process. These are all in memory and gone on restart:
- a reminder that fired but was not voiced before the restart
- which MCP servers a session has activated
- the task list
- the log of screenshots taken this session
- one-time secrets typed into a masked box
- the report viewer and its PDF export (the report's
.htmlfile in your workspace does survive)
A reminder that comes due while nothing at all is listening is a different case: it is not delivered, not settled, and stays due. See Reminders.
The two image tools
remember_image — keep one forever
Takes: source, about (the entity — reuse the same name you would use in memory_write, so
facts and photos link up), note, kind in
person | self | product | post | place | scene | other.
source resolves: "attachment" (the image you just shared — the commonest case), a filename from
your Files, a data: URL, or an http(s) URL. The URL form is work mode only and
SSRF-guarded: a fetch inside a voice turn stalls the call.
The image is copied into her own store, so it persists independently of your Files.
A save is reported from the store's own answer, never from a search afterwards. The store's add()
returns None for five different reasons and only "exact duplicate" means the image was kept —
asking instead whether any entry existed under that name announced a too-big image as saved.
The schema is blunt that memory_write is not an alternative: it saves words and keeps no image.
recall_image — look at them again
Takes: query — a person's name, a product, any keyword.
The top matches are re-opened as images, so the vision model actually sees them. They also ride the
events channel as a recalled_image frame carrying only the keepsake's ID, never its bytes —
that queue also carries approval cards, and hundreds of KB per image do not belong in it.
The instruction it returns is written from what was actually attached, never from what was found. An entry whose bytes are gone still said "analyze the image(s)" and handed her, as the thing to analyse, her own old note — the obvious completion being "here he is, dark hair and glasses", said as if looking.
For a screenshot by filename rather than an entity, use view_capture. See
Core tools.
The background collector
Separate from all five tools, a lightweight pass runs after each reply. It reads your latest message and extracts durable facts on its own, without being asked and without adding latency to the turn.
Its rule is "only things still true in about seven days". Its prompt names four things it must never extract:
- something you asked her to do
- a passing reaction, a joke, a provocation, or anything said to get a rise out of her
- anything about what she or the conversation are doing right now
- an inference about your identity, health, beliefs or politics that you did not plainly state
It is fed what is already stored, so it can do the cross-language de-duplication the write-time keyword check cannot — otherwise "recuerda: me gusta X" gets a Spanish entry and an English one.
It can also update two things besides facts: your own name, and the companion's name if you unambiguously rename her. That second one is deliberately strict — mentioning a third party's name, greeting her by her current name, or complimenting a name all return null.
Unlike an explicit memory_write, the collector's candidates are screened for task narration
(filter_ephemeral), because it turns a whole conversation into candidates.
