Contents TandemLLM Docs
v0.14.2
Star58

Part IV, The conductor

26Memory notes

The model can keep notes about the user and the work, as files, and read them back in later turns and sessions. One note is one Markdown file with a frontmatter: one fact, a title of at most 120 characters, a body of at most 400 words. The files are the truth, and the dashboard’s Memory screen, Notes tab, reads and edits them. Memory notes are on in the shipped chat, coding, hermes and chat-app profiles (SRV-471) and off in the others; this chapter says what they do.

26.1 Where the files are

text
~/.qwen38-spark-engine/memory/notes/<user>/
  INDEX.md                               generated each night: one line per live note
  user/<kind>-<slug>--<id>.md            about the person
  projects/<name>-<hash8>/<kind>-...md   about one working directory
  sessions/<session>/<kind>-...md        true for one conversation only
  revisions/<id>/<n>.md                  every earlier text of a note
  archive/<yyyy-mm>/...                  archived notes, deleted after 30 days
  usage.json                             how often each note went into a request

<user> is one fixed name for the engine’s own requests. A request the proxy relayed carries the proxy user’s name (the key’s label, or X-Tandem-User from a trusted address); a relayed request without one reads and writes nothing. Files are mode 0600, and the store refuses a root inside a git work tree.

TandemLLM serves one user: every client of the engine is that user, so a “remember this” from any client is a note in the same store, shown as the line › remembered: <title> and archived or deleted in the Notes tab. A request that must not touch the notes sends X-Tandem-Memory: off (nothing read, nothing written, no card; the journal says why) or read (the reads only; a block the model writes anyway is dropped). Tests and scripts against a live engine send off.

A note has one of eight kinds: rule (how the user wants things done), ruling (a decision), gotcha (what went wrong once), runbook, program_state (with a verify_by date, stale after it and never injected), reference, registry and archive. And one of three scopes: the user, a project (one working directory), or a session.

26.2 How the model writes

The model writes a block in its answer, usually after its text:

text
<tandem_memory op="add" scope="project" kind="gotcha">
title: the build needs node 20
tags: build

npm run build fails on node 18 with "Unexpected token 'with'".
</tandem_memory>

op is add, update (with an id) or archive (with an id and a reason). The server holds the block back from the client the way it holds a tool call (server/memblock.py), writes the note, and the client sees one status line, “› remembered: the build needs node 20”, in the content after the answer whatever the request’s reasoning_format (opencode shows it as a reasoning part’s title), so Open WebUI and the OpenAI SDK show the write too; the server takes the line out of the history the client sends back. A block that is not taken goes out as the text it was: one that never closed, one over 2,000 characters, the fifth of an answer, or one the store refused. A block inside the reasoning is left alone. The block’s tokens are recorded as hidden spans, so the next turn’s prefix still matches the rows.

The rules are code, not prompt text (server/conductor/notes.py):

  • A write over a cap is refused, never cut: title 120 characters, body 400 words, 20 notes a session, 500 live notes a user.
  • Every title and body passes the secret-pattern refusal of the transcripts, and a body that is mostly a secret is refused.
  • A rule or ruling about the user needs the user’s own message in the turn: a tool turn or a script cannot write one.
  • The model writes provisional notes. Only the dashboard ratifies, pins or deletes a note, and a model’s revision of a ratified note makes it provisional again. The model’s archive of one does the same: the note stays, with the model’s reason in its changelog, until the user archives it or ratifies it again.
  • Before an add lands, the same scope is searched; the same normalised title, or a close BM25 match, turns the add into a revision of that note.

The model’s scripts have the same write as a route, under the session’s address only: POST {tandem_url}/v1/memory, with search and read beside it. Without a known session token the routes answer 403. Figure 26.1 follows a note from its write to its archive.

Figure 26.1 The life of a memory note. Three ways in, one set of checks, a provisional note that a person may ratify or pin, reads back into later turns, and an archive.

26.3 How the model reads

