jatin.blog ~ $
$ cat ai-engineering/memory-write-policies.md

Memory Write Policies: What's Worth Remembering

Agent-memory admission control with extraction, distillation, deduplication, and linking.

Jatin Bansal@blog:~/ai-engineering$ open memory-write-policies

A coding agent is six months into a long-running engagement with one user. The long-term episodic store holds 47,000 entries; every user turn, every tool result, every assistant reply, faithfully appended on every call. The agent feels worse than it did at month two. Retrieval pulls textually similar episodes that are no longer relevant; the recency × importance × similarity rerank is sorting through a haystack that is now 95% noise; the hierarchical memory hot tier gets pre-paged with stale facts because the warm tier’s top-K is dominated by old write-spam. None of the storage layers are broken. The substrate works. The substrate is too good at storing everything, and the team never built a defensible answer to the upstream question: which writes should ever have entered the store in the first place? That question is what memory write policies answer.

Write pipeline

The pattern that has converged across Mem0, LangMem, Letta, and A-MEM is a four-stage pipeline. Knowing the stages by name is what makes the design decisions in each framework legible:

Stage 1; Triage. Given a candidate turn or exchange, decide whether it’s worth processing at all. The cheapest possible filter: skip system messages, skip empty assistant turns, skip pure conversational fillers (“Got it”, “Thanks”, “Can you repeat that?”). No model call; a regex or token-count heuristic does the work. The triage stage is what keeps the write pipeline from running expensive downstream stages over the high-volume floor of pure noise. Skipping triage and going straight to LLM extraction is the most common production cost bug; you’ll spend $5 a day per active user on extraction calls for “ok” turns.

Stage 2; Extract / distill. Given a turn that cleared triage, transform it into the shape you actually want to store. Three common shapes: keep the raw episode (journal-only), extract one or more structured facts ({type, subject, value, confidence}, checkpoint-only), or both (hybrid). The extract stage is typically a small-model call (Mem0’s default is GPT-5-mini, Letta’s classifier path uses a smaller model, most hand-rolled systems use Haiku or Llama-3-8B-Instruct), and the prompt anchors the schema and the confidence scale. The fact-shaped output is itself the unit of admission downstream; a single turn might extract zero facts (if it was pure conversation) or three (a preference, an event, a corrective). The distill step is what separates “memory” from “transcript.”

Stage 3; Dedupe / resolve. Given an extracted fact, check whether it duplicates or contradicts something already in the store. Dedup against existing facts is typically a hybrid lookup: exact-match on a stable identifier (e.g., a normalized entity name) plus embedding similarity above a threshold. Conflict detection is harder; the new fact might update an old one (“user moved from SF to NYC”), supersede it (“user no longer works at Acme”), or contradict it (likely a hallucination, should be flagged for review). The 2026 production answer has converged: don’t try to resolve conflicts at write time in the synchronous path; write the new fact, mark the old one with a back-reference, and let the read-time rerank or a background reconciliation pass surface the conflict. The full ADD/UPDATE/DELETE/NOOP resolver, the user-vs-system arbitration policy, and the contradiction-density curve all get the dedicated treatment in the memory conflict and forgetting article. A-MEM’s Zettelkasten-inspired linking extends this idea further; every new memory dynamically updates the contextual attributes of related existing memories at write time, building an explicit knowledge graph as a side effect of the write path.

Stage 4; Persist / index. Given an admitted fact, embed it, write it to the storage tier, and update any auxiliary indices (entity graph, by-time index, by-user index). This is the cheapest physical operation in the pipeline; it dominates only if stages 1-3 have already filtered hard. Persist is also where the tier decision lands: a high-confidence durable preference goes to the hot/core tier, a moderate-confidence fact goes to the warm/recall tier, a low-confidence raw episode goes to the cold/archival tier.

The shape of a defensible memory subsystem is all four stages run on every write, with the cost of each stage bounded and the failure of each stage handled explicitly. Skipping stage 1 floods stages 2-4 with garbage; skipping stage 2 turns the store into a transcript dump; skipping stage 3 lets contradictions accumulate; skipping stage 4 means the fact never makes it into a queryable tier. Production failures usually trace to a missing stage rather than to a bad stage.

When write policy runs

The single biggest cost lever in a memory write policy is when it runs relative to the user-facing turn.

