affordance

Showcase  /  Built with Affordance

We built OptMem, a long-term memory for AI agents, as a small Affordance app

A chat model starts every conversation knowing nothing about the last one. OptMem is a design by Victor Taelin that fixes this with notes. The model writes down what matters as one-line notes, and it reads them back when the next conversation starts.

We wanted to know how much work it is to build a design like OptMem on Affordance. It came to two files, 116 lines together. Then we tested it. After 16 conversations about a made-up project, 8 new conversations each got one question about it, and all 8 answers were right.

The problem: a new conversation starts empty

A model only knows what is in the text it is given. That text is called the prompt. When a conversation ends and a new one starts, the new prompt does not contain the old conversation, so the model has forgotten it.

You could paste every old conversation into every new prompt. Then the prompt grows every day. The model gets slower and costs more to run, and one day the text no longer fits.

OptMem's answer: one-line notes, and summaries of the older ones

While it works, the model can save a fact as a one-line note. Notes are only ever added. When a fact changes, the model adds a new note and the old one stays where it is.

To keep the notes short enough to read, every two notes get a one-line summary, which the model writes. Every two summaries get a summary of their own, and so on upward. Here are four notes from our test, with the summaries the model wrote above them:

summary of the two summaries
Kestrel: API 120 req/min per key; on-call weekly, handoff Mon 10:00 UTC; ingest worker Rust on NATS, max message 2 MB.
summaries
Kestrel API limit: 120 req/min per key; on-call rotation weekly, handoff Monday 10:00 UTC.
Kestrel ingest worker: Rust; queue NATS; NATS max message size 2 MB.
notes
Kestrel API rate limit: 120 requests per minute per key.
Kestrel on-call rotation is weekly, handoff Monday 10:00 UTC.
Kestrel ingest worker language: Rust; queue is NATS.
Kestrel NATS maximum message size: 2 MB.

A new conversation starts with a short view of all this: the newest notes in full, and the older ones as summaries. If the model needs a detail that a summary left out, it can search every note, or open a summary to see the two lines under it.

What it took to build in Affordance

An Affordance app is a file that says what the model reads and what it is allowed to do. Affordance does the rest: it calls the model, keeps the app's data between conversations, and runs the app's code when the model acts. So the app only has to describe OptMem itself. It has four parts.

Keeping the notes. The notes are a list and the summaries are a table. useState keeps a value for as long as the app runs, which is longer than any one conversation. Adding a note appends it to the list.

memory.tsxlines 35 to 36, line 41
const [log, setLog] = useState([], "log");
const [tree, setTree] = useState({}, "tree");
...const note = (text) => { setLog(l => [...l, String(text).slice(0, chars)]); };

Letting the model write a note. In Affordance, something the model is allowed to do is called a tool. This is the note tool: its name, a description the model reads, the shape of its input, and the function that runs when the model uses it.

session.tsxlines 24 to 26
<aff tool_name="note" description="Record one memory: one line, at most 280 bytes."
     args_schema={{type: "object", properties: {text: {type: "string"}}, required: ["text"]}}
     onNote={({ text }) => { onNote(text); return `noted as #${memories.length}`; }}>{"[note]\n"}</aff>

Showing the notes to a new conversation. This function builds the short view. A recent note is shown in full, as #7 text. A block of older notes is shown as its summary, as #0-3 text.

memory.tsxlines 28 to 32
export function wakeText(log, tree, lines) {
  if (log.length == 0) return "(no memories yet)";
  return cover(log.length, lines).map(([lo, hi]) =>
    hi - lo == 1 ? `#${lo} ${log[lo]}` : `#${lo}-${hi - 1} ${tree[`${lo}-${hi}`] ?? "(summary pending)"}`).join("\n");
}

Writing a summary. When two notes are ready to be summarized, the app asks the model for one line that covers both. model(...) is how an app calls the model. The reply goes into the table of summaries.

memory.tsxlines 53 to 54
const r = await model(<Compress template={template} lo={lo} hi={hi} chars={chars} body={body}/>);
setTree(t => ({...t, [`${lo}-${hi}`]: r.text.trim().split("\n")[0].slice(0, chars)}));

The search tool and the tool that opens a summary are another 17 lines. Here are both files in full:

memory.tsx, the whole file (66 lines)
// Permanent memory for agents, after OptMem (github.com/VictorTaelin/OptMem), as an instance tree.
//
// The memory is this root's state: an append-only log of one-line memories and a binary tree of
// one-line summaries over it (block [lo, hi) summarizes its two halves). Sessions are its children,
// one at a time; each wakes with the memory's cover (recent memories verbatim, older ones collapsed
// into tree nodes, `views/strategies/context.tsx` `cover`, the same tiling as OptMem's) and can
// recall, zoom and note. Differences from OptMem, which the harness makes possible:
//   - Compression happens here, as a model call (a model() call) when a note
//     makes a block complete. A session never stops its work to "nap".
//   - Who may write memory is structural, not a prompt rule: a session writes through the `onNote`
//     callback this root passes it. A helper it mounts gets no `onNote`, so it cannot write.
//   - The memory outlives sessions because the root does: a session's stop (`onStop`) mounts the
//     next, and a crash resumes the whole tree from its journals.
import { TurnStrategy } from "../../strategies/turn_strategy.tsx";
import { cover, pendingBlocks } from "../../strategies/context.tsx";
import { MessagesApp } from "../../template_apps/messages.tsx";
import { GLM53App } from "../../template_apps/glm53.tsx";
import { Session } from "./session.tsx";

// One line in place of a block of memories.
function Compress({ template, lo, hi, body, chars }) {
  const text = `Summarize memories #${lo}-${hi - 1} in one line of at most ${chars} bytes.\nKeep what will still matter later and leave out the rest. Add nothing that is not in them.\n\n${body}\n\nReply with the line only.`;
  if (template == "glm53") return <GLM53App system="You maintain a memory."><TurnStrategy strategy="unrolled" log={[]}>{text}</TurnStrategy></GLM53App>;
  return <MessagesApp system="You maintain a memory."><TurnStrategy strategy="unrolled" log={[]}>{text}</TurnStrategy></MessagesApp>;
}

// What `wake` prints: the cover of the log, one line per block, finest near now.
export function wakeText(log, tree, lines) {
  if (log.length == 0) return "(no memories yet)";
  return cover(log.length, lines).map(([lo, hi]) =>
    hi - lo == 1 ? `#${lo} ${log[lo]}` : `#${lo}-${hi - 1} ${tree[`${lo}-${hi}`] ?? "(summary pending)"}`).join("\n");
}

export default function Memory({ sessions, template = "messages", wake_lines = 96, chars = 280 }) {
  const [log, setLog] = useState([], "log");
  const [tree, setTree] = useState({}, "tree");
  const [current, setCurrent] = useState(0, "current");
  // A note is appended at once (a functional update: notes from sessions running side by side all
  // land). Compression is its own step: while a block of notes is complete and unsummarized,
  // summarize the smallest one from its two halves, one at a time.
  const note = (text) => { setLog(l => [...l, String(text).slice(0, chars)]); };
  const [busy, setBusy] = useState(false, "compressing");
  const pending = pendingBlocks(log.length, tree);
  useEffect(() => {
    if (busy || !pending.length) return;
    const [lo, hi] = pending[0];
    const mid = (lo + hi) / 2;
    const half = (a, b) => b - a == 1 ? `  #${a} ${log[a]}` : `  #${a}-${b - 1} ${tree[`${a}-${b}`]}`;
    const body = half(lo, mid) + "\n" + half(mid, hi);
    setBusy(true);
    const compress = async () => {
      try {
        const r = await model(<Compress template={template} lo={lo} hi={hi} chars={chars} body={body}/>);
        setTree(t => ({...t, [`${lo}-${hi}`]: r.text.trim().split("\n")[0].slice(0, chars)}));
      } finally {
        setBusy(false);
      }
    };
    compress();
  }, [busy, log.length, Object.keys(tree).length]);
  if (current >= sessions.length) return "";
  return <Mount key={`session-${current}`} model="default">
    <Session task={sessions[current]} template={template} wake={wakeText(log, tree, wake_lines)} memories={log} tree={tree}
             onNote={note} onStop={() => { setCurrent(c => c + 1); }}/>
  </Mount>;
}
session.tsx, the whole file (50 lines)
import { useLog } from "../../lib/log.tsx";
// One session of an agent with permanent memory (`memory.tsx` is its parent). It wakes with the
// memory's cover, works on its task, records what has lasting effect (`note`), and can search
// every memory (`recall`) or open a summary into its two halves (`zoom`). `answer` ends it.
import { TurnStrategy } from "../../strategies/turn_strategy.tsx";
import { useLoop } from "../../lib/loop.tsx";
import { MessagesApp } from "../../template_apps/messages.tsx";
import { GLM53App } from "../../template_apps/glm53.tsx";

const SYSTEM = "You are an assistant with a permanent memory that outlives this session. Your memory is shown at the start, as lines `#i text` (one memory) and `#a-b text` (a summary of memories a..b, oldest collapsed most). Record anything of lasting effect with `note` (one line each, no duplicates). To find an old memory, `recall` searches every memory word for word and `zoom` opens a summary into its two halves. Finish with `answer`.";