A profile’s memory switch is off (the default: the request is the release’s), shadow (the card goes in and blocks are written, nothing is read back) or on. With on, three tail notes, each replayed at its place on later turns so each costs its prefill once:

  • the memory card, about 150 tokens: the block’s shape and when to write one;
  • the index, on the first turn of a session and again after a compaction: the pinned ratified rules, then one line per note the request may see, within 1,500 tokens;
  • the matching notes, on a new user message (never a tool turn): BM25 over the message, at most 3 notes that share at least 2 of its words, within 600 tokens. On a session’s first turn they go in after the index. Their note has its own frame: the other hidden notes say “do not mention it”, but these are the user’s notes, so the frame and its head tell the model to answer from a note that answers the message, to say it comes from its notes (a ratified note is confirmed by the user), and to tell the user both when the files disagree.

26.4 The background writer

Besides the blocks in its answers, a session’s facts can be written by a hidden turn of the model itself, thinking off, on the conversation the engine already holds (server/conductor/memwriter.py). Its switch is settings.memory.notes.writer.mode: off (the default), shadow (the turn runs, its blocks are counted and logged to a shadow file, nothing is stored) or on.

  • Triggers: the session’s end (no request for 180 s and at least 1,500 new tokens since its last pass), a give-up, and a compaction.
  • The record: the session’s last generation (its end anchor), else the last prompt in the rows up to its last message’s end.
  • The gate: Laya’s question “do these messages contain something to remember” on the session’s last user messages; a confident no skips the turn.
  • The turn’s prompt carries the notes that already match the session, so it updates instead of adding. Its output is parsed as the inline blocks are, and notes land provisional.
  • The tool-call token gets a logit bias of -100-100 in the writer’s turn (ban_tool_call, on), so the turn cannot start a call; any other call it writes anyway ends the turn at its function’s name, and the run records that it wrote nothing.
  • A request that arrives stops the turn at its next chunk; the conversation’s rows and anchors stay as they were.

Measured on a test engine (the 0.4.0 series, a synthetic 10-turn coding session, low effort, writer on): the writer at the session’s end wrote 2 notes in 7.6 s for 282 tokens from a 5,374-token record. A new session that asked what was remembered got 1,781 prompt tokens with memory on (the index and 3 notes) against 136 off, and its first token in 0.97 s against 0.39 s; the answer named both rules the user had asked to keep. Decode was 99.7 ms a block with memory on and 99.6 off (measured).

26.5 On the second model

Nothing in the notes depends on which model serves: they reach the model as prompt text, and the server parses the blocks it writes. In front of Qwen3.8-Flash-Next (Chapter 17) the same code runs. Nobody has checked yet whether Flash-Next writes useful blocks, because no session with memory notes on has run against it. Its profiles keep the notes off, like every shipped profile.

26.6 Behind the proxy

For a backend behind the proxy (vLLM, SGLang, any OpenAI-compatible server, a cloud API), the writer’s turn is one more request to the backend the session used (proxy/memwriter.py): the messages of the session’s last request as they were sent, so the backend’s prefix cache matches, its answer, and the writer’s user turn. Thinking is off, and the session’s tools stay in the body with tool_choice: "none". The answer goes through the engine writer’s own parser and store, and the request is a ledger row of the client harness:writer.

It runs in the backend’s idle time: one writer request at a time, only while the backend has no client request in flight; on a local backend a client request that arrives closes the writer’s stream. Per backend: writer auto (on, but off on a paid cloud model), off, shadow or on; a spend cap a day on a paid model (0.25 USD, checked against an estimate before each run); 48 runs a day; a prompt of at most 32,000 estimated tokens. A paid model without a price is refused. Measured on 2026-10-05 with Qwen3.5-0.8B behind vLLM and SGLang, one run each: the writer started nothing while a client request was in flight, and with an answer prefill it wrote 2 notes on vLLM and 1 on SGLang (measured).

26.7 The Notes tab

The Memory screen’s Notes tab shows the tree by scope, the list with each note’s uses in 7 days, a note as a file (edit as a ratified revision, ratify, reject, archive, pin a ratified rule, delete), its history as diffs, the search with scores, the requests the notes went into, the writes and refusals with their reasons, the writer’s runs, the notes settings, an export as a zip, and the user’s delete behind a typed confirmation. Its routes are /v1/conductor/memory/notes..., under the admin rule for reads too: open while no admin password is set.

What is journaled: each write (operation, id, scope, kind, why it was refused, redaction counts), each injection (the note ids), the dashboard’s edits and the nightly job. Never a title or a body.