yc1838 commited on
Commit
b04f018
·
1 Parent(s): f2975c9

docs(superpowers): add LangMem persistent memory design and plan

Browse files
docs/superpowers/plans/2026-04-26-langmem-persistent-memory-plan.md ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LangMem Persistent Memory Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Implement a three-tiered persistent memory system (Short-term, Semantic, Episodic) for the Lilith Agent using LangGraph's SqliteSaver and LangMem.
6
+
7
+ **Architecture:**
8
+ 1. Replace in-memory `MemorySaver` with `SqliteSaver` pointing to `.lilith/threads.sqlite` for short-term thread persistence.
9
+ 2. Integrate `langmem` to extract facts (Semantic) and task summaries (Episodic) in the background at the end of runs, compressing/merging them to prevent bloat.
10
+ 3. Query the LangMem vector store at the start of new tasks and inject relevant context into the initial `SystemMessage`.
11
+
12
+ **Tech Stack:** `langgraph-checkpoint-sqlite`, `langmem`, `sqlite3`, `chromadb` (or `langmem` default local storage)
13
+
14
+ ---
15
+
16
+ ### Task 1: Setup Dependencies and Short-Term Thread Persistence
17
+
18
+ **Files:**
19
+ - Modify: `pyproject.toml:10-33`
20
+ - Modify: `src/lilith_agent/app.py`
21
+ - Modify: `src/lilith_agent/tui.py`
22
+ - Create: `tests/test_memory_persistence.py`
23
+
24
+ - [ ] **Step 1: Add new dependencies**
25
+
26
+ ```bash
27
+ # Update pyproject.toml to include dependencies
28
+ sed -i '' '/"langgraph>=1.0,<2.0",/a \
29
+ "langgraph-checkpoint-sqlite>=1.0,<2.0",\
30
+ "langmem>=0.0.1",
31
+ ' pyproject.toml
32
+ ```
33
+
34
+ - [ ] **Step 2: Write test for SqliteSaver initialization**
35
+
36
+ ```python
37
+ # tests/test_memory_persistence.py
38
+ import pytest
39
+ from pathlib import Path
40
+ from lilith_agent.config import Config
41
+ from lilith_agent.app import build_react_agent
42
+
43
+ def test_build_react_agent_uses_sqlite_saver(tmp_path):
44
+ cfg = Config(
45
+ cheap_provider="google",
46
+ cheap_model="gemini-3-flash-preview",
47
+ strong_provider="google",
48
+ strong_model="gemini-3-flash-preview",
49
+ extra_strong_provider="google",
50
+ extra_strong_model="gemini-3-flash-preview"
51
+ )
52
+
53
+ # Temporarily override where the agent looks for the .lilith dir
54
+ import os
55
+ os.environ["LILITH_HOME"] = str(tmp_path / ".lilith")
56
+
57
+ agent = build_react_agent(cfg)
58
+
59
+ assert agent.checkpointer is not None
60
+ assert type(agent.checkpointer).__name__ == "SqliteSaver"
61
+
62
+ # Check if DB file was created
63
+ db_path = tmp_path / ".lilith" / "threads.sqlite"
64
+ assert db_path.exists()
65
+ ```
66
+
67
+ - [ ] **Step 3: Run the failing test**
68
+
69
+ Run: `pytest tests/test_memory_persistence.py -v`
70
+ Expected: FAIL
71
+
72
+ - [ ] **Step 4: Implement SqliteSaver in app.py**
73
+
74
+ Modify `src/lilith_agent/app.py`:
75
+
76
+ ```python
77
+ # Add imports
78
+ import os
79
+ from pathlib import Path
80
+ from langgraph.checkpoint.sqlite import SqliteSaver
81
+ import sqlite3
82
+
83
+ # Modify build_react_agent
84
+ def build_react_agent(cfg: Config):
85
+ # ... existing code ...
86
+
87
+ graph = StateGraph(AgentState)
88
+ # ... existing graph node/edge setup ...
89
+
90
+ # Setup SQLite Saver
91
+ lilith_home = Path(os.getenv("LILITH_HOME", ".lilith"))
92
+ lilith_home.mkdir(parents=True, exist_ok=True)
93
+ db_path = lilith_home / "threads.sqlite"
94
+
95
+ # Note: in a production app you might manage connection lifecycle differently,
96
+ # but for CLI a persistent connection during the app lifetime works.
97
+ conn = sqlite3.connect(str(db_path), check_same_thread=False)
98
+ memory_saver = SqliteSaver(conn)
99
+
100
+ compiled = graph.compile(checkpointer=memory_saver)
101
+ return compiled.with_config({"recursion_limit": cfg.recursion_limit})
102
+ ```
103
+
104
+ - [ ] **Step 5: Run the test again to verify it passes**
105
+
106
+ Run: `pytest tests/test_memory_persistence.py -v`
107
+ Expected: PASS
108
+
109
+ - [ ] **Step 6: Commit**
110
+
111
+ ```bash
112
+ git add pyproject.toml src/lilith_agent/app.py tests/test_memory_persistence.py
113
+ git commit -m "feat: replace MemorySaver with SqliteSaver for thread persistence"
114
+ ```
115
+
116
+ ---
117
+
118
+ ### Task 2: Implement Semantic Memory Extraction (LangMem Background)
119
+
120
+ **Files:**
121
+ - Create: `src/lilith_agent/memory.py`
122
+ - Modify: `src/lilith_agent/app.py`
123
+
124
+ - [ ] **Step 1: Create memory.py module with extraction logic**
125
+
126
+ ```python
127
+ # src/lilith_agent/memory.py
128
+ import os
129
+ import logging
130
+ from pathlib import Path
131
+ import langmem
132
+ from typing import List, Dict, Any
133
+ from langchain_core.messages import BaseMessage, AIMessage, HumanMessage
134
+
135
+ log = logging.getLogger(__name__)
136
+
137
+ # Initialize local langmem client
138
+ lilith_home = Path(os.getenv("LILITH_HOME", ".lilith"))
139
+ langmem.init(local_dir=str(lilith_home / "memory"))
140
+
141
+ def extract_and_compress_facts(messages: List[BaseMessage], model) -> None:
142
+ """
143
+ Extracts new facts from the conversation and merges/compresses them
144
+ with existing semantic memory to prevent bloat.
145
+ """
146
+ log.info("[memory] Extracting semantic facts from thread...")
147
+ try:
148
+ # Convert messages to dict format expected by some extraction prompts
149
+ conv_str = "\n".join([f"{m.type}: {m.content}" for m in messages if m.content])
150
+
151
+ # We use a simple prompt to extract facts and deduplicate.
152
+ # In a full langmem setup, we'd use their create_memory_manager or memory schemas.
153
+ # For this local implementation, we'll use a direct extraction approach:
154
+
155
+ prompt = f"""
156
+ Extract any persistent facts, preferences, or knowledge about the user, the project,
157
+ or the environment from this conversation.
158
+ Focus ONLY on static knowledge (e.g., 'User prefers Python', 'API Key is X').
159
+ Ignore dynamic reasoning or temporary states.
160
+
161
+ Conversation:
162
+ {conv_str}
163
+
164
+ Output as a JSON list of strings. If no facts, output [].
165
+ """
166
+
167
+ response = model.invoke(prompt)
168
+ # Parse JSON and save via langmem (Implementation detail depends on langmem API version)
169
+ # Placeholder for actual langmem SDK call:
170
+ # facts = json.loads(response.content)
171
+ # for fact in facts:
172
+ # langmem.save_fact(content=fact, namespace="lilith_semantic")
173
+
174
+ log.info("[memory] Extraction complete.")
175
+ except Exception as e:
176
+ log.error(f"[memory] Failed to extract facts: {e}")
177
+
178
+ ```
179
+
180
+ - [ ] **Step 2: Hook up extraction in the graph (End of run)**
181
+
182
+ Modify `src/lilith_agent/app.py` to trigger this after the final answer.
183
+ (Since the current graph just returns END when there are no tool calls, we can wrap the invocation or add an `extract_memory` node that runs before END).
184
+
185
+ ```python
186
+ # In src/lilith_agent/app.py
187
+ from lilith_agent.memory import extract_and_compress_facts
188
+
189
+ # Modify build_react_agent to add an extraction node
190
+ def build_react_agent(cfg: Config):
191
+ # ... setup tools & models ...
192
+ cheap_model = get_cheap_model(cfg)
193
+
194
+ # ... model_node, tool_node, fail_safe_node ...
195
+
196
+ def extract_memory_node(state):
197
+ # Run fact extraction asynchronously or synchronously at the end
198
+ extract_and_compress_facts(state["messages"], cheap_model)
199
+ return state
200
+
201
+ graph = StateGraph(AgentState)
202
+ graph.add_node("model", model_node)
203
+ graph.add_node("tools", tool_node)
204
+ graph.add_node("fail_safe", fail_safe_node)
205
+ graph.add_node("extract_memory", extract_memory_node) # NEW
206
+
207
+ def _router(state) -> str:
208
+ if state.get("iterations", 0) >= cfg.recursion_limit - 2:
209
+ return "fail_safe"
210
+ if _count_tool_calls_since_last_human(state["messages"]) >= cfg.budget_hard_cap:
211
+ return "fail_safe"
212
+ last = state["messages"][-1]
213
+ if isinstance(last, AIMessage) and getattr(last, "tool_calls", None):
214
+ return "tools"
215
+ return "extract_memory" # Route to memory before ending
216
+
217
+ graph.set_entry_point("model")
218
+ graph.add_conditional_edges("model", _router, {
219
+ "tools": "tools",
220
+ "fail_safe": "fail_safe",
221
+ "extract_memory": "extract_memory"
222
+ })
223
+ graph.add_edge("tools", "model")
224
+ graph.add_edge("fail_safe", "extract_memory")
225
+ graph.add_edge("extract_memory", END) # End after memory
226
+
227
+ # ... compile ...
228
+ ```
229
+
230
+ - [ ] **Step 3: Commit**
231
+
232
+ ```bash
233
+ git add src/lilith_agent/app.py src/lilith_agent/memory.py
234
+ git commit -m "feat: add semantic memory extraction node using langmem"
235
+ ```
236
+
237
+ ---
238
+
239
+ ### Task 3: Implement Episodic Memory Summarization
240
+
241
+ **Files:**
242
+ - Modify: `src/lilith_agent/memory.py`
243
+
244
+ - [ ] **Step 1: Add episodic summarization logic**
245
+
246
+ ```python
247
+ # In src/lilith_agent/memory.py
248
+ def summarize_episode(messages: List[BaseMessage], model) -> None:
249
+ """
250
+ Summarizes the trajectory of the task to learn from past experiences.
251
+ """
252
+ log.info("[memory] Summarizing task episode...")
253
+ try:
254
+ # Extract the initial question
255
+ initial_question = ""
256
+ for m in messages:
257
+ if isinstance(m, HumanMessage):
258
+ initial_question = str(m.content)
259
+ break
260
+
261
+ conv_str = "\n".join([f"{m.type}: {m.content[:200]}..." for m in messages if m.content])
262
+
263
+ prompt = f"""
264
+ Summarize the trajectory of this task to help a future agent avoid mistakes and repeat successes.
265
+ Include:
266
+ 1. Task description
267
+ 2. Tools used and why
268
+ 3. Errors encountered and how they were bypassed
269
+ 4. Final outcome
270
+
271
+ Initial Question: {initial_question}
272
+ Trajectory:
273
+ {conv_str}
274
+ """
275
+
276
+ response = model.invoke(prompt)
277
+ # Placeholder for actual langmem SDK call:
278
+ # langmem.save_episode(summary=response.content, task=initial_question)
279
+
280
+ log.info("[memory] Episode summarized.")
281
+ except Exception as e:
282
+ log.error(f"[memory] Failed to summarize episode: {e}")
283
+
284
+ # Update the node function
285
+ def extract_and_compress_facts(messages: List[BaseMessage], model) -> None:
286
+ # ... existing facts logic ...
287
+ summarize_episode(messages, model)
288
+ ```
289
+
290
+ - [ ] **Step 2: Commit**
291
+
292
+ ```bash
293
+ git add src/lilith_agent/memory.py
294
+ git commit -m "feat: add episodic task summarization"
295
+ ```
296
+
297
+ ---
298
+
299
+ ### Task 4: Inject Retrieved Memory into System Prompt
300
+
301
+ **Files:**
302
+ - Modify: `src/lilith_agent/memory.py`
303
+ - Modify: `src/lilith_agent/app.py`
304
+
305
+ - [ ] **Step 1: Implement Retrieval Logic**
306
+
307
+ ```python
308
+ # In src/lilith_agent/memory.py
309
+ def retrieve_relevant_context(query: str) -> str:
310
+ """
311
+ Queries the semantic and episodic memory banks for relevant facts and past experiences.
312
+ """
313
+ try:
314
+ # Placeholder for actual langmem SDK sparse retrieval:
315
+ # facts = langmem.search_facts(query, top_k=3)
316
+ # episodes = langmem.search_episodes(query, top_k=1)
317
+
318
+ facts = [] # stub
319
+ episodes = [] # stub
320
+
321
+ context_parts = []
322
+ if facts:
323
+ context_parts.append("<relevant_facts>\n" + "\n".join(f"- {f}" for f in facts) + "\n</relevant_facts>")
324
+ if episodes:
325
+ context_parts.append("<past_experiences>\n" + "\n".join(f"- {e}" for e in episodes) + "\n</past_experiences>")
326
+
327
+ return "\n\n".join(context_parts)
328
+ except Exception as e:
329
+ log.error(f"[memory] Retrieval failed: {e}")
330
+ return ""
331
+ ```
332
+
333
+ - [ ] **Step 2: Inject into SystemMessage**
334
+
335
+ Modify `src/lilith_agent/app.py` in the `model_node`:
336
+
337
+ ```python
338
+ # In src/lilith_agent/app.py
339
+ from lilith_agent.memory import retrieve_relevant_context
340
+
341
+ def build_react_agent(cfg: Config):
342
+ # ...
343
+ def model_node(state):
344
+ from langchain_core.messages import SystemMessage
345
+
346
+ # ... existing initial_question extraction ...
347
+
348
+ # Retrieve context ONLY on the first iteration of a new question
349
+ iteration = state.get("iterations", 0)
350
+ memory_context = ""
351
+ if iteration == 0 and initial_question:
352
+ memory_context = retrieve_relevant_context(initial_question)
353
+
354
+ base_prompt = (
355
+ "You are Lilith, an autonomous ReAct research assistant operating in a continuous session.\n\n"
356
+ # ... existing directives ...
357
+ )
358
+
359
+ if memory_context:
360
+ base_prompt += "\n\nCRITICAL CONTEXT (Retrieved from Long-Term Memory):\n" + memory_context
361
+
362
+ sys_prompt = apply_caveman(base_prompt, cfg.caveman, cfg.caveman_mode)
363
+ sys_msg = SystemMessage(sys_prompt)
364
+
365
+ # ... rest of model_node ...
366
+ ```
367
+
368
+ - [ ] **Step 3: Commit**
369
+
370
+ ```bash
371
+ git add src/lilith_agent/memory.py src/lilith_agent/app.py
372
+ git commit -m "feat: inject retrieved semantic and episodic memory into system prompt"
373
+ ```
docs/superpowers/specs/2026-04-26-langmem-persistent-memory-design.md ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Persistent Memory Design for Lilith Agent (LangMem)
2
+
3
+ ## Overview
4
+
5
+ This document outlines the design for a three-tiered persistent memory system for the Lilith Agent, utilizing `langmem` and inspired by DeepSeek V4's architecture to prevent memory explosion.
6
+
7
+ The goal is to enable Lilith to:
8
+ 1. **Resume interrupted conversations** (Short-Term/Thread Persistence).
9
+ 2. **Remember user preferences and static facts** across sessions (Semantic Memory).
10
+ 3. **Learn from past complex tasks** to avoid repeating mistakes (Episodic Memory).
11
+
12
+ ## Architecture: Three-Tiered Memory
13
+
14
+ Inspired by DeepSeek V4's separation of static knowledge (Engram) from dynamic reasoning, and its aggressive compression mechanisms (HCA), we will implement the following tiers:
15
+
16
+ ### 1. Short-Term Memory (Thread Persistence)
17
+ * **Purpose:** Allows resuming the exact state of a conversation after the CLI/TUI is closed.
18
+ * **Mechanism:** Replace LangGraph's in-memory `MemorySaver` with `SqliteSaver`.
19
+ * **Storage:** Local SQLite database at `.lilith/threads.sqlite`.
20
+ * **Implementation:**
21
+ * Add `langgraph-checkpoint-sqlite` to dependencies.
22
+ * Initialize `SqliteSaver(conn)` in `app.py::build_react_agent`.
23
+ * Ensure the TUI and batch runner correctly pass and reuse `thread_id`s.
24
+
25
+ ### 2. Semantic Memory (Static Knowledge / "Engram")
26
+ * **Purpose:** Stores atomic facts, user preferences, and environmental knowledge extracted from conversations (e.g., "User prefers Python 3.11", "API key X is used for service Y").
27
+ * **Mechanism:** Background extraction and active compression using `langmem`.
28
+ * **Storage:** Local vector store (e.g., Chroma or local SQLite + vector extensions) managed by `langmem` at `.lilith/semantic_memory`.
29
+ * **Implementation:**
30
+ * Add `langmem` to dependencies.
31
+ * Create an asynchronous background task that runs at the end of a successful graph execution (or periodically).
32
+ * Use the `cheap_model` (e.g., `gemini-3-flash-preview`) to extract new facts from the recent conversation.
33
+ * **Anti-Bloat Compression (HCA equivalent):** Before saving, query the vector store for similar existing facts. Instruct the model to merge, update, or delete existing facts to resolve contradictions and maintain a highly compressed, deduplicated knowledge base.
34
+ * **Sparse Retrieval (Lightning Indexer equivalent):** At the start of a new task, embed the user's query, perform a Top-K (e.g., K=3) search against the semantic memory, and inject only the relevant facts into the `SystemMessage`.
35
+
36
+ ### 3. Episodic Memory (Task Experiences)
37
+ * **Purpose:** Remembers the trajectories and outcomes of complex tasks (e.g., "When parsing a GAIA PDF, `pypdf` failed but `pdfplumber` worked").
38
+ * **Mechanism:** Summarization of successful (or informatively failed) task executions.
39
+ * **Storage:** Local vector store managed by `langmem` at `.lilith/episodic_memory`.
40
+ * **Implementation:**
41
+ * When the agent reaches the `END` node with a final answer, trigger an episodic summarizer (using the `cheap_model`).
42
+ * The summarizer condenses the entire ReAct trajectory (tools used, errors encountered, successful path) into a concise "episode" summary.
43
+ * **Sparse Retrieval:** Similar to semantic memory, fetch Top-K (e.g., K=1 or 2) relevant past episodes based on the new task's initial query and inject them into the system prompt as historical context.
44
+
45
+ ## Component Interactions
46
+
47
+ 1. **Start of Task:**
48
+ * User inputs a query.
49
+ * Agent embeds the query and searches Semantic and Episodic memory.
50
+ * Agent constructs the initial `SystemMessage`, injecting retrieved facts and past episodes.
51
+ 2. **During Task (Reasoning):**
52
+ * Agent executes the ReAct loop, saving state to the `SqliteSaver` (Short-Term memory) at each step.
53
+ * Context is managed using existing compaction logic to prevent context window explosion.
54
+ 3. **End of Task (Extraction & Compression):**
55
+ * Task concludes (success or failure).
56
+ * Background process reads the thread history from `SqliteSaver`.
57
+ * **Extract:** Identify new facts and summarize the episode.
58
+ * **Compress:** Deduplicate and merge new facts with existing facts in the Semantic vector store.
59
+ * **Store:** Save the updated facts and the new episode summary.
60
+
61
+ ## Data Schema (Conceptual)
62
+
63
+ **Semantic Fact (Engram)**
64
+ ```json
65
+ {
66
+ "id": "uuid",
67
+ "content": "User strictly uses Python 3.11 for all scripts.",
68
+ "type": "preference",
69
+ "last_updated": "2026-04-26T...",
70
+ "embedding": [0.1, 0.2, ...]
71
+ }
72
+ ```
73
+
74
+ **Episode Summary**
75
+ ```json
76
+ {
77
+ "id": "uuid",
78
+ "task_description": "Extract text from a scanned PDF for GAIA benchmark.",
79
+ "summary": "Attempted pypdf first, which failed due to scanned image format. Pivoted to using OCR via inspect_visual_content, which successfully extracted the text.",
80
+ "outcome": "success",
81
+ "timestamp": "2026-04-26T...",
82
+ "embedding": [0.3, 0.4, ...]
83
+ }
84
+ ```
85
+
86
+ ## Dependencies
87
+ * `langgraph-checkpoint-sqlite`
88
+ * `langmem`
89
+ * Vector database client (e.g., `chromadb` or equivalent compatible with `langmem` for local storage).
90
+
91
+ ## Next Steps (Implementation Plan)
92
+ 1. Implement `SqliteSaver` for thread persistence.
93
+ 2. Set up the `langmem` infrastructure (local vector store).
94
+ 3. Implement Semantic Memory extraction and sparse retrieval.
95
+ 4. Implement active compression/merging logic for facts to prevent bloat.
96
+ 5. Implement Episodic Memory summarization and retrieval.