Memory Handling
Dev-Fassung mit Code-Walks — geprüft gegen wintermute@47ec498 (Stand 2026-07-03).
Kapitel 04 — Memory Handling
TL;DR: Ein Agent ohne Memory ist ein Goldfisch. Wintermute kombiniert drei Memory-Schichten: tägliche Markdown-Logs (für Menschen und als Kurzzeit-Historie des Agenten), eine kuratierte Langzeit-Datei (für jeden Turn mitgeladen) und eine Vektor-Datenbank für semantische Suche über tausende vergangener Turns. Dieses Kapitel zeigt, wie die drei zusammenspielen und wo die Stolperfallen liegen.
Das Problem
Standard-LLMs haben kein eigenes Gedächtnis. Was im aktuellen Gespräch nicht im Kontextfenster steht, existiert für das Modell nicht. Damit kollabieren genau die Eigenschaften, die einen persönlichen Agenten interessant machen:
- Du erzählst eine Vorliebe ein Mal — und musst sie nächste Woche wieder erklären
- Der Agent kann nicht über Wochen an einem Projekt mitarbeiten
- Jede neue Konversation ist kalter Start
- Lessons aus früheren Fehlern gehen verloren
Die einfachste „Lösung" der kommerziellen Anbieter: ein Konversations-Verlauf wird abgespeichert und beim nächsten Mal mitgeladen. Das ist Goldfisch-mit-Notizblock. Es funktioniert für einzelne, kurze Konversationen — aber nicht für „der Agent kennt mich seit drei Monaten".
Das eigentliche Problem ist nicht „Wie speichere ich Text?", sondern „Wie finde ich relevante alte Erinnerungen in Sekundenbruchteilen wieder, ohne den ganzen Verlauf in jeden Prompt zu packen?". Antwort: semantische Suche über Vektoren.
Unsere Lösung
Wintermute hat drei Memory-Schichten, die unterschiedliche Zwecke erfüllen:
flowchart TD
U["User-Nachricht kommt rein"]
A["<b>Agent-Core</b><br/>baut den System Prompt"]
L1["<b>Schicht 1: Daily Logs</b><br/>memory/conversation/YYYY-MM-DD.md<br/><i>Markdown-Logs: Audit-Spur + Kurzzeit-Historie</i>"]
L2["<b>Schicht 2: Long-term Markdown</b><br/>memory/longterm.md<br/><i>handgepflegt, immer im System Prompt</i>"]
L3["<b>Schicht 3: Vektor-Store</b><br/>Qdrant + Ollama-Embeddings<br/><i>semantische Suche pro Turn</i>"]
P["System Prompt mit injiziertem Recall-Kontext"]
R["Antwort"]
SAVE["fire-and-forget: speichere user + assistant<br/>als zwei Memory-Punkte"]
U --> A
A -.->|"liest"| L2
A -.->|"lädt letzte N Turns"| L1
A -->|"memory_search(query)"| L3
L3 -->|"top-K relevante Erinnerungen"| P
L2 --> P
P --> R
R --> SAVE
SAVE -.->|"upsert"| L3
R -.->|"git-bar, lesbar"| L1
classDef store fill:#dbeafe,stroke:#2563eb,stroke-width:2px;
class L1,L2,L3 store;
Was die Schichten leisten
- Schicht 1 — Daily Logs (Markdown). Pro Tag eine Datei
memory/conversation/YYYY-MM-DD.md. Jeder Gesprächsturn wird als lesbarer Markdown-Block angehängt. Das ist menschen- lesbar,git diff-bar, mit Standardwerkzeugen durchsuchbar (grep,cat, Editor). Sie ist aber nicht nur Rohdaten-Backup und Audit-Spur: der Agent liest diese Schicht auch selbst — beim Prompt-Bau lädt er die letzten N Nachrichten aus genau diesen Dateien als Kurzzeit-Historie zurück. Das macht das Datei-Format zur Schnittstelle, nicht nur zur Ablage — mit einem echten Fallstrick, siehe Tech-Vertiefung. - Schicht 2 — Long-term Markdown. Eine einzige Datei,
memory/longterm.md. Hand-kuratiert. Sie wird bei jedem Turn komplett in den System Prompt geladen. Hier stehen Dinge, die immer gelten sollen: „Der User heißt Jürgen", „Lieblingssprache: Deutsch", „bevorzugte Anrede: du". Die Datei muss kompakt bleiben (Token-Budget!) — typisch 2-5 KB. - Schicht 3 — Vektor-Store (Qdrant). Jeder Turn wird
automatisch gespeichert: User-Nachricht und Assistant-Antwort
jeweils als separater Punkt. Vor jedem neuen Turn macht der
Agent eine semantische Suche nach der aktuellen User-
Nachricht, holt die Top-K relevanten Punkte zurück, und
injiziert sie unter
## Recalled contextin den System Prompt.
Was bewusst nicht gespeichert wird
Der Auto-Save schreibt pro Turn genau zwei Dinge: die
User-Nachricht und die finale Assistant-Antwort. Die
Tool-Aufrufe dazwischen — Datei-Dumps, Status-JSON, komplette
E-Mail-Bodies aus den IMAP-Tools — erreichen den Vektor-Store
nie; sie leben nur im Tool-Loop des jeweiligen Turns. Das ist
eine Privacy-Entscheidung mit klarer Nebenwirkung: eine Mail
kann später nur dann per Recall auftauchen, wenn der Agent sie
in seiner Antwort zitiert hat. Wer einen Inhalt bewusst behalten
will, pinnt die Antwort per /remember.
Warum diese Trennung?
Die drei Schichten optimieren auf verschiedene Zugriffsmuster:
- Schicht 1 ist optimiert auf chronologischer Verlauf — für Menschen zum Nachlesen, für den Agenten als letzte N Turns
- Schicht 2 ist optimiert auf jeder Turn braucht diesen Kontext → eine kleine Datei, immer im Prompt
- Schicht 3 ist optimiert auf Agent sucht passende Erinnerungen zu dieser konkreten Frage → Vektorsuche, skaliert auf zehntausende Punkte ohne dass der Prompt explodiert
Du brauchst alle drei. Wer nur Schicht 3 hat, kann den Verlauf nicht inspizieren. Wer nur Schicht 1 hat, kann nicht semantisch suchen. Wer nur Schicht 2 hat, hat ein Token-Budget-Problem sobald „Langzeit-Kontext" auf 50 KB anwächst.
Trade-offs & offene Fragen
- Speicher-Bedarf wächst linear mit Nutzung. Pro Turn ein bis zwei Vektoren à 768 Dimensionen Float32 = ~3 KB plus Payload-Text. Bei intensiver Nutzung sind das ~50-100 MB Qdrant-Daten pro Jahr — handhabbar, aber nicht null.
- Embedding-Kosten kommen on top. Jeder Turn = mindestens drei Embedding-Aufrufe (Suche + 2× Save). Mit lokalem Ollama ist das umsonst, aber langsam (~200-500ms). Mit OpenAI ist es schnell, aber pro-Token-bepreist. Und: batch-artige Embedding-Läufe laufen sequenziell in die Minuten — der HTTP-Timeout zwischen Agent und Gateway muss das aushalten (Details unten, „Lange Embedding-Läufe").
- Semantische Suche ist nicht perfekt. Sie findet was ähnlich klingt, nicht zwingend was die Frage beantwortet. Konkret: Wintermute lernte (LESSONS §28), dass eine Suche nach „httpx ReadTimeout leerer Error Envelope" nichts zurückgab, obwohl die passende Lesson 8 das Mechanismus- Detail enthielt — aber unter Symptom-Vokabular gespeichert war. Konsequenz für Authoring: wer Lessons oder Erinnerungen schreibt, sollte sowohl Symptom als auch Mechanismus erwähnen, damit beide Such-Wege das Dokument finden.
- Auto-Save erzeugt Rauschen. Wenn der User „kannst du das
bitte nochmal in fett?" schreibt, landet das als
Memory-Punkt im Store. Wintermutes Antwort: ein
Save-Filter vor dem Speichern (Mindest-Länge, Footnote-
Anteil, Code-Anteil, Dedup-Lookback). Pinned Saves via
/rememberumgehen den Filter — manuelle Kuration schlägt Heuristik. - Ein menschenlesbares Format ist trotzdem ein Parser.
Wintermutes Daily Logs trennen Nachrichten mit
### user/### assistant-Zeilen. Der erste Parser splittete auf dem Substring###— und zerschnitt damit jede Assistant- Antwort mit eigenen###-Überschriften. Details und Fix in der Tech-Vertiefung (LESSONS §29). - Was fehlt: Vergessen. Wintermute kann gezielt einzelne
Punkte löschen (
memory_forget), aber es gibt keine systematische „Memory-Komprimierung" oder „alte-irrelevante-Erinnerungen-zusammenfassen"-Routine. Das ist offen. - Multi-User-Memory ist nicht gelöst. Aktuell ein globaler
Store. Wer mehrere User mit getrennten Memories braucht,
muss
conversation_idzur Pflicht-Filter-Bedingung machen und ein Berechtigungsmodell draufsetzen.
🔧 Tech-Vertiefung
Konkrete Tools im MCP-Gateway
Die Memory-Schicht 3 ist vollständig über das MCP-Gateway
gewrappt. Fünf Tools (in mcp-gateway/src/gateway/tools/memory.py):
| Tool | Zweck | Aufrufer |
|---|---|---|
memory_search |
Semantische Suche, Top-K Punkte zurück | Agent (pro Turn) |
memory_save |
Neuen Punkt speichern | Agent (auto), User (/remember) |
memory_status |
Vektorzahl, RAM-Schätzung, Headroom | Agent (alle N Turns), User (/memstatus) |
memory_forget |
Punkt nach ID löschen | Admin / User (/forget) |
memory_load_doc |
Markdown-Doc als gepinnte Doc-Points ingestieren, idempotent | Admin (on demand) |
Read-Flow pro Turn: die echte Recall-Funktion
In agent/src/agent/memory_recall.py lebt die ganze
Recall-Logik in einer Funktion. Verbatim:
# agent/src/agent/memory_recall.py (Auszug, Z.92-148)
async def recall_for_turn(
tools: ToolRegistry,
*,
query: str,
conversation_id: str,
enabled: bool,
top_k: int,
min_score: float,
max_chars: int,
) -> str | None:
"""Fetch top-K recall hits and return a formatted markdown block.
Returns ``None`` when:
* recall is disabled,
* the query is empty or whitespace,
* the gateway call fails or returns no usable hits.
The caller treats ``None`` as "no recall this turn" and proceeds with
the existing prompt build.
"""
if not enabled:
return None
if not query or not query.strip():
return None
arguments = {
"query": query.strip(),
"k": int(top_k),
"conversation_id": conversation_id,
"min_score": float(min_score),
"include_pinned": True,
}
try:
envelope = await tools.call_tool("memory_search", arguments)
except Exception as e: # noqa: BLE001
logger.warning("memory recall: gateway call failed: %s", e)
return None
inner = _unwrap(envelope)
if inner is None:
# Either a transport error or a tool-level error. Either way, skip.
logger.info(
"memory recall: skipping turn — gateway returned %r",
(envelope.get("error") if isinstance(envelope, dict) else envelope),
)
return None
hits = inner.get("hits") or []
if not isinstance(hits, list) or not hits:
return None
block = _format_hits(hits, max_chars=max_chars)
if not block.strip():
return None
return block
Drei Design-Entscheidungen, die hier sichtbar werden:
- Fail-soft auf jeder Stufe.
enabled=False, leere Query, Gateway-Crash, leereshits-Array — alles führt zureturn None. Der Caller behandeltNoneals „kein Recall in diesem Turn" und macht normal weiter. Ein gescheiterter Recall darf den Agent nicht blockieren. include_pinned: Trueals impliziter Default. Pinned Saves sind die User-kuratierten Erinnerungen (siehe/remember). Die wollen wir immer sehen, auch wenn der Score-Match niedrig ist. Der Gateway-Tool injiziert sie zusätzlich zu den Top-K-Vector-Hits.max_charsals Sicherheits-Cap._format_hitsschneidet den Block ab, wenn er zu lang würde. Wintermutes Default ist 4000 Zeichen (MEMORY_MAX_CHARS) — ein vernünftiger Kompromiss zwischen „genug Kontext für relevante Anfragen" und „nicht den ganzen System Prompt mit altem Material zumüllen".
Write-Flow pro Turn: Schedule + Filter + Save
Der Write-Path ist ein Detached-Background-Task pro Turn. Die Schedule-Funktion:
# agent/src/agent/memory_save.py (Auszug, Z.314-360)
def schedule_turn_saves(
tools: ToolRegistry,
*,
enabled: bool,
conversation_id: str,
channel: str | None,
user_message: str,
assistant_reply: str,
tags: list[str] | None = None,
) -> list[asyncio.Task]:
"""Schedule background saves for a completed turn. Returns the task handles.
Tasks are detached — the caller does not need to await them. They are
returned only so tests (and the optional shutdown path) can wait on
them deliberately.
The caller is responsible for ensuring this runs inside an event loop
(it does, because the chat handler is already async).
"""
if not enabled:
return []
pairs: list[tuple[str, str]] = []
if user_message and user_message.strip():
pairs.append(("user", user_message.strip()))
if assistant_reply and assistant_reply.strip():
pairs.append(("assistant", assistant_reply.strip()))
tasks: list[asyncio.Task] = []
for role, content in pairs:
coro = _save_one(
tools,
content=content,
role=role,
conversation_id=conversation_id,
channel=channel,
pinned=False,
tags=tags,
)
try:
task = asyncio.create_task(coro, name=f"memory_save:{role}")
except RuntimeError as e:
# No running loop (shouldn't happen from the chat handler, but be safe).
logger.warning("memory_save: cannot schedule (%s): %s", role, e)
continue
tasks.append(task)
return tasks
Der eigentliche Filter sitzt eine Schicht tiefer in
_filter_skip_reason:
# agent/src/agent/memory_save.py (Auszug, Z.146-198)
def _filter_skip_reason(
*,
role: str,
content: str,
conversation_id: str,
min_chars: int,
footnote_ratio: float,
code_ratio: float,
dedup_lookback: int,
dedup_threshold: float,
) -> str | None:
"""Return the name of the rule that fires, or ``None`` to allow the save.
Rules are checked in cheapest-first order; the first match wins.
"""
stripped = content.strip()
total = len(stripped)
if total == 0:
# The caller's empty-check should have already handled this; guard
# anyway so the ratio math below doesn't divide by zero.
return "empty"
# Rule 1: length floor. Applies to both roles.
if total < min_chars:
return "length_floor"
# Rules 2 & 3 only make sense for assistant output. User messages can
# legitimately be short questions or pasted snippets and we want to
# keep the user side of the conversation in the index.
if role == "assistant":
# Rule 2: framework-footnote dominant.
fn_len = _footnote_block_length(stripped)
if fn_len > 0 and fn_len / total > footnote_ratio:
return "footnote_dominant"
# Rule 3: tool-output / fenced-code dominant.
code_len = _code_block_length(stripped)
if code_len > 0 and code_len / total > code_ratio:
return "code_dominant"
# Rule 4: near-duplicate of a recent same-role save in this conversation.
if dedup_lookback > 0:
buf = _dedup_buffer(conversation_id, role, dedup_lookback)
# Snapshot under the lock: a concurrent _record_dedup append would
# otherwise mutate the deque mid-iteration (RuntimeError).
with _dedup_lock:
recent = list(buf)
for prev in recent:
ratio = SequenceMatcher(a=prev, b=stripped, autojunk=False).ratio()
if ratio > dedup_threshold:
return "dedup"
return None
Fünf wichtige Implementierungs-Details:
- „Cheapest-first" als Such-Reihenfolge. Rule 1 (Längen-Floor) ist O(1), Rule 4 (SequenceMatcher gegen N vorherige Saves) ist O(N×M) — die billige Regel fliegt zuerst. Bei kurzen Antworten („ja", „ok", „danke") überlebt nichts den Filter, und die teure Vergleichs-Logik läuft nie.
- Asymmetrische Behandlung von
uservs.assistant. User-Nachrichten können legitimerweise kurz und sich wiederholend sein (Folge-Fragen, Klärungen). Sie müssen nur den Längen-Floor passieren. Assistant-Antworten haben drei zusätzliche Hürden (Footnote-Dominanz, Code-Dominanz, Dedup), weil sie sonst den Store mit Framework-Footnotes und mehrfach-zitierten Tool-Outputs verstopfen würden. SequenceMatchermitautojunk=False. Standard-difflib.SequenceMatcherhat eine „Junk-Optimierung", die für Code-/Text-ähnliche Strings nichts beschleunigt und manchmal Ratio-Werte verzerrt.autojunk=Falsemacht die Vergleichs-Logik vorhersehbarer.- Dedup vergleicht gegen einen Snapshot, nicht die lebende
Deque. Der Ring-Buffer wird von den detached Background-
Save-Tasks konkurrierend beschrieben. Die Vergleichs-Schleife
zieht deshalb unter Lock eine Kopie (
recent = list(buf)) — ein konkurrierendes Append während der Iteration wäre sonst einRuntimeError. Klassischer Bug, der erst unter Last auftaucht. - Rückgabewert ist der Rule-Name als String (oder
None). Damit lässt sich die Skip-Statistik trivial loggen: „diese Antwort wurde wegendedupnicht gespeichert" ist mehr Info als „nicht gespeichert". Hilft beim Tuning der Filter- Schwellen.
Pinned Saves via /remember <text> laufen über eine eigene
Funktion save_pinned — sie umgeht den Filter komplett und
wird, anders als die Auto-Saves, awaited, weil der Caller
die Bestätigung sehen will, bevor er dem User antwortet.
Explizite Kuration durch den User schlägt jede Heuristik.
Die Filter-Schwellen kommen aus der Umgebung; der Env-Block im
docker-compose.yml (Agent-Service) verbatim:
# docker-compose.yml (Agent-Service, Auszug)
- MEMORY_ENABLED=${MEMORY_ENABLED:-true}
- MEMORY_TOP_K=${MEMORY_TOP_K:-5}
- MEMORY_MIN_SCORE=${MEMORY_MIN_SCORE:-0.6}
- MEMORY_MAX_CHARS=${MEMORY_MAX_CHARS:-4000}
- MEMORY_STATUS_EVERY_N=${MEMORY_STATUS_EVERY_N:-50}
- MEMORY_SAVE_MIN_CHARS=${MEMORY_SAVE_MIN_CHARS:-50}
- MEMORY_SAVE_FOOTNOTE_RATIO=${MEMORY_SAVE_FOOTNOTE_RATIO:-0.7}
- MEMORY_SAVE_CODE_RATIO=${MEMORY_SAVE_CODE_RATIO:-0.8}
- MEMORY_SAVE_DEDUP_LOOKBACK=${MEMORY_SAVE_DEDUP_LOOKBACK:-3}
- MEMORY_SAVE_DEDUP_THRESHOLD=${MEMORY_SAVE_DEDUP_THRESHOLD:-0.85}
Wichtig fürs Privacy-Modell: gespeichert wird pro Turn nur
user_message und assistant_reply. Die Tool-Call-/Tool-Result-
Paare aus dem Tool-Loop (agent/core.py) — inklusive kompletter
Mail-Bodies aus den IMAP-Tools — erreichen memory_save nie.
Siehe docs/IMAP-TOOLS.md, Abschnitt „Privacy & memory".
Daily Logs parsen: der Delimiter-Fallstrick (LESSONS §29)
Schicht 1 ist nicht nur Ablage — MemoryManager.get_recent_history
in agent/src/agent/memory.py parst die Tagesdateien bei jedem
Prompt-Bau zurück in eine Nachrichten-Liste. Geschrieben wird im
Format ### {role}\n{content}\n\n, und genau da lag der Bug:
der erste Parser splittete mit text.split("### ") — auf einem
Substring, den Assistant-Antworten selbst ständig enthalten
(### Summary, ### Schritt 1; die Persona liebt Überschriften).
Jede eingebettete ### -Stelle wurde als Nachrichtengrenze
gelesen, der Assistant-Turn am ersten eigenen Header abgeschnitten
und der Rest als Pseudo-Rolle verworfen. Das Modell bekam so jede
Runde eine verstümmelte Kopie seines eigenen letzten Turns.
Der Fix: ein verankerter, eindeutiger Delimiter — eine Zeile, die exakt ein bekannter Rollen-Header ist:
# agent/src/agent/memory.py (Auszug)
# A message boundary is a line that is EXACTLY a known role header, e.g.
# ``### user`` or ``### assistant``. Anchoring to start-of-line and to the
# known role set means markdown headers inside a message body (``### Summary``,
# ``### Schritt 1`` — which the persona emits constantly) are NOT mistaken for
# message boundaries. The old ``text.split("### ")`` split on every header and
# truncated/dropped the assistant's own turns.
_MESSAGE_HEADER_RE = re.compile(r"^### (user|assistant)[ \t]*{{INHALT}}quot;, re.MULTILINE)
@staticmethod
def _parse_messages(text: str) -> list[dict]:
"""Parse one conversation file into ordered ``{role, content}`` dicts.
Splits only on exact role headers (see ``_MESSAGE_HEADER_RE``); a
message body may freely contain ``### ...`` markdown headers.
"""
out: list[dict] = []
matches = list(_MESSAGE_HEADER_RE.finditer(text))
for i, m in enumerate(matches):
role = m.group(1)
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
body = text[start:end].strip()
out.append({"role": role, "content": body})
return out
Die allgemeine Regel aus LESSONS §29: wenn dein serialisiertes
Format einen Marker benutzt, dann entweder (a) den Marker in
Payloads escapen, oder (b) den Split so verankern, dass Payload-
Vorkommen nicht matchen können (Zeilenanfang + geschlossenes
Token-Set). Ein nacktes .split(marker) über Freitext ist ein
latenter Korruptions-Bug.
Qdrant-API-Stolperfalle (LESSONS §15)
Wenn du qdrant-client ≥ 1.10 verwendest: die alte .search()-
Methode ist weg. Du musst .query_points() benutzen:
response = await client.query_points(
collection_name="wintermute-memory",
query=vec, # nicht "query_vector="!
limit=5,
score_threshold=0.6,
query_filter=qm.Filter(should=[...]),
with_payload=True,
)
hits = response.points # nicht direkt iterierbar!
Wer das aus älteren Tutorials kopiert, kriegt einen
AttributeError und debuggt sich tot. Wintermutes
Memory-Client liegt im Gateway in
mcp-gateway/src/gateway/tools/memory.py — wenn du dort
hinein-schaust, siehst du das query_points-Pattern als
einzigen Code-Pfad (kein Backward-Compatibility-Fallback,
weil Wintermute auf qdrant-client>=1.10 festgepinnt ist).
Lange Embedding-Läufe: memory_load_doc und der Timeout (LESSONS §8, Variante 2)
Das fünfte Tool, memory_load_doc, ingestiert ein strukturiertes
Markdown-Dokument (z.B. LESSONS.md) als gepinnte, system-role
Doc-Points. Idempotent über Edits hinweg: neue Einträge werden
embedded und hinzugefügt, geänderte aktualisiert, unveränderte
kosten null Embedding-Calls, verwaiste werden entfernt — die
Datei ist die Source of Truth, der Store wird abgeglichen.
Der Fallstrick: so ein Lauf embedded Dutzende Einträge
sequenziell. Der erste echte Lauf gegen LESSONS.md
(27 Ollama-Embeddings, ~50s Wall-Clock) riss den damaligen
30s-HTTP-Timeout des Agent-Clients. Perfides Detail:
str(httpx.ReadTimeout) ist der leere String — der Agent sah
also nur {"error": ""}, während die Arbeit serverseitig längst
erledigt war und alle 27 Punkte in Qdrant standen. Der Lauf war
erfolgreich; nur der Antwort-Kanal starb.
Konsequenz: der Agent→Gateway-Timeout ist heute großzügig bemessen und ohne Rebuild tunebar:
# docker-compose.yml (Agent-Service, Auszug)
- AGENT_TOOL_HTTP_TIMEOUT_SECONDS=${AGENT_TOOL_HTTP_TIMEOUT_SECONDS:-300}
Faustregel für den eigenen Nachbau: der HTTP-Timeout für Tool-Calls muss sich am langsamsten legitimen Tool orientieren (Batch-Embedding, Self-Update), nicht am typischen. Ein zu kurzer Timeout produziert die schlimmste Fehlerklasse: die Arbeit passiert, aber das Ergebnis sieht nach Fehler aus.
Memory-Health-Selbstreflexion
In agent/src/agent/memory_health.py lebt eine Eigenheit, die
direkt aus der Wintermute-Persönlichkeit kommt: alle
MEMORY_STATUS_EVERY_N=50 Turns ruft der Agent memory_status
auf. Wenn das Ergebnis headroom != "ok" ist, kommentiert er
das selbständig in der nächsten Antwort.
Die Notice-Strings verbatim:
# agent/src/agent/memory_health.py (Auszug, Z.27-41)
# Notice messages keyed by headroom level. Tone: dry, terse,
# in-character. Length kept short so prepending doesn't push the
# actual answer off-screen in compact UIs (Telegram, etc.).
_NOTICES: dict[str, str] = {
"warn": (
"⚠️ memory headroom: warn. RAM use is past the soft "
"threshold; I'm fine but it'd be a good time to think "
"about housekeeping. Run `/memstatus` for the numbers."
),
"critical": (
"⚠️ memory headroom: **critical**. I need more RAM. "
"(No, really. Run `/memstatus` and consider `/forget`-"
"ting some points or raising `MEMORY_RAM_CRIT_BYTES`.)"
),
}
Der Kommentar oben drüber im Original-File ist eine kleine Lore-Notiz, die zeigt wie persona-bewusst Wintermute geschrieben wurde:
„Wintermute, you need more RAM." „I know, Case. I know."
We never warn twice in a row for the same headroom level. Once you've been told it's
warn, the next notice only fires if it improves tookthen worsens again, or escalates tocritical. This avoids the agent hand-wringing every fifty turns about the same condition.
Das Anti-Re-Announce-Verhalten ist nicht nur Lore — es ist auch Operations-Hygiene: ein Agent, der alle 50 Turns dasselbe Problem meldet, wird ignoriert. Wenn die Warnung nur bei Eskalation oder Wiederkehr feuert, hat sie noch Gewicht.
Konfigurierbar via:
MEMORY_RAM_WARN_BYTES(default 256 MB) →headroom: "warn"MEMORY_RAM_CRIT_BYTES(default 512 MB) →headroom: "critical"MEMORY_STATUS_EVERY_N(default 50) → wie oft gepollt wird
Empfehlungen für eigene Implementierungen
- Trenne die Schichten. Auch wenn dein erster Wurf nur Vector-Store hat — bau die Markdown-Schichten dazu, weil sie dir später beim Debuggen Gold wert sind.
- Auto-Save mit Filter, nicht ohne. Ein ungefilterter Auto-Save erzeugt nach Wochen einen Store, in dem 80% Rauschen ist. Filter sind keine Kür.
- Pins als Sicherheitsventil. Eine
/remember-artige Funktion, die den Filter umgeht, ist Gold wert. Manche Dinge weiß der User besser als jede Heuristik. - Symptom und Mechanismus speichern. Wenn dein Agent Erinnerungen schreibt, sollten beide Vokabulare drinstehen (siehe LESSONS §28).
- Self-Reporting einbauen.
memory_statusals Tool + periodische Selbstprüfung ist günstig zu implementieren und spart später Stunden Debugging. - Record-Formate nur auf verankerten Delimitern splitten. Wenn dein Agent seine eigene Historie parst, darf der Trenner nichts sein, was in den Nachrichten selbst vorkommen kann (siehe LESSONS §29).
📚 Quellen im Wintermute-Repo
agent/src/agent/memory.py— Daily-Log-Writer +_parse_messagesmit verankertem Delimiteragent/src/agent/memory_recall.py—recall_for_turn, Hit-Formatierungagent/src/agent/memory_save.py— Schedule + die vier Filter-Regeln + Pinned-Pfad (save_pinned)agent/src/agent/memory_health.py— Periodischesmemory_status-Polling, Notice-Logikmcp-gateway/src/gateway/tools/memory.py— Qdrant-Client-Wrapper,query_points-Pattern,memory_load_docdocs/IMAP-TOOLS.md— Abschnitt „Privacy & memory": Tool-Outputs erreichen den Vektor-Store nieLESSONS.md§8 Variante 2 (Embedding-Lauf vs. 30s-Timeout, leerer Error- Envelope), §15 (qdrant-client API-Break), §28 (Symptom-vs- Mechanismus-Vokabular bei Recall), §29 (Delimiter-Parsing der Konversations-Historie)