function Context({ template, log, children }) {
  if (template == "glm53") return <GLM53App system={SYSTEM}><TurnStrategy strategy="unrolled" log={log}>{children}</TurnStrategy></GLM53App>;
  return <MessagesApp system={SYSTEM}><TurnStrategy strategy="unrolled" log={log.map(r => ({...r, ids: null}))}>{children}</TurnStrategy></MessagesApp>;
}

export function Session({ task, template = "messages", wake, memories, tree, onNote }) {
  const log = useLog();
  useLoop(log);
  // The session wakes once: later notes arrive as props but stay out of its first message, so each
  // prompt is an exact append of the last. It sees its own notes as its tool calls.
  const [woke] = useState(wake, "woke");
  const app = <>
    <aff tool_name="note" description="Record one memory: one line, at most 280 bytes."
         args_schema={{type: "object", properties: {text: {type: "string"}}, required: ["text"]}}
         onNote={({ text }) => { onNote(text); return `noted as #${memories.length}`; }}>{"[note]\n"}</aff>
    <aff tool_name="recall" description="Search every memory ever recorded, word for word (a regular expression, case-insensitive)."
         args_schema={{type: "object", properties: {pattern: {type: "string"}}, required: ["pattern"]}}
         onRecall={({ pattern }) => {
           const re = new RegExp(pattern, "i");
           const hits = [];
           for (const [i, m] of memories.entries()) { if (re.test(m)) hits.push(`#${i} ${m}`); }
           return hits.length ? hits.slice(0, 20).join("\n") : `no memory matches ${pattern}`;
         }}>{"[recall]\n"}</aff>
    <aff tool_name="zoom" description="Open a summary #a-b into its two halves (down to the memories themselves)."
         args_schema={{type: "object", properties: {lo: {type: "integer"}, hi: {type: "integer"}}, required: ["lo", "hi"]}}
         onZoom={({ lo, hi }) => {
           const end = hi + 1;
           if (end - lo < 2) return `#${lo} ${memories[lo] ?? "(none)"}`;
           const mid = Math.trunc((lo + end) / 2);
           const half = (a, b) => b - a == 1 ? `#${a} ${memories[a]}` : `#${a}-${b - 1} ${tree[`${a}-${b}`] ?? "(no summary)"}`;
           return half(lo, mid) + "\n" + half(mid, end);
         }}>{"[zoom]\n"}</aff>
    <aff tool_name="answer" description="Give your final answer for this session and end it."
         args_schema={{type: "object", properties: {text: {type: "string"}}, required: ["text"]}}
         onAnswer={({ text }) => { harness.stop(text); return "done"; }}>{"[answer]\n"}</aff>
  </>;
  const turns = [{kind: "input", content: `## Your memory\n${woke}\n\n## Task\n${task}`}, ...log.records];
  return <Context template={template} log={turns}>{app}</Context>;
}

The test: a project diary, then eight questions

We made up a software project and wrote 16 days of diary for it: who leads it, its rate limits, two incidents, a customer, a budget. Each day went to the model in its own conversation. On some days an earlier fact changes. Deploys move from Tuesday to Thursday, for example.

Then we started 8 more conversations and asked one question in each, the kind a new teammate would ask. No conversation could see any earlier one. Each started with at most 8 lines of notes and summaries.

QuestionHow the model's answer beganHad the fact changed?
On which day of the week do deploys happen now?ThursdayYes
What is Northwind's rate limit, in requests per minute?Northwind's rate limit is 300 requests per minuteYes
Who is the acting lead while Mara Okafor is on leave?Jun Park is the acting lead while Mara Okafor is on leaveNo
What is the maximum message size on the queue now?8 MB per message.Yes
Which ticket tracks the fix for tokens in the logs?KES-212No
For how many days are backups retained?Backups are retained for 35 daysNo
Who mentors Tomas Lind?Devi Rao mentors Tomas Lind.No
What caused the disk to fill on db-stg-2?According to my notes, on day 4 of the Kestrel project the disk on db-stg-2No

All 8 answers were right. For the three facts that had changed during the diary, the model gave the newer value.

How much the model had to read

Prompts are measured in tokens. A token is a piece of text about three quarters of a word long. At the start of each conversation the model read between 456 and 801 tokens: its instructions, its tools, the view of its notes, and the day's task. That stayed level while the notes grew from none to 24.

Tokens the model read at the start of each conversation

Conversations 1 to 16 record a day of the diary. Conversations 17 to 24 answer a question.

diaryquestionsThe tallest bar is 801 tokens.

This was one run, with a DeepSeek-V4.1-Flash variant served by vLLM on our own machine. It checks that the app works on a small case. We did not compare it with another memory design.

Try it

$ affordance run views/demo/memory

The example is in the repository at views/demo/memory. Its page in the docs says how to point it at a model and where it differs from OptMem. Victor Taelin's original is at github.com/VictorTaelin/OptMem.