Hot-path admission (synchronous). Every turn ends with the full write pipeline running synchronously before the agent returns control to the user. Pros: the next turn (in this session or any other) sees the new memory; no risk of dropping memories if the process crashes between the user turn and a deferred pass. Cons: the user-facing latency includes the full extract-dedupe-persist cost; on a turn that didn’t need a memory, you’ve paid for stages 1-2 anyway; the per-turn LLM call for extraction roughly doubles the agent’s per-turn cost. Best fit: short interactive conversations where same-session continuity matters and the user tolerates a 200-500ms latency tax.

Deferred at session-end (batched). The agent buffers candidate writes in working memory through the session; when the session closes (explicit logout, idle timeout, or natural turn-of-topic) a single batched write pass processes the entire session. Pros: amortizes the extraction cost across many turns (one call instead of N); produces higher-quality extracted facts because the model sees full context; no per-turn latency tax. Cons: in-session continuity has to come from the working-memory scratchpad or the conversation buffer, not from long-term memory; if the session ends abruptly (process crash, network drop) all of it is lost; the user can’t reference a fact from earlier in the session via memory retrieval; only via the in-context conversation log. Best fit: customer-support sessions, bounded interactions with a defined end, anywhere the value of cross-session continuity dominates the value of within-session continuity.

Background / sleep-time (asynchronous). A separate process consumes a raw journal (every turn, written unfiltered) and runs the extract-dedupe-persist pipeline in the background, on a queue, often using a different model than the foreground agent. LangMem’s background memory manager is the productized version of this; Letta’s sleep-time agents are a richer variant that also consolidates the existing store. Pros: zero impact on user-facing latency; full session context available to the extractor; can use larger, slower, more accurate models than the foreground agent can afford; the journal serves as a durable audit log even if the background pipeline fails. Cons: there is a window where new memories aren’t yet queryable (the consistency lag from journal to indexed tier); operationally more complex; you now have two pipelines, a queue, and a failure mode where the journal grows faster than the extractor drains it; and the journal itself is unbounded by default, which puts the write-amplification problem back at the storage layer if you don’t add log compaction. Best fit: high-throughput multi-tenant systems where per-turn latency is critical and the operator has the engineering budget to run and monitor a background pipeline.

The defensible production answer is a mix: a fast triage on the hot path (sub-millisecond, no model call), a deferred extract pass at session-end for the common case, and a background reconciliation pass that runs nightly to dedupe, consolidate, and clean up. The shape mirrors what databases do: the WAL writes synchronously for durability, the checkpoint flushes asynchronously for read performance, and the autovacuum runs in the background to clean up bloat. The same three-tier pattern, applied one layer up.

Distilling turns into facts

The extract stage is where most of the policy’s intelligence lives, and it has converged on a remarkably consistent prompt shape across frameworks. The pattern, distilled from the Mem0 fact-extraction prompt and LangMem’s memory manager:

  1. Anchor the schema. Tell the model exactly the structured shape you want: {type: "preference"|"fact"|"event"|"correction", subject: string, predicate: string, object: string, confidence: 0-1, valid_from: timestamp?}. The schema is what makes the extracted facts dedupable downstream; without consistent shape, the dedup stage can’t compare apples to apples. The valid_from field is the upstream half of the bi-temporal model that the temporal-reasoning article works on the read side; writing it consistently here is what lets the read path answer “as of when?” queries later.
  2. Anchor the confidence scale. “0.2 = a guess from one ambiguous turn, 0.5 = an inference from a single clear statement, 0.9 = the user explicitly stated it, 1.0 = a system-of-record import.” Without anchoring, confidence scores cluster at 0.7 ± 0.1 and become useless for downstream filtering.
  3. Anchor what not to extract. “Do not extract: small talk, the assistant’s own opinions, content that depends on session-specific context, hypothetical or counterfactual statements.” This is the negative-example list that prevents the extract stage from over-producing; without it, the model will extract a “preference” from “I think pizza could be good for dinner tonight” and clutter the store.
  4. Return JSON, return an array. Always return a list, even if zero or one. The extractor returns zero facts as often as it returns one; zero-fact turns are the common case for any conversational system, and the policy has to handle them as a normal output, not an error.

The single-pass ADD-only redesign Mem0 shipped in 2026 is worth knowing as a specific instance of this pattern. The old pipeline ran two LLM passes: the first extracted candidate facts, the second compared each candidate against the existing store and emitted an ADD / UPDATE / DELETE / NOOP decision. The new pipeline runs one LLM pass that emits ADD-only writes and defers all UPDATE/DELETE reconciliation to either retrieval-time scoring or an asynchronous consolidation job. Reported result: 60-70% fewer write-time LLM calls, with the read path absorbing the conflict-resolution work via the recency-weighted rerank from the Generative Agents paper; newer ADDs naturally outscore older ones for the same fact. The trade-off is that the store now contains both versions of any fact-with-history, and the dedup work happens at read time rather than write time. The redesign is a classic case of move the cost off the hot path; the read path’s bounded top-K naturally drops the older versions, and the cheaper writes pay for themselves immediately.

Admission-controlled write gate in Python

The smallest interesting build: a four-stage write pipeline with triage, distill, dedupe, and persist, against Chroma for the checkpoint tier and a sqlite-backed journal. Uses the Anthropic SDK for the extract stage. Install: pip install anthropic chromadb.

python
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
import hashlib
import json
import sqlite3
import time
import uuid
from anthropic import Anthropic

client = Anthropic()
EXTRACT_MODEL = "claude-haiku-4-5"  # small model for the high-frequency write path

# --------- Stage 1: Triage (no model call) ---------
SKIP_PATTERNS = {"ok", "thanks", "got it", "k", "ty", "thx", "yes", "no"}

def triage(role: str, content: str) -> bool:
    """Cheapest possible filter. No LLM. Returns True if the turn is a candidate."""
    if role == "system":
        return False
    text = content.strip().lower()
    if len(text) < 5:
        return False
    if text in SKIP_PATTERNS:
        return False
    # Skip pure clarification-request turns.
    if text.startswith(("can you", "could you")) and len(text) < 40:
        return False
    return True

# --------- Stage 2: Extract / distill (one small-model call) ---------
EXTRACT_PROMPT = """Extract durable facts from this turn. Return JSON: a list of
fact objects, or [] if nothing is worth remembering across sessions.

Schema per fact:
  {"type": "preference"|"fact"|"event"|"correction",
   "subject": str, "predicate": str, "object": str,
   "confidence": float in [0,1],
   "rationale": str}

Confidence anchor:
  0.2 = ambiguous inference from one turn
  0.5 = inference from a clear statement
  0.9 = user explicitly stated it
  1.0 = system-of-record import

DO NOT EXTRACT: small talk, the assistant's own opinions, session-specific
context, hypotheticals, or counterfactuals. Return [] when in doubt.

Turn:
  role: {role}
  content: {content}"""

def extract(role: str, content: str) -> list[dict]:
    resp = client.messages.create(
        model=EXTRACT_MODEL,
        max_tokens=500,
        messages=[{"role": "user",
                   "content": EXTRACT_PROMPT.format(role=role, content=content)}],
    )
    try:
        text = "".join(b.text for b in resp.content if b.type == "text")
        facts = json.loads(text)
        return facts if isinstance(facts, list) else []
    except (json.JSONDecodeError, KeyError):
        return []  # malformed -> drop; never silently store junk

# --------- Stage 3: Dedupe / resolve (one cheap embedding lookup) ---------
def fact_key(fact: dict) -> str:
    """Stable identifier for exact-match dedup. Two facts with the same
    (subject, predicate) collide; conflict resolution happens on update."""
    key = f"{fact['subject'].lower()}::{fact['predicate'].lower()}"
    return hashlib.sha1(key.encode()).hexdigest()[:16]

def is_duplicate(checkpoints, fact: dict, threshold: float = 0.92) -> str | None:
    """Returns the existing fact ID if this fact is a near-duplicate."""
    fact_text = f"{fact['subject']} {fact['predicate']} {fact['object']}"
    hits = checkpoints.query(query_texts=[fact_text], n_results=3)
    if not hits["ids"][0]:
        return None
    for doc_id, distance in zip(hits["ids"][0], hits["distances"][0]):
        # Chroma L2 -> rough similarity proxy in [0,1]
        similarity = 1.0 / (1.0 + distance)
        if similarity >= threshold:
            return doc_id
    return None

# --------- Stage 4: Persist ---------
def persist(checkpoints, journal_conn, user: str, fact: dict, journal_id: str):
    fact_text = f"{fact['subject']} {fact['predicate']} {fact['object']}"
    checkpoints.add(
        documents=[fact_text],
        metadatas=[{
            "user": user, "type": fact["type"],
            "subject": fact["subject"], "predicate": fact["predicate"],
            "object": fact["object"], "confidence": fact["confidence"],
            "key": fact_key(fact), "journal_id": journal_id,
            "ts": time.time(), "strength": 1.0,  # MemoryBank-style decay seed
        }],
        ids=[str(uuid.uuid4())],
    )

# --------- The full write pipeline ---------
def init_journal(path: str = "journal.db") -> sqlite3.Connection:
    conn = sqlite3.connect(path)
    conn.execute("""CREATE TABLE IF NOT EXISTS journal (
        id TEXT PRIMARY KEY, user TEXT, session TEXT, role TEXT,
        content TEXT, ts REAL,
        extracted INTEGER DEFAULT 0  -- 0=pending, 1=processed
    )""")
    conn.execute("CREATE INDEX IF NOT EXISTS j_user_ts ON journal(user, ts)")
    return conn

def write_turn(checkpoints, journal_conn, user: str, session: str,
               role: str, content: str, *, hot_path: bool = True):
    """The journal write is unconditional (audit trail). The checkpoint write
    runs the gate, and can be deferred to a background pass if hot_path=False."""
    journal_id = str(uuid.uuid4())
    journal_conn.execute(
        "INSERT INTO journal VALUES (?, ?, ?, ?, ?, ?, 0)",
        (journal_id, user, session, role, content, time.time()),
    )
    journal_conn.commit()

    if not hot_path:
        return  # let the background pass do stages 1-4

    # Stage 1: triage. No model call; sub-millisecond.
    if not triage(role, content):
        journal_conn.execute(
            "UPDATE journal SET extracted=1 WHERE id=?", (journal_id,))
        journal_conn.commit()
        return

    # Stage 2: extract. One small-model call.
    facts = extract(role, content)
    if not facts:
        journal_conn.execute(
            "UPDATE journal SET extracted=1 WHERE id=?", (journal_id,))
        journal_conn.commit()
        return

    # Stages 3 & 4: dedupe and persist, per fact.
    for fact in facts:
        if fact["confidence"] < 0.5:
            continue  # drop low-confidence at the gate
        if is_duplicate(checkpoints, fact):
            continue  # already known; let the existing entry stand
        persist(checkpoints, journal_conn, user, fact, journal_id)

    journal_conn.execute(
        "UPDATE journal SET extracted=1 WHERE id=?", (journal_id,))
    journal_conn.commit()

# --------- Background consolidation pass ---------
def process_pending_journal(checkpoints, journal_conn, batch: int = 50):
    """Run as a separate process or cron. Drains the pending journal queue."""
    cur = journal_conn.execute(
        "SELECT id, user, session, role, content FROM journal "
        "WHERE extracted=0 ORDER BY ts LIMIT ?", (batch,))
    for row in cur.fetchall():
        journal_id, user, session, role, content = row
        if triage(role, content):
            for fact in extract(role, content):
                if fact["confidence"] >= 0.5 and not is_duplicate(checkpoints, fact):
                    persist(checkpoints, journal_conn, user, fact, journal_id)
        journal_conn.execute(
            "UPDATE journal SET extracted=1 WHERE id=?", (journal_id,))
    journal_conn.commit()

the journal write is unconditional; even turns that fail triage land in the journal; the journal is the audit trail, and we want a future run of the extractor (possibly with a better prompt, possibly with a larger model) to be able to revisit them. The four stages are explicit and individually skippable: production tuning happens at the stage boundary, not inside the stages. Want cheaper writes? Tighten triage. Want higher precision? Raise the confidence threshold in stage 3. Want background-only? Set hot_path=False everywhere and run process_pending_journal from cron. The dedup pass uses both exact-match and similarity; fact_key is the stable identifier for known-duplicate detection, the similarity check catches semantic duplicates. A real production system would also pass the new fact through an LLM “is this an update to an existing fact?” check when similarity is in the 0.6-0.9 ambiguous range; for clarity that stage is omitted here.