Kotoba

Where to start

What Kotoba isWho she is and where she runs, in one page
InstallingOne package, two commands
First runA key, a model, her first words
The approval gateHow she asks before she acts
The two voice modesLocal voice, or the agent tunnel
The soul fileChange who she is
RoadmapWhat grows next, and what was cut on purpose

Or a section

↑↓ move openesc close124 pages
All pages

The agent6 of 15

Memory

About 8 minutes to read

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.

StoreWhereWhat it holds
Long-term facts~/.kotoba/memory/ — MarkdownDurable facts about you and your projects
Conversation logkotoba.db — SQLite + FTS5Every turn, searchable
Visual memory~/.kotoba/visual-memory/ — images + index.jsonImages 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:

  1. 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."

  2. 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:

"emotion and tools_used are 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 .html file 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.