Set the boundary first. The previous chapter uses Summary and Session Recall to shorten a current session and recover raw evidence. Memory asks which knowledge should survive into another session. Both help a model recall the past, but they read different ledgers and write under different rules.
Evidence boundary. Implementation links are pinned to public snapshot 0c7774187da9330144df2a038ef18ee89ef2ae1c; experiments are pinned to benchmark snapshot c9aabe56c0eb1cb80e927d2a34ecc72658173cbe. Rememorio introduced the initial service in the commit corresponding to #70. Results describe one model, prompt, storage backend, and retrieval budget; they are not a universal deployment guarantee.
Reading contract. Keep one fact in view: “the user upgraded production from Go 1.23 to 1.24.” By the end, you should be able to explain which delta Runner hands off, why the extractor proposes update rather than add, how deterministic reconcile prevents duplicate writes, where the final entry lives, and how a new Session retrieves it within a bounded budget.
1. Why Summary Cannot Double As Memory
A certificate investigation may produce three kinds of information. "Reading logs" is transient process. "The user's production environment runs Go 1.24" is a stable fact. "The certificate was rotated in the Shanghai data center on 2026-07-20" is a time-bound episode. Summary sacrifices detail so the current case can continue. Memory must first decide what remains useful in future cases.
| Content | Better owner | Why |
|---|---|---|
| Yesterday's full tool output | Session events / Session Recall | Raw evidence should not first become a user fact. |
| "Network failure has been ruled out" | Session Summary | It advances the current case but may not remain durable. |
| "Production uses Go 1.24" | Memory Fact | Stable and reusable across sessions. |
| "Certificate rotated on July 20" | Memory Episode | Its meaning depends on time, participants, and place. |
Source types encode that distinction. KindFact covers preferences, identity, and background; KindEpisode
covers an event at a time and may carry event_time, participants, and location. Entries are isolated
by AppName + UserID. See Kind, Memory, and Entry.
2. Follow One Fact Through The Write Lifecycle
2.1 The Worker Reads Only The New Conversation Delta
Auto memory does not write after every sentence. After a run, the service hands Session to a worker. The worker reads the last extraction
timestamp, scans newer events, keeps non-empty user and assistant text, and excludes tool messages and tool calls. A checker may stop here
when the delta is not worth extracting. See EnqueueJob
and scanDeltaSince.
full Session
└─ delta after last extraction
├─ user: production runs Go 1.24
├─ assistant: certificate plan generated
└─ tool result: 12 KB log // not sent directly to extractor
MemoryJob = {UserKey, delta messages, latest timestamp}
This checker is not another model asked whether something deserves memory. It is a pure Go function,
func(*ExtractionContext) bool, that receives the user key, filtered delta messages, and optional last-extraction time.
No configured checker means proceed. Built-ins include “message count is strictly greater than n” and “more than this interval has elapsed,”
while ChecksAll and ChecksAny compose AND and OR policies. False is a normal no-op: no extractor call, no Memory write,
and no cursor advance. The interface has no error channel. See Checker and its built-ins.
// memory/extractor/checker.go and memory.go (excerpt)
type Checker func(ctx *ExtractionContext) bool
func CheckMessageThreshold(n int) Checker {
return func(ctx *ExtractionContext) bool {
return len(ctx.Messages) > n
}
}
func (e *memoryExtractor) ShouldExtract(ctx *ExtractionContext) bool {
if len(e.checkers) == 0 {
return true
}
for _, check := range e.checkers {
if !check(ctx) {
return false
}
}
return true
}
len(ctx.Messages) > n is strictly greater, not greater than or equal. An empty checker list returns true and preserves default behavior.
Multiple checkers naturally form AND inside ShouldExtract; callers that want OR first compose one checker through
ChecksAny. Because the return type is only bool, false can mean only “skip this extraction,” never a retryable error.
2.2 Why The Queue Is Sharded By User, And What Happens When It Fills
Extraction adds another model call and storage writes. Running it inline would make an already successful response wait for, or fail with,
a maintenance task. The worker therefore uses context.WithoutCancel: closing the HTTP request does not cancel an accepted job.
Each job gets its own timeout. Defaults are one worker, queue capacity ten, and thirty seconds. A stable hash of
AppName + UserID routes one user's updates to the same queue, preserving their order while allowing other users to use other workers.
An unstarted or full queue does not silently drop the delta. If the caller is still live, processing falls back to the synchronous path; only an already-cancelled caller skips that fallback. A missing extractor, nil Session, invalid user key, no new user/assistant text, or a checker that declines extraction is a normal no-op. Auto Memory is conditional maintenance, not a mandatory database row per run.
2.3 Search Existing Memories Before Writing
If the store already says "Go 1.23" and the new conversation says "we upgraded to 1.24," a blind append creates a conflict. The worker
builds a query from the new user messages, retrieves related entries, and gives both old memories and new conversation to the extractor.
The model proposes add, update, delete, or clear operations rather than free-form prose. See createAutoMemory
and Extract.
Existing-memory lookup has its own fallback. When relevance search fails, the worker reads a small recent set as deduplication context. Only when both search and fallback read fail does the job stop without advancing its extraction timestamp. It does not blindly emit adds while knowing nothing about prior state.
The extractor prompt exposes four operations as tools. add stores genuinely new knowledge. update revises an existing entry.
delete handles one explicitly forgotten or obsolete item. clear is reserved for an explicit “forget everything.”
Operations can also classify Fact or Episode and attach topics, absolute time, participants, and location. The model chooses the semantic intent;
it does not receive a storage transaction or mutate the service merely by emitting a tool call.
existing memory-17
Fact: "The user's production environment runs Go 1.23"
topics: [go, production]
new delta
user: "We upgraded production to Go 1.24"
extractor proposal
update(memory_id="memory-17",
memory="The user's production environment runs Go 1.24")
2.4 Reconcile Stops Paraphrased Duplicates Before The Store
The model may still propose add for the upgrade. For each add, the worker retrieves at most three candidates and compares two independent signals: the backend's relevance score and token-level Jaccard overlap. The signals use logical OR. Vector stores are good at semantic paraphrase; keyword stores often produce lower BM25-like scores; Jaccard catches common entity names and exact versions.
| Tier | Either condition | Runtime action |
|---|---|---|
| Effectively identical | score ≥ 0.90 or Jaccard ≥ 0.70 | Drop add if no topic is new; otherwise rewrite as a topic-only update. |
| A newer form of the same fact | score ≥ 0.60 or Jaccard ≥ 0.40 | Rewrite add as update of the strongest entry and merge topics. |
| Genuinely new | Neither threshold is reached | Keep the original add. |
// memory/internal/memory/auto.go (excerpt)
func reconcileDecisionTier(score, jaccard float64) int {
switch {
case score >= reconcileSkipScore ||
jaccard >= reconcileJaccardHigh:
return reconcileTierSkip
case score >= reconcileUpdateScore ||
jaccard >= reconcileJaccardMid:
return reconcileTierUpdate
default:
return reconcileTierNone
}
}
Candidate tier is compared before score and Jaccard, so a clear duplicate is not displaced by a merely related entry with a slightly higher score. A reconcile-search failure preserves the extractor's original operation rather than dropping uncertain data. If add becomes update but the deployment disables update, the worker falls back to the original add. The LLM proposes semantic operations; deterministic code owns idempotence and capability boundaries. See the reconcile tiers.
The switch makes “either condition” literal. Search score and Jaccard are not added into an undocumented composite score; they are independent evidence sources. The high tier appears first, so a candidate satisfying both skip and update always becomes skip. Only two weak signals preserve the original add. The model proposes an operation, while ordinary Go control flow owns the final tier.
2.5 The Write Completion Point Controls Retry
Only after reconcile does the worker execute add, update, delete, or clear. If an update targets a missing ID and add is enabled, it may fall back
to add. One failed operation is logged while later operations continue. There is an important boundary: last_extract_at advances only
after extraction and reconcile complete, but individual storage-write failures are logged rather than returned for the whole batch. That avoids
replaying one delta forever, and it also means production monitoring must inspect operation-level failures, not only job completion.
A failure while loading old memories, invoking the extractor, or timing out the whole job does not advance the timestamp, so a later job may retry
the delta. Successful entries are isolated by AppName + UserID; any new Session with that identity can retrieve them. The completed
foreground run never changes retroactively. Memory first becomes visible to later model calls.
Session, not the Memory store, owns this incremental cursor. The framework writes the timestamp of the last included Event to
Session.State["memory:last_extract_at"]. It is not in one storage transaction with add, update, or delete, so it means
“the worker processed through this point,” not “every Memory operation in the batch committed.” Production observability needs both operation-level
errors and this cursor. See readLastExtractAt and writeLastExtractAt.
Carrying memory-17 through the remaining stages exposes what still separates a model proposal from cross-session recall:
1. extractor produces a candidate operation
update(memory_id="memory-17",
memory="The user's production environment runs Go 1.24")
2. worker executes the storage interface
UpdateMemory(
Key{AppName: "support", UserID: "user-42", MemoryID: "memory-17"},
"The user's production environment runs Go 1.24",
topics=["go", "production"],
kind=Fact,
)
3. durable Entry in the Memory store (shape-level)
ID: <effective ID returned by the backend>
AppName: "support"
UserID: "user-42"
Memory.Memory: "The user's production environment runs Go 1.24"
Memory.Topics: ["go", "production"]
Memory.Kind: "fact"
UpdatedAt: <write time>
4. a new Session uses the same UserKey
UserKey{AppName: "support", UserID: "user-42"}
-> preload / search
-> this model request sees Go 1.24
The update request locates its target with the old ID. Some content-addressed backends rotate to a canonical ID derived from the new
content and metadata, while others may update in place. The durable cross-session partition is therefore
AppName + UserID, not an application holding memory-17 forever. Only stage 3 creates durable Memory;
only a preload or active search hit in stage 4 makes that Memory adopted by a later model call.
2.6 Storage Shape Exists To Support Retrieval
Fact versus Episode, absolute time, topics, participants, and location matter at query time. "What happened first?" needs event-time ordering;
"which mountain did Alice visit?" must validate participants; proper nouns often need lexical matching. Search options therefore include kind,
time ranges, ordering, deduplication, hybrid search, and RRF. See SearchOptions.
3. Read Memory Into A New Session, Then Make Retrieval Precise
3.1 Separate Framework Preload From Agent-Initiated Search
A stored Memory is not automatically visible to the model. tRPC-Agent-Go offers two read paths. The first is framework preload.
After the application explicitly configures WithPreloadMemory(N), ContentRequestProcessor takes a
MemoryReader from Invocation and reads prior entries under the current Session's AppName + UserID. The default is zero,
which disables preload. -1 loads all entries, while positive N is the per-request entry budget.
first turn of a new Session
user: “Plan the upgrade for my current production setup”
└─ ContentRequestProcessor
├─ probe N+1 entries to classify the set as small or large
├─ small set: load all returned entries
└─ large set: build a query from the current user message
└─ hybrid search + deduplicate, at most N entries
└─ inject into this model request
// internal/flow/processor/content.go (excerpt)
probeEntries, err := reader.ReadMemories(ctx, userKey, budget+1)
if err != nil || len(probeEntries) == 0 {
return nil
}
if len(probeEntries) <= budget {
return newPreloadMemoryMessage(probeEntries, p.PreloadMemoryPlaybook)
}
query := buildPreloadSearchQuery(inv.Message)
if query == "" {
return p.loadPreloadMemoryMessage(ctx, inv, reader, userKey, budget)
}
searchOpts := memory.SearchOptions{
Query: query, MaxResults: budget,
Deduplicate: true, HybridSearch: true,
}
memories, err := reader.SearchMemories(
ctx, userKey, query, memory.WithSearchOptions(searchOpts),
)
if err != nil || len(memories) == 0 {
return p.loadPreloadMemoryMessage(ctx, inv, reader, userKey, budget)
}
return newPreloadMemoryMessage(memories, p.PreloadMemoryPlaybook)
A small set does not need a relevance search. Above the budget, the processor derives a query from the current user message. A search error or
empty result falls back to reading the most recent N entries. If the probe or fallback read also fails, this request gets no Memory injection but
still proceeds. Preloaded content is a system message by default, with an option to place it near user/history context. Follow the complete branch in
getPreloadMemoryMessage.
budget+1 is a small but important implementation trick: the processor avoids an expensive count by reading only one entry beyond the
budget. Only then does it build a query. An empty query, search failure, or zero hits falls back to a bounded recent read. A failed initial probe
returns nil because storage readability itself is unknown; continuing the model request is safer than promoting a Memory outage into a failed run.
The second path is an Agent-initiated tool call. When the application exposes memory_search and
memory_load, the model can decide what to retrieve and whether to split a question into several searches during the same run.
An empty query returns an empty result; a backend failure returns a tool error, so the Agent can rewrite the query, answer with reduced confidence,
or explain the failure. These paths are complementary: preload places common facts before the first model call, while tools handle long-tail,
multi-hop, and time-filtered retrieval on demand.
3.2 Vectors And Keywords Cover Different Misses
Vector search handles paraphrase but may under-rank book titles, place names, and exact versions. Keyword search handles exact strings but not
semantic rewrites. The pgvector backend runs vector and full-text search, merges ranks through Reciprocal Rank Fusion, retries without a kind
filter when that filter returns too little, then deduplicates content. The order is visible in SearchMemories.
vector rank: [Mt. Fuji trip, hiking preference, Alice profile]
keyword rank: [Alice + Mt. Fuji, Mt. Fuji trip]
RRF merge: [Mt. Fuji trip, Alice + Mt. Fuji, hiking preference]
dedupe: remove near-identical restatements
RRF avoids comparing an incompatible cosine score such as 0.82 with a keyword score such as 7.4. It uses ranks instead. With default
k=60, a result ranked first in one list and third in another receives 1/(60+1) + 1/(60+3); a result present in only
one list receives one term. Items supported by both semantic and exact matching move upward without requiring a shared score scale.
3.3 Kind Fallback And Deduplication Correct Extraction Uncertainty
For “when did the upgrade happen?” an Agent may prefer Episode, while the extractor may have stored the statement as Fact. If a kind-filtered
query returns fewer than three entries, KindFallback runs an unfiltered search and merges it while preserving requested-kind priority.
Content deduplication then uses 0.80 word-set Jaccard and keeps the higher-scored representative, preventing two near-identical versions from
consuming prompt budget. The Memory tool enables hybrid and deduplication by default, and enables kind fallback only when a kind was requested.
These are service capabilities, not identical backend implementations. Vector backends such as pgvector, SQLiteVec, and MySQLVec can merge vector and keyword rankings with RRF. Keyword-only stores use BM25-like relevance. “Hybrid” in this article refers to a backend path that supports it; it does not imply that every store secretly generates embeddings.
3.4 Multi-Hop Questions Need Multiple Query Angles
"What did Alice buy before Japan, and what did she do after returning?" often exceeds one top-k. The optimized benchmark agent uses two or
three short searches from entity, time, and sub-question angles, then combines evidence. Tool guidance explicitly recommends separate searches
for multi-part questions and checking participants before using a result. See memory_search.
That choice is not free. Repeated searches make the model reread prior context, increasing both tokens and latency. A useful benchmark must report Tokens/QA, Calls/QA, and latency beside F1.
4. The Benchmark Separates Better Extraction From Better Retrieval
4.1 Why LoCoMo-10
LoCoMo-10 contains ten long conversations and 1,986 questions. It covers direct recall, evidence composition, temporal order, open-ended recall, and questions that should not be answered. The full protocol is in the Memory benchmark report.
| Category | Count | What it tests |
|---|---|---|
| single-hop | 282 | One stored item directly answers the question. |
| multi-hop | 321 | Several facts must be retrieved and combined. |
| temporal | 96 | Relative time becomes a stable, correctly ordered date. |
| open-domain | 841 | Broad wording still retrieves relevant background. |
| adversarial | 446 | The agent refuses when evidence is insufficient. |
The main run fixes GPT-4o-mini for answering and judging, with text-embedding-3-small for embeddings. Long Context sends the full
transcript. Original uses baseline auto extraction plus pgvector. Optimized adds structured extraction, hybrid search, and multi-pass retrieval.
Session Recall also ran on the same data, but it searches raw session events and belongs to the context chapter rather than being relabeled as a Memory gain.
4.2 What Each Experimental Arm Actually Changes
| Arm | Write path | Read path | Question answered |
|---|---|---|---|
| Long Context | No Memory extraction | Full transcript in prompt | Quality and token reference without retrieval. |
| Original Memory | Baseline auto extraction | One pgvector retrieval | What the original Memory pipeline provides. |
| Optimized Memory | Fact/Episode, absolute time, finer atomic facts | Hybrid + RRF, top-30, two to three query passes | End-to-end value and cost after improving write and read paths. |
| Session Recall | Raw Events, no knowledge extraction | Search and load transcript evidence | Whether structure beats direct evidence recovery; covered in the context chapter. |
This design compares complete systems, but it cannot attribute the gain to one switch. Optimized changes schema, retrieval mode, top-k, and call count. The main table answers whether the pipeline is worthwhile; top-k and search-pass ablations answer local parameter questions. Some framework comparisons in the report also require benchmark adapters or manual configuration and should not be read as rankings of untouched defaults.
4.3 Quality Reaches Long Context, But Cost Is Real
| Arm | F1 | BLEU | LLM Score | Tokens/QA | Calls/QA | Latency |
|---|---|---|---|---|---|---|
| Long Context | 0.469 | 0.426 | 0.526 | 18,776 | 1.0 | 2,607 ms |
| Original Memory | 0.399 | 0.371 | 0.416 | 3,056 | 2.0 | 6,659 ms |
| Optimized Memory | 0.469 | 0.431 | 0.532 | 17,182 | 3.0 | 8,585 ms |
Optimized raises F1 from 0.399 to 0.469, a 17.5% improvement that reaches Long Context. Nominal tokens, however, rise from 3,056 to 17,182 and latency increases. The report finds 43.9% of prompt tokens served from provider cache, making estimated new input about 9,663/QA. That explains billing but does not erase the end-to-end latency of three model calls.
4.4 The Gain Is Concentrated In Multi-Hop And Temporal Questions
| Category | Original | Optimized | Reading |
|---|---|---|---|
| single-hop | 0.316 | 0.396 | More atomic facts improve direct recall. |
| multi-hop | 0.096 | 0.453 | Multiple searches produce the largest gain. |
| temporal | 0.088 | 0.247 | Episodes, absolute time, and ordering help. |
| open-domain | 0.358 | 0.441 | Hybrid retrieval broadens recall. |
| adversarial | 0.814 | 0.626 | Original refused more aggressively; the drop must remain visible. |
The category view matters more than the 17.5% headline. The optimized pipeline repairs multi-hop and temporal recall, while adversarial behavior regresses. Deployment needs a separate policy for refusing unsupported answers.
4.5 Top-k Ablation: More Evidence Can Mean More Noise
On 199 questions from locomo10_1, SQLiteVec F1 rises from 0.320 at top-k 5 to 0.343 at top-k 10. It falls to 0.329 and 0.327 at
top-k 20 and 40 while prompt tokens grow from 346,253 to 621,790 and 965,423. A second search raises prompt tokens to 659,981 but leaves overall
F1 at 0.342. Retrieved noise consumes attention; Memory cannot optimize recall alone.
The benchmark does not say "Memory is solved." It shows that structured extraction, time metadata, Hybrid + RRF, and multi-pass search repair specific failure modes. It also shows that every extra result and every extra search may increase noise, tokens, and latency. The optimum is a joint quality and budget decision.
Two limits should not be hidden in footnotes. LoCoMo-10 contains only ten long conversations, and GPT-4o-mini participates in both answering and judging; the numbers compare these arms, not every model. Memory quality also multiplies three stages: write the right item, retrieve it, then use it correctly. When F1 falls, retain extractor operations, retrieval ranks, and final-answer traces so the failure can be assigned to schema, backend, or answer policy.
5. Translate The Evidence Into Engineering Choices
| Situation | Prefer | Do not start with |
|---|---|---|
| One current session is too large | Summary + Context Compaction; recover detail through Session Recall. | Extracting the whole transcript as durable user facts. |
| Stable preference, identity, environment | Atomic Fact; search and reconcile before writing. | Saving process-heavy transcript blocks. |
| Time-bound experience | Episode with absolute time and participants. | Relative phrases such as "last month" alone. |
| Multi-entity or multi-hop question | Split queries and cap the total retrieval budget. | One very large top-k. |
| High-risk unsupported question | Independent refusal policy and adversarial evaluation. | Hiding false confidence inside aggregate F1. |
Memory is not simply a vector database. It is a governed knowledge pipeline: select a delta, extract atomic facts and episodes, reconcile against existing entries, persist under user isolation, then return a bounded evidence set through hybrid retrieval. The next chapter changes owners again. Evolution does not remember user facts; it turns task experience into reusable Skills and uses evidence to decide whether a candidate may enter future runs.
