Building a Local-First Agent Framework in Rust (Part 15): Memory Tiers: Notes
See Part 0 for the latest table of contents and sample code. New chapters will be added over time.
Chapter 15: Memory Tiers: Notes
Chapter 14 gave the agent run an audit trail. Every run can now leave an events.jsonl file inside its session directory. That file is about what happened during one run: the user message, raw model responses, tool results, and final answer.
This post is also available on Medium. If you’re a paid Medium member and happen to read it there, it helps fund my next cup of coffee. Much appreciated ☕️😄
This chapter turns to a different kind of persistence. Not a run log, but memory.
The agent already has two note tools from Chapter 6:
session_note_append
session_note_search
At first, they wrote plain text lines into a project-level notes file: .abcb/notes.txt. That was enough to prove the tool wiring worked. But now that we have session directories, timestamps, and JSONL event records, the notes file should grow up a little too.
The framework change in this chapter is small:
.abcb/
notes.jsonl
The file stays project-scoped. It was already outside the session directory. What changes is that we now make that scope explicit and upgrade the record format: .abcb/notes.txt becomes .abcb/notes.jsonl, with one timestamped note per line.
A session log explains one run. Project notes survive across runs.
The Rust code will show that difference directly. The event log is strict: if a line is malformed, read_events returns an error. Notes are lenient: if a note line is malformed, search skips it and keeps going. The file shape is similar, but the policy is different because the purpose is different.
The sample code for this chapter is in chapter15/abcb/.
15.1 Project Notes, Not Session Notes
The first visible change is the path string:
File: abcb/crates/abcb-cli/src/main.rs
const NOTES_PATH: &str = ".abcb/notes.jsonl";
In Chapter 14, the value was already project-level:
const NOTES_PATH: &str = ".abcb/notes.txt";
So this is not a move from session storage into project storage. The file stays beside the session directory, but the name now says what the file actually contains: JSONL records.
Session artifacts live under:
.abcb/sessions/<session-id>/
Notes live at:
.abcb/notes.jsonl
That is what makes these notes project-scoped. If one run appends a note, a later run in the same project can search it. The note is not tied to the session that created it.
The next question is how this project-level path reaches the note tools. We do not add a new memory manager or a new project-memory abstraction. The CLI already builds the tool registry, so it passes the notes path into the two note tools when it registers them:
File: abcb/crates/abcb-cli/src/main.rs
fn default_registry(notes_path: PathBuf) -> ToolRegistry {
let mut registry = ToolRegistry::new();
registry.register(Echo).expect("echo is unique");
registry
.register(AddNumbers)
.expect("add_numbers is unique");
registry
.register(SessionNoteAppend::new(notes_path.clone()))
.expect("session_note_append is unique");
registry
.register(SessionNoteSearch::new(notes_path))
.expect("session_note_search is unique");
registry
}
This is one of the quiet benefits of the design from Chapter 6. The model never sees the file path. The loop does not decide where notes live. The wiring layer constructs the tools with a path, and the tools use that path.
There is a naming mismatch here that is worth admitting. The Rust types and tool names still say "session": SessionNoteAppend, SessionNoteSearch, session_note_append, and session_note_search. But the path we pass in is project-scoped. Those names came from the first version of the tools, and they are now a little misleading. I am leaving them in this chapter to keep the code change focused on the storage format and search policy. A later cleanup could rename the tools once the memory tiers settle.
That means a "memory tier" is mostly a path choice. Session memory, project memory, and global memory do not need three different mechanisms at this level. They can be the same tool shape pointed at different files.
15.2 The Note Record
The note file now stores one JSON object per line:
File: abcb/crates/abcb-tools/src/session_notes.rs
#[derive(Debug, Deserialize, Serialize)]
struct Note {
at: u64,
text: String,
}
This mirrors the idea from Chapter 14, but with a smaller shape. A LoggedEvent wrapped an Event and added at. A Note directly stores the two facts it needs: when the note was written, and the note text itself.
{"at":1780000000000,"text":"remember this"}
The timestamp is not meant to make notes smart. It is just a durable fact. Later code may sort, display, prune, or summarize notes using time. For now, we only stamp it and keep it.
The append tool imports the clock helper from abcb-core:
File: abcb/crates/abcb-tools/src/session_notes.rs
use abcb_core::{Tool, ToolError, now_millis};
That import is possible because now_millis changed from private to public:
File: abcb/crates/abcb-core/src/lib.rs
pub fn now_millis() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis() as u64)
.unwrap_or(0)
}
This is a small boundary decision. The project now has more than one place that needs to stamp records: session creation, event logging, and notes. Instead of copying the clock-read logic into abcb-tools, abcb-core exposes the helper.
15.2.1 pub at the Module Boundary and Inside a Struct
Without pub, now_millis is visible only inside the module where it is defined and that module's child modules. Sibling modules cannot import it, and other crates certainly cannot import it.
With pub fn now_millis, the function becomes part of abcb-core's public API. That is why abcb-tools can write:
use abcb_core::now_millis;
Public does not mean "use everywhere." It means other code is allowed to depend on it, so the decision should be intentional. That does not mean every helper should become public. It means this one became shared behavior at the crate boundary.
The same word, pub, also appears inside structs, but it controls a different layer. Making a struct public makes the type name available outside the module. It does not automatically make the fields public.
pub struct Note {
at: u64,
text: String,
}
With this shape, other code can name Note, but it cannot directly build Note { at, text } or read note.text unless it is in the same module. The fields are still private.
If we wanted the fields to be public too, each field would need its own pub:
pub struct Note {
pub at: u64,
pub text: String,
}
In this chapter, Note is private anyway because it is only an internal file format for the note tool. Later, when we make a type public but keep its fields private, it usually means: other code may hold this value, but this module still controls how the value is created or inspected.
15.3 Appending a Timestamped Note
The append tool still accepts a JSON argument from the model:
File: abcb/crates/abcb-tools/src/session_notes.rs
#[derive(Deserialize)]
struct AppendArgs {
note: String,
}
Inside invoke, the tool parses that argument, builds a Note, serializes it, and writes one JSONL line:
File: abcb/crates/abcb-tools/src/session_notes.rs
fn invoke(&self, args: &serde_json::Value) -> Result<String, ToolError> {
let typed: AppendArgs = serde_json::from_value(args.clone())?;
let note = Note {
at: now_millis(),
text: typed.note,
};
let line = serde_json::to_string(¬e).map_err(|e| ToolError::Execution(e.to_string()))?;
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open(&self.path)?;
writeln!(file, "{line}")?;
Ok("note appended".into())
}
The argument parsing line is familiar:
let typed: AppendArgs = serde_json::from_value(args.clone())?;
If the model sends bad arguments, serde_json::from_value returns an error. The ? operator uses From<serde_json::Error> for ToolError, which maps that error to ToolError::InvalidArguments. That is right because the model gave the tool a bad input shape.
The clone() is there because from_value consumes the Value it receives, but args is only a borrowed &serde_json::Value. We cannot move out of that borrowed value, so we duplicate it before deserializing. This is a small cost at the tool boundary, and it keeps the Tool trait simple.
The serialization line is different:
let line = serde_json::to_string(¬e).map_err(|e| ToolError::Execution(e.to_string()))?;
This also can return a serde_json::Error, but the meaning is different. At this point the tool has already built a valid internal Note value from a u64 and a String. If serializing that value fails, it is not the model's fault. It is an internal execution failure.
That is why this code does not use plain ? here. We already implemented From<serde_json::Error> for ToolError in abcb-core, and that conversion treats serde errors as ToolError::InvalidArguments. That is useful when we are parsing model-provided arguments.
But this line is not parsing model arguments:
serde_json::to_string(¬e)
If we wrote serde_json::to_string(¬e)?, Rust would use the existing From conversion and report the error as invalid arguments. That would compile, but it would give the wrong meaning. In this case, map_err lets us choose the error variant that tells the truth.
Rust:map_errmap_errtransforms the error side of aResultwhile leaving the success value alone. Here,serde_json::to_string(¬e)returnsResult<String, serde_json::Error>. We want theStringwhen it succeeds, but if it fails, we want to turn the serde error intoToolError::Execution.
The file write is the same append pattern we have used before:
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open(&self.path)?;
writeln!(file, "{line}")?;
append(true) means new notes go to the end. create(true) means the notes file appears the first time the tool writes to it. writeln! adds the newline that makes this JSONL.
15.4 Testing the Written Shape
The append test no longer expects plain text:
File: abcb/crates/abcb-tools/src/session_notes.rs
#[test]
fn append_writes_note_as_timestamped_jsonl_record() {
let file = NamedTempFile::new().expect("temp file");
let tool = SessionNoteAppend::new(file.path().to_path_buf());
tool.invoke(&serde_json::json!({"note": "remember this"}))
.expect("append");
let notes = stored_notes(&tool.path);
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].text, "remember this");
assert!(notes[0].at > 0, "append should stamp a real time");
}
NamedTempFile comes from the same tempfile crate we have used before. tempdir() gives us a throwaway directory. NamedTempFile gives us a single throwaway file, which is exactly what this tool needs.
The test uses a helper to read the file back into Note values:
File: abcb/crates/abcb-tools/src/session_notes.rs
fn stored_notes(path: &PathBuf) -> Vec<Note> {
let content = fs::read_to_string(path).expect("read");
content
.lines()
.map(|line| serde_json::from_str::<Note>(line).expect("note line parses"))
.collect()
}
There are two small Rust details here.
First, the test module can access the private Note type and the private path field because it is a child module of the same module. Privacy in Rust is module-based, not file-based in the way many languages feel.
Second, the test does not assert the exact timestamp. It asserts:
assert!(notes[0].at > 0);
That is the same nondeterminism pattern from Chapter 13 and Chapter 14. Time changes on every run, so we test the invariant.
15.5 Searching Notes
Search still accepts a query:
File: abcb/crates/abcb-tools/src/session_notes.rs
#[derive(Deserialize)]
struct SearchArgs {
query: String,
}
If the notes file does not exist, search returns no matches:
File: abcb/crates/abcb-tools/src/session_notes.rs
if !self.path.exists() {
return Ok("no matches".into());
}
That choice is practical. A missing notes file means nothing has been remembered yet. It is not an exceptional condition.
When the file exists, search reads it and filters the notes:
File: abcb/crates/abcb-tools/src/session_notes.rs
let content = fs::read_to_string(&self.path)?;
let matches: Vec<String> = content
.lines()
.filter_map(|line| serde_json::from_str::<Note>(line).ok())
.filter(|note| note.text.contains(&typed.query))
.map(|note| note.text)
.collect();
This is the central Rust line of the chapter:
.filter_map(|line| serde_json::from_str::<Note>(line).ok())
Read it in two pieces.
serde_json::from_str::<Note>(line) tries to parse one line as a Note. It returns:
Result<Note, serde_json::Error>
Then .ok() turns that Result into an Option:
Ok(note) -> Some(note)
Err(_) -> None
Finally, filter_map keeps the Some(note) values and skips the None values.
So this one line means: parse each JSONL line as a note, keep the lines that parse, and silently skip the lines that do not.
15.6 Lenient Memory, Strict Audit
That skip behavior is intentional. It is also the opposite of the event log.
In Chapter 14, read_events did this:
let event: LoggedEvent = serde_json::from_str(trimmed)?;
The ? means a malformed event line stops the read and returns an error. That is right for an audit trail. If the run log is corrupted, we should know.
Notes have a different job. They are a convenience memory. If one note line is corrupted, search can still return other useful notes. A malformed note should not make the agent forget everything else.
This is an important design distinction: strict versus lenient is a policy, not a mechanism. Both files are JSONL. Both use serde. Both parse one line at a time. The difference is what each file means.
- For the event log, the policy is: this is an audit record, so corruption should be loud.
- For notes, the policy is: this is best-effort memory, so one corrupted line can be skipped while the rest of the file remains useful.
The iterator chain encodes that policy:
.filter_map(|line| serde_json::from_str::<Note>(line).ok())
It is compact, but it is not just clever syntax. It is the policy in code.
15.7 Returning Matches
After parsing and filtering, search returns either "no matches" or the matching note texts:
File: abcb/crates/abcb-tools/src/session_notes.rs
if matches.is_empty() {
Ok("no matches".into())
} else {
Ok(matches.join("\n"))
}
The search result is a plain string because tool output is what the model sees. We could return structured JSON from a tool, but then the model would have to parse or interpret that shape. For now, one matched note per line is enough.
The test shows the behavior:
File: abcb/crates/abcb-tools/src/session_notes.rs
#[test]
fn search_finds_substring_match() {
let file = NamedTempFile::new().expect("temp file");
let append = SessionNoteAppend::new(file.path().to_path_buf());
for note in ["buy milk", "feed cat", "buy bread"] {
append
.invoke(&serde_json::json!({ "note": note }))
.expect("append");
}
let search = SessionNoteSearch::new(file.path().to_path_buf());
let output = search
.invoke(&serde_json::json!({"query": "buy"}))
.expect("search");
assert_eq!(output, "buy milk\nbuy bread");
}
The timestamps are stored, but they are not returned by search. That is another deliberate simplification. The model asked for matching notes. It gets matching text.
Later, if the agent needs recency-aware memory, this shape gives us room to use at. But the current search tool stays simple.
15.8 Scope Is a Constructor Argument
The tools store their path:
File: abcb/crates/abcb-tools/src/session_notes.rs
pub struct SessionNoteAppend {
path: PathBuf,
}
impl SessionNoteAppend {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
}
SessionNoteSearch has the same shape:
File: abcb/crates/abcb-tools/src/session_notes.rs
pub struct SessionNoteSearch {
path: PathBuf,
}
impl SessionNoteSearch {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
}
That means the memory scope is not baked into the tool. The same tool type can point at different files.
Project notes:
SessionNoteAppend::new(PathBuf::from(".abcb/notes.jsonl"))
Possible session notes:
SessionNoteAppend::new(session_dir.join("notes.jsonl"))
Possible global notes:
SessionNoteAppend::new(home_dir.join(".abcb/notes.jsonl"))
Those examples are not all implemented in this chapter. The point is that the architecture already allows them. A new memory tier can be a new path and a second registration, not a new tool mechanism.
That is the decision spotlight of this chapter: scope is a constructor argument.
15.9 What This Is Not Yet
This notes file is useful, but it is not a full memory system.
First, the model has to choose to search by calling the session_note_search tool. Nothing automatically injects relevant notes into the prompt before the model answers. If the model never calls the search tool, the notes may as well be invisible. Automatic retrieval is a different feature.
Second, this is not a wiki. A wiki has named pages, updates, deletion, merge behavior, and retrieval rules. This notes file is append-only memory. It can accumulate facts, reminders, and observations, but it does not try to maintain a canonical page for a topic.
Third, substring search is intentionally simple. It does not rank, stem, embed, or summarize. That is not a flaw in this chapter. It is the boundary of this chapter.
The goal here is to give the agent a persistent scratchpad that can survive across runs, while keeping the architecture small enough to understand.
15.10 What Changed
Chapter 15 upgrades notes from plain text lines to timestamped JSONL records at .abcb/notes.jsonl.
The framework lesson is memory scope. Session artifacts belong under .abcb/sessions/<session-id>/. Project notes live beside the sessions directory so later runs can search them. Because note tools receive their file path through constructors, memory tiers are mostly wiring decisions.
The Rust lesson is policy through iterator shape. Event logs use strict parsing because they are audit records. Notes use:
.filter_map(|line| serde_json::from_str::<Note>(line).ok())
because they are best-effort memory. Bad note lines are skipped, good note lines still help.
We also made now_millis public so abcb-tools can stamp records with the same clock helper used by sessions and events. A tiny visibility change, but a useful crate-boundary decision.
The next chapter uses a different kind of boundary. The agent already has tools and memory. Now it needs a place where risky tool calls can be stopped before they run.
To be continued