Conversations and memory¶
Four capabilities that give an agent a past: every turn is saved, searchable,
distilled into durable facts, and summarised. All of it is plain files under
data/, so you can read, grep and diff what the agent knows.
| Capability | Gives the model | Profiles |
|---|---|---|
| StepPersistence | nothing visible: it saves every run, step by step | assistant, demo, secure |
| ConversationSearch | search_conversation_history, read_turn |
assistant, demo, secure |
| Memory | an injected <memory> block, memory_archive_suggestion, and with the scoped render search_memory, list_memory |
assistant, demo |
| ThreadSummarizer | an injected <thread-summary> block, list_turn_summaries |
assistant, demo |
secure keeps persistence and search (the audit trail) and leaves out memory
and summaries: nothing carries over between conversations.
StepPersistence¶
Writes every run to data/paf/ as it happens: JSONL events plus a JSON
snapshot per run, one directory each. It is the single source for everything
else on this page. Search reads exactly what persistence wrote, and memory
facts point back into it.
ConversationSearch¶
The model's way back into the past, in a fixed order: what is in context, then
search_conversation_history, then read_turn.
search_conversation_history(query, run_id?)ranks persisted history with BM25. A multi-word query searches each word independently, and rare terms score higher.run_idnarrows it to one run.read_turn(ref, full?)expands a hit (run_id:turn_id) into the whole turn, tool calls and results included.full=truereturns it byte for byte. A memory id such as#42resolves to the turn(s) that fact came from.
Search never indexes the injected <memory> or summary blocks, so it can't
find its own notes. An agent that keeps searching without reading a hit is
nudged to read one.
search:
scope: conversation # or `all`
max_matches: 10
scope: conversation (the default) limits results to the current
conversation and fails closed if there isn't one. all searches every
conversation in the store. All keys are in the
spec reference.
Memory¶
A durable notebook of facts about you and your work, injected into the agent's context. The agent reads it; a separate background process writes it.
conversation turns ──▶ owner agent ──▶ journal ──▶ index ──▶ MEMORY.md
(background) (source) (derived) (what the agent sees)
Journal (data/memory/<agent>/memory-journal.jsonl) is the source of
truth: an append-only log, one record per fact, never edited or deleted. Each
record has a type (identity, rule, preference, decision, context,
event), the date it became true, and a reference to the turn it came from.
Facts you contradict later are not overwritten; a newer record wins when the
view is built.
Index (memory-index.json) is derived from the journal and rebuilt from it
alone: which line is the live copy of each fact, and which older lines it
replaced. It can't drift into a second source of truth.
View (MEMORY.md) is rendered from the index. Read it with /memory in
paf chat.
The owner is a background agent (specs/background_agents/memory_extractor.yaml)
that reads new turns and appends facts. Memory belongs to the agent, not to
one conversation: it runs once 5 new turns have accumulated across all of the
agent's conversations, out of band, so it never slows a reply. A second background agent
(memory_pair_reviewer) runs inside the same job and merges duplicates and
applies updates, by writing overrides beside the journal rather than editing
it.
What the agent can do. It never edits memory. If it sees a fact that is
stale or wrong, it calls memory_archive_suggestion(journal_id, reason). That
records a pending request; the fact stays visible, marked
(archive pending review), until the reviewer decides.
What is injected¶
Each turn the memory is rebuilt for this conversation: About you, this
project, any mentioned project, recent history, and a line naming
other projects. Sections are filled to a token budget, ranked by BM25 against
your latest messages and then by recency. The agent also gets
search_memory(query, project?) and list_memory(project, type?, offset?).
Every fact shown ends with its id (#N), so read_turn("#N") shows where it
came from.
memory:
render:
global_tokens: 2000
project_tokens: 2500
Projects¶
A project labels a conversation and can optionally select its working directory.
In the scoped memory render it decides which facts count as "this project's"
and which as global. Manage them in paf chat:
| Command | |
|---|---|
/projects |
list projects |
/projects add <name> [-- description] |
create one |
/projects rename <slug> <name>, archive <slug>, unarchive <slug> |
edit |
/projects root <slug> <path> |
set an existing absolute directory (~ is supported) |
/projects root <slug> - |
clear the root |
/project |
show this conversation's project |
/project <slug>, /project none |
assign or clear |
Tagging a conversation after the fact is fine: its earlier facts are filed under the project at the next render.
Project roots are stored in data/projects/projects.json; conversations store
only their assigned project slug. Editing a root therefore affects all
conversations assigned to that project, on their next turn. The agent must opt
in with workspace.project_roots.within.
Without that policy, stored project roots are ignored. A disallowed or broken
root refuses the turn instead of falling back to another directory.
The REST API accepts optional root on POST /api/projects and
PATCH /api/projects/{slug}. A string sets a validated directory, null is
unchanged on update (omitted on create), and "" clears it on update. Invalid
values return 400 with a repair message. Assign with
POST /api/conversations?project=<slug> or
PATCH /api/conversations/{id} with {"project": "<slug>"}; disallowed roots
are rejected before assignment.
Wait for background shell commands and delegated tasks to finish before switching a conversation's project or changing its root. The maintenance workers for memory, keywords, and summaries remain independent.
Files¶
data/memory/<agent>/
memory-journal.jsonl the journal
memory-index.json derived index
memory-overrides.jsonl pending and decided archive/supersede overrides
MEMORY.md the rendered view
Memory settings (consolidate_turns, max_memory_size,
pairwise_review, render) are in the spec reference.
Limits¶
- Memory is background knowledge, not instruction. The agent is told that your latest statement wins over a remembered fact.
- A fact missing from the view may still exist: old
t3entries age out of the tiered view. The agent is told so, and told to search before saying it doesn't know. - Extraction is a model call per pass, so memory costs tokens in the background and can miss or mis-state a fact. Provenance is what makes that checkable: every fact links to its turn.
ThreadSummarizer¶
Where memory holds facts that outlive a conversation, the thread summarizer holds the story of this one: what it was doing and how it evolved, as dated beats, each anchored to a turn. A long conversation keeps its thread even after compaction trims the old messages.
- A background agent (
thread_summarizer) writes summaries todata/summaries/<agent>/conversations/, per conversation, on its own cadence. - Each real user turn, a bounded
<thread-summary>block (oldest to newest, with recency headers) is injected. The injection costs nothing per turn; only the background writer calls a model. list_turn_summaries(offset?, limit?)pages through them. It only reads the current conversation.
Memory and the summarizer are independent: a profile can have either.