memctrl 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
memctrl/extractor.py ADDED
@@ -0,0 +1,261 @@
1
+ """MemCtrl — LLM-powered memory extraction from text.
2
+
3
+ Extracts structured memories with confidence scoring:
4
+ - Explicit (1.0): "we decided to use FastAPI"
5
+ - Inferred (0.7): "import fastapi" ← inferred from code
6
+ - Mentioned (0.5): "FastAPI was suggested" ← not yet decided
7
+
8
+ Security: NEVER extracts passwords, API keys, secrets, or PII.
9
+ Uses regex patterns for secret detection + redaction.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import uuid
16
+ from datetime import datetime, timedelta
17
+ from typing import Any, Callable, Coroutine, Dict, List, Optional
18
+
19
+ # Type alias
20
+ LLMCallable = Callable[[str, bool], Coroutine[Any, Any, str]]
21
+
22
+ # Secret patterns to redact/detect
23
+ _SECRET_PATTERNS = [
24
+ (r"\b(sk-[a-zA-Z0-9]{20,})\b", "API_KEY"),
25
+ (r"\b([A-Za-z0-9/+=]{40,})\b", "TOKEN"),
26
+ (r"\b(password\s*[=:]\s*\S+)", "PASSWORD"),
27
+ (r"\b(secret\s*[=:]\s*\S+)", "SECRET"),
28
+ (r"\b(AKIA[0-9A-Z]{16})\b", "AWS_KEY"),
29
+ (r"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----", "PRIVATE_KEY"),
30
+ ]
31
+
32
+ _PII_PATTERNS = [
33
+ (r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "EMAIL"),
34
+ (r"\b\d{3}-\d{2}-\d{4}\b", "SSN"),
35
+ (r"\b\d{3}-\d{3}-\d{4}\b", "PHONE"),
36
+ (r"\b\d{10,12}\b", "PHONE_INTL"),
37
+ ]
38
+
39
+
40
+ class MemoryExtractor:
41
+ """Extract structured memories from text with confidence scoring.
42
+
43
+ Distinguishes:
44
+ - Explicit facts (confidence=1.0): "we use FastAPI"
45
+ - Inferred facts (confidence=0.7): "import fastapi" ← from code
46
+ - Mentioned (confidence=0.5): "FastAPI was suggested"
47
+
48
+ NEVER extracts passwords, API keys, secrets, or PII.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ llm_client: Optional[LLMCallable] = None,
54
+ rules: Optional[Any] = None,
55
+ ):
56
+ self.llm_client = llm_client
57
+ self.rules = rules
58
+
59
+ # --- Public API ---
60
+
61
+ async def extract(
62
+ self,
63
+ text: str,
64
+ layer: str,
65
+ rules,
66
+ ) -> List[dict]:
67
+ """Extract structured memories from text.
68
+
69
+ text: source text (commit message, chat, file content)
70
+ layer: target layer (project/session/user)
71
+ rules: Rules object with confidence levels and forget.never
72
+
73
+ Returns list of dicts: {id, layer, content, source, confidence,
74
+ created_at, expires_at, tags}
75
+ """
76
+ # 1. Security scan
77
+ if self._has_secrets(text, rules.forget_never):
78
+ text = self._sanitize_text(text)
79
+
80
+ # 2. LLM extraction
81
+ if self.llm_client:
82
+ try:
83
+ memories = await self._llm_extract(text, layer, rules)
84
+ if memories:
85
+ return memories
86
+ except Exception:
87
+ pass # Fallback to heuristic
88
+
89
+ # 3. Fallback heuristic extraction
90
+ return self._fallback_extract(text, layer, rules)
91
+
92
+ # --- Security ---
93
+
94
+ def _has_secrets(self, text: str, never_list: List[str]) -> bool:
95
+ """Check if text contains forbidden patterns."""
96
+ text_lower = text.lower()
97
+ for pattern in never_list:
98
+ if pattern.lower() in text_lower:
99
+ return True
100
+ for pattern, _ in _SECRET_PATTERNS:
101
+ if re.search(pattern, text, re.I):
102
+ return True
103
+ return False
104
+
105
+ def _sanitize_text(self, text: str) -> str:
106
+ """Redact secrets and PII from text."""
107
+ for pattern, label in _SECRET_PATTERNS + _PII_PATTERNS:
108
+ text = re.sub(pattern, f"[REDACTED_{label}]", text, flags=re.I)
109
+ return text
110
+
111
+ def _detect_pii(self, text: str) -> List[str]:
112
+ """Detect PII in text. Returns list of found PII types."""
113
+ found = []
114
+ for pattern, label in _PII_PATTERNS:
115
+ if re.search(pattern, text):
116
+ found.append(label)
117
+ return found
118
+
119
+ # --- LLM extraction ---
120
+
121
+ async def _llm_extract(
122
+ self, text: str, layer: str, rules,
123
+ ) -> List[dict]:
124
+ """Use LLM to extract memories with confidence scoring."""
125
+ prompt = self._build_extraction_prompt(text, layer, rules)
126
+ response = await self.llm_client(prompt, json_mode=True)
127
+
128
+ try:
129
+ data = {"memories": []}
130
+ import json as _json
131
+ data = _json.loads(response)
132
+ except Exception:
133
+ return []
134
+
135
+ results = []
136
+ for mem in data.get("memories", []):
137
+ content = mem.get("content", "").strip()
138
+ if not content or len(content) < 5:
139
+ continue
140
+
141
+ # Final secret check
142
+ if any(p.lower() in content.lower() for p in rules.forget_never):
143
+ continue
144
+ if self._detect_pii(content):
145
+ continue
146
+
147
+ confidence = mem.get("confidence", 0.5)
148
+ # Clamp to valid levels
149
+ valid_levels = list(rules.confidence.values()) if rules.confidence else [0.5, 0.7, 1.0]
150
+ if valid_levels and confidence not in valid_levels:
151
+ confidence = min(valid_levels, key=lambda x: abs(x - confidence))
152
+
153
+ results.append({
154
+ "id": str(uuid.uuid4()),
155
+ "layer": layer,
156
+ "content": content,
157
+ "source": "llm_extract",
158
+ "confidence": confidence,
159
+ "created_at": datetime.now().isoformat(),
160
+ "expires_at": None,
161
+ "tags": mem.get("tags", [layer, "llm-extracted"]),
162
+ })
163
+
164
+ return results
165
+
166
+ def _build_extraction_prompt(self, text: str, layer: str, rules) -> str:
167
+ """Build LLM prompt for memory extraction."""
168
+ layer_desc = rules.layers.get(layer, layer)
169
+ explicit_c = rules.confidence.get("explicit", 1.0)
170
+ inferred_c = rules.confidence.get("inferred", 0.7)
171
+ mentioned_c = rules.confidence.get("mentioned", 0.5)
172
+
173
+ return (
174
+ f"Extract memories from the following text for the '{layer}' layer.\n\n"
175
+ f"Layer definition: {layer_desc}\n\n"
176
+ f"Text:\n{text[:3000]}\n\n" # Limit to 3K chars
177
+ f"Confidence levels:\n"
178
+ f" {explicit_c} = explicit statement (e.g., 'we decided to use X')\n"
179
+ f" {inferred_c} = inferred from context (e.g., 'import X')\n"
180
+ f" {mentioned_c} = mentioned but not decided\n\n"
181
+ f"NEVER extract: passwords, API keys, secrets, PII.\n\n"
182
+ f"Return ONLY JSON:\n"
183
+ f'{{"memories": [\n'
184
+ f' {{"content": "fact text", "confidence": {explicit_c}, '
185
+ f'"tags": ["{layer}"]}}\n'
186
+ f"]}}"
187
+ )
188
+
189
+ # --- Fallback extraction (no LLM) ---
190
+
191
+ def _fallback_extract(self, text: str, layer: str, rules) -> List[dict]:
192
+ """Non-LLM extraction using regex patterns."""
193
+ results = []
194
+ lines = text.split("\n")
195
+
196
+ patterns = [
197
+ # Explicit patterns (1.0)
198
+ (r"(?i)(we\s+(?:use|use[d]|chose|decided|migrated|switched|implemented)\s+.+)",
199
+ "explicit", "tech_choice"),
200
+ (r"(?i)(adr[-\s]?\d+\s*[:\-]?\s*.+)",
201
+ "explicit", "adr"),
202
+ (r"(?i)(decided\s+to\s+.+)",
203
+ "explicit", "decision"),
204
+ # Migration patterns
205
+ (r"(?i)(migrated?\s+(?:from\s+)?\w+\s+to\s+\w+.+)",
206
+ "explicit", "migration"),
207
+ # Inferred patterns (0.7)
208
+ (r"(?i)^\s*(?:import|from)\s+(\w+).+",
209
+ "inferred", "dependency"),
210
+ (r"(?i)(?:built|written|developed)\s+(?:with|on|using)\s+(\w+).+",
211
+ "inferred", "framework"),
212
+ # Preference patterns
213
+ (r"(?i)(?:prefer|like|always|never)\s+.+",
214
+ "explicit", "preference"),
215
+ ]
216
+
217
+ for line in lines:
218
+ line = line.strip()
219
+ if len(line) < 10:
220
+ continue
221
+ if len(line) > 500:
222
+ line = line[:500]
223
+
224
+ for pattern, level, tag in patterns:
225
+ match = re.search(pattern, line)
226
+ if match:
227
+ content = match.group(1) if match.groups() else match.group(0)
228
+ content = content.strip(". ;,\t")
229
+ if len(content) < 10:
230
+ continue
231
+
232
+ # Skip if contains secrets
233
+ if any(p.lower() in content.lower()
234
+ for p in rules.forget_never):
235
+ continue
236
+ if self._detect_pii(content):
237
+ continue
238
+
239
+ confidence = rules.confidence.get(level, 0.5)
240
+ results.append({
241
+ "id": str(uuid.uuid4()),
242
+ "layer": layer,
243
+ "content": content,
244
+ "source": "heuristic_extract",
245
+ "confidence": confidence,
246
+ "created_at": datetime.now().isoformat(),
247
+ "expires_at": None,
248
+ "tags": [layer, tag, level],
249
+ })
250
+ break # One match per line
251
+
252
+ # Deduplicate by content similarity
253
+ seen = set()
254
+ deduped = []
255
+ for mem in results:
256
+ key = mem["content"][:50].lower()
257
+ if key not in seen:
258
+ seen.add(key)
259
+ deduped.append(mem)
260
+
261
+ return deduped
memctrl/installer.py ADDED
@@ -0,0 +1,122 @@
1
+ """MemCtrl — SKILL.md installer for AI coding assistants.
2
+
3
+ Replicates Graphify's install pattern:
4
+ - `uv tool install graphifyy` → `pip install memctrl`
5
+ - `graphify install` → `memctrl install`
6
+ - Writes SKILL.md to ~/.claude/agent/skills/memctrl/SKILL.md etc.
7
+ - Auto-detects installed tools by checking config dir existence
8
+
9
+ Research: Graphify writes to ~/.claude/, .claude/, ~/.cursor/, .cursor/,
10
+ ~/.codex/, ~/.axga/, ~/.pi/ directories. Uses YAML frontmatter SKILL.md.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+ from pathlib import Path
17
+ from typing import List, Optional
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Tool paths
21
+ # ---------------------------------------------------------------------------
22
+
23
+ TOOL_PATHS = {
24
+ "claude_code": [
25
+ "~/.claude/agent/skills/memctrl/SKILL.md",
26
+ ".claude/agent/skills/memctrl/SKILL.md",
27
+ ],
28
+ "cursor": [
29
+ "~/.cursor/skills/memctrl/SKILL.md",
30
+ ".cursor/skills/memctrl/SKILL.md",
31
+ ],
32
+ "codex": [
33
+ "~/.codex/skills/memctrl/SKILL.md",
34
+ ],
35
+ "axga": [
36
+ "~/.axga/agent/skills/memctrl/SKILL.md",
37
+ ],
38
+ "pi": [
39
+ "~/.pi/agent/skills/memctrl/SKILL.md",
40
+ ],
41
+ }
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Install logic
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def detect_installed_tools() -> List[str]:
49
+ """Check which tool config directories exist. Returns tool names."""
50
+ installed = []
51
+ for tool_name, paths in TOOL_PATHS.items():
52
+ for path in paths:
53
+ expanded = Path(path).expanduser().resolve()
54
+ if expanded.parent.exists():
55
+ installed.append(tool_name)
56
+ break
57
+ return installed
58
+
59
+
60
+ def install_skill(
61
+ tool: Optional[str] = None,
62
+ project: bool = False,
63
+ verbose: bool = True,
64
+ ) -> List[str]:
65
+ """Install SKILL.md for specified tool or all detected tools.
66
+
67
+ Args:
68
+ tool: Specific tool name (claude_code, cursor, codex, etc.)
69
+ project: If True, install to project-level paths (e.g., .claude/)
70
+ verbose: Print summary
71
+
72
+ Returns:
73
+ List of paths where SKILL.md was installed.
74
+ """
75
+ skill_template = Path(__file__).parent / "templates" / "SKILL.md"
76
+ if not skill_template.exists():
77
+ if verbose:
78
+ print(f"[memctrl] ERROR: SKILL.md template not found at {skill_template}")
79
+ return []
80
+
81
+ targets = [tool] if tool else detect_installed_tools()
82
+ installed_paths: List[str] = []
83
+ summary: List[str] = []
84
+
85
+ for target in targets:
86
+ if target not in TOOL_PATHS:
87
+ if verbose:
88
+ print(f"[memctrl] Unknown tool: {target}")
89
+ continue
90
+
91
+ paths = TOOL_PATHS[target]
92
+ if project:
93
+ project_paths = [p for p in paths if not p.startswith("~/")]
94
+ for path in project_paths:
95
+ dest = Path(path)
96
+ dest.parent.mkdir(parents=True, exist_ok=True)
97
+ shutil.copy2(skill_template, dest)
98
+ installed_paths.append(str(dest))
99
+ summary.append(f" {target} (project): {dest}")
100
+ else:
101
+ user_paths = [p for p in paths if p.startswith("~/")]
102
+ for path in user_paths:
103
+ dest = Path(path).expanduser()
104
+ dest.parent.mkdir(parents=True, exist_ok=True)
105
+ shutil.copy2(skill_template, dest)
106
+ installed_paths.append(str(dest))
107
+ summary.append(f" {target} (user): {dest}")
108
+
109
+ if verbose:
110
+ if installed_paths:
111
+ print("[memctrl] SKILL.md installed to:")
112
+ for line in summary:
113
+ print(line)
114
+ else:
115
+ print("[memctrl] No tools detected. Install paths checked:")
116
+ for tool_name, paths in TOOL_PATHS.items():
117
+ for p in paths:
118
+ print(f" {tool_name}: {p}")
119
+ print("\nTo force install for a specific tool, use:")
120
+ print(" memctrl install --tool claude_code")
121
+
122
+ return installed_paths
@@ -0,0 +1,269 @@
1
+ """MemCtrl — LangGraph integration.
2
+
3
+ Provides checkpoint-style persistence and memory nodes for LangGraph agents.
4
+
5
+ Usage:
6
+ from memctrl.integrations.langgraph import MemoryNode, MemCtrlMemory
7
+
8
+ # As a LangGraph node
9
+ workflow.add_node("memory", MemoryNode())
10
+ workflow.add_edge("agent", "memory")
11
+
12
+ # As a memory manager inside any node
13
+ memory = MemCtrlMemory()
14
+ memory.remember("user prefers dark mode", layer="user")
15
+ facts = memory.recall("what does the user prefer?")
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ from memctrl.store import MemoryStore
25
+ from memctrl.tree import MemoryTreeBuilder
26
+ from memctrl.retriever import MemoryRetriever
27
+ from memctrl.rules import RuleEngine
28
+
29
+ # Optional LangGraph import with graceful degradation
30
+ try:
31
+ from langgraph.checkpoint.base import BaseCheckpointSaver
32
+ from langgraph.types import StateSnapshot
33
+ LANGGRAPH_AVAILABLE = True
34
+ except ImportError:
35
+ BaseCheckpointSaver = object
36
+ StateSnapshot = Any
37
+ LANGGRAPH_AVAILABLE = False
38
+
39
+
40
+ class MemCtrlMemory:
41
+ """High-level memory manager for LangGraph agents.
42
+
43
+ Wraps MemoryStore with async-friendly methods designed for agent nodes.
44
+ """
45
+
46
+ def __init__(self, db_path: Optional[str] = None):
47
+ self.store = MemoryStore(db_path)
48
+ self.builder = MemoryTreeBuilder()
49
+ self.retriever = MemoryRetriever()
50
+ self.engine = RuleEngine()
51
+
52
+ def remember(
53
+ self,
54
+ content: str,
55
+ layer: str = "session",
56
+ source: str = "langgraph",
57
+ confidence: float = 1.0,
58
+ tags: Optional[List[str]] = None,
59
+ ) -> str:
60
+ """Store a memory fact. Returns memory ID."""
61
+ return self.store.insert_memory(
62
+ layer=layer,
63
+ content=content,
64
+ source=source,
65
+ confidence=confidence,
66
+ tags=tags or [],
67
+ )
68
+
69
+ def recall(self, query: str, top_k: int = 5) -> List[str]:
70
+ """Retrieve relevant memory facts with reasoning trace."""
71
+ memories = [m.to_dict() for m in self.store.list_memories()]
72
+ if not memories:
73
+ return []
74
+
75
+ tree = asyncio.run(self.builder.build_tree(memories))
76
+ tree_dict = tree.to_dict() if tree else {}
77
+ memory_lookup = {m["id"]: m for m in memories}
78
+
79
+ result = asyncio.run(
80
+ self.retriever.retrieve(query, tree_dict, top_k=top_k, memory_lookup=memory_lookup)
81
+ )
82
+ return result.facts
83
+
84
+ def recall_with_trace(self, query: str, top_k: int = 5) -> Dict[str, Any]:
85
+ """Retrieve memories with full trace and metadata."""
86
+ memories = [m.to_dict() for m in self.store.list_memories()]
87
+ if not memories:
88
+ return {"facts": [], "trace": ["empty"], "confidence": 0.0}
89
+
90
+ tree = asyncio.run(self.builder.build_tree(memories))
91
+ tree_dict = tree.to_dict() if tree else {}
92
+ memory_lookup = {m["id"]: m for m in memories}
93
+
94
+ result = asyncio.run(
95
+ self.retriever.retrieve(query, tree_dict, top_k=top_k, memory_lookup=memory_lookup)
96
+ )
97
+ return {
98
+ "facts": result.facts,
99
+ "trace": result.trace,
100
+ "confidence": result.confidence,
101
+ }
102
+
103
+ def consolidate(self, event: str = "on_commit", context: Optional[Dict] = None) -> List[str]:
104
+ """Fire a trigger rule to consolidate memories."""
105
+ return self.engine.fire_trigger(event, context or {}, self.store)
106
+
107
+ def get_stats(self) -> Dict[str, Any]:
108
+ """Get memory store statistics."""
109
+ return self.store.stats()
110
+
111
+
112
+ class MemoryNode:
113
+ """LangGraph node that adds persistent memory capabilities.
114
+
115
+ Expects state dict with at least:
116
+ - "messages": list of message dicts (optional, for auto-extraction)
117
+ - "memory_query": str (optional, for explicit recall)
118
+ - "memory_facts": list (output, populated by this node)
119
+
120
+ Usage:
121
+ workflow.add_node("memory", MemoryNode())
122
+ workflow.add_edge("agent", "memory")
123
+ workflow.add_edge("memory", END)
124
+ """
125
+
126
+ def __init__(self, db_path: Optional[str] = None, auto_extract: bool = True):
127
+ self.memory = MemCtrlMemory(db_path)
128
+ self.auto_extract = auto_extract
129
+
130
+ def __call__(self, state: Dict[str, Any]) -> Dict[str, Any]:
131
+ """Process state: extract memories, answer queries, return enriched state."""
132
+ new_state = dict(state)
133
+
134
+ # Auto-extract from latest message if enabled
135
+ if self.auto_extract and "messages" in state:
136
+ messages = state["messages"]
137
+ if messages:
138
+ latest = messages[-1]
139
+ content = latest.get("content", "") if isinstance(latest, dict) else str(latest)
140
+ if len(content) > 20:
141
+ self.memory.remember(
142
+ content=content[:500],
143
+ layer="session",
144
+ source="langgraph_conversation",
145
+ confidence=0.7,
146
+ )
147
+
148
+ # Handle explicit memory queries
149
+ query = state.get("memory_query", "")
150
+ if query:
151
+ result = self.memory.recall_with_trace(query)
152
+ new_state["memory_facts"] = result["facts"]
153
+ new_state["memory_trace"] = result["trace"]
154
+ new_state["memory_confidence"] = result["confidence"]
155
+ else:
156
+ new_state.setdefault("memory_facts", [])
157
+ new_state.setdefault("memory_trace", [])
158
+ new_state.setdefault("memory_confidence", 0.0)
159
+
160
+ # Run consolidation if requested
161
+ if state.get("memory_consolidate"):
162
+ affected = self.memory.consolidate()
163
+ new_state["memory_consolidated"] = affected
164
+
165
+ return new_state
166
+
167
+
168
+ class MemCtrlSaver(BaseCheckpointSaver):
169
+ """LangGraph checkpoint saver backed by MemCtrl.
170
+
171
+ Uses MemoryStore to persist agent state across runs.
172
+ Provides hierarchical memory + traceability for every checkpoint.
173
+
174
+ Usage:
175
+ from langgraph.graph import StateGraph
176
+ from memctrl.integrations.langgraph import MemCtrlSaver
177
+
178
+ checkpointer = MemCtrlSaver()
179
+ app = workflow.compile(checkpointer=checkpointer)
180
+ """
181
+
182
+ def __init__(self, db_path: Optional[str] = None):
183
+ if not LANGGRAPH_AVAILABLE:
184
+ raise ImportError(
185
+ "LangGraph is required for MemCtrlSaver. "
186
+ 'Install with: pip install "memctrl[langgraph]"'
187
+ )
188
+ super().__init__()
189
+ self.store = MemoryStore(db_path)
190
+
191
+ def get_tuple(self, config: Dict[str, Any]) -> Optional[StateSnapshot]:
192
+ """Retrieve checkpoint by thread ID."""
193
+ thread_id = config.get("configurable", {}).get("thread_id", "default")
194
+ mem = self.store.get_memory(f"checkpoint:{thread_id}")
195
+ if not mem:
196
+ return None
197
+ try:
198
+ data = json.loads(mem.content)
199
+ return StateSnapshot(
200
+ values=data.get("values", {}),
201
+ next=data.get("next", []),
202
+ config=config,
203
+ metadata=data.get("metadata", {}),
204
+ created_at=mem.created_at,
205
+ parent_config=data.get("parent_config"),
206
+ tasks=data.get("tasks", []),
207
+ )
208
+ except Exception:
209
+ return None
210
+
211
+ def put(
212
+ self,
213
+ config: Dict[str, Any],
214
+ checkpoint: Dict[str, Any],
215
+ metadata: Dict[str, Any],
216
+ new_versions: Any,
217
+ ) -> Dict[str, Any]:
218
+ """Store checkpoint."""
219
+ thread_id = config.get("configurable", {}).get("thread_id", "default")
220
+ data = {
221
+ "values": checkpoint.get("values", {}),
222
+ "next": checkpoint.get("next", []),
223
+ "metadata": metadata,
224
+ "parent_config": checkpoint.get("parent_config"),
225
+ "tasks": checkpoint.get("tasks", []),
226
+ }
227
+ # Upsert: delete old then insert
228
+ self.store.delete_memory(f"checkpoint:{thread_id}")
229
+ self.store.insert_memory(
230
+ layer="session",
231
+ content=json.dumps(data),
232
+ source=f"checkpoint:{thread_id}",
233
+ confidence=1.0,
234
+ tags=["langgraph", "checkpoint", thread_id],
235
+ )
236
+ return config
237
+
238
+ def list(
239
+ self,
240
+ config: Optional[Dict[str, Any]],
241
+ *,
242
+ before: Optional[Dict[str, Any]] = None,
243
+ limit: Optional[int] = None,
244
+ filter: Optional[Dict[str, Any]] = None,
245
+ ) -> List[StateSnapshot]:
246
+ """List checkpoints (returns session-layer checkpoints)."""
247
+ memories = self.store.list_memories("session")
248
+ results = []
249
+ for mem in memories:
250
+ if not mem.source.startswith("checkpoint:"):
251
+ continue
252
+ try:
253
+ data = json.loads(mem.content)
254
+ results.append(
255
+ StateSnapshot(
256
+ values=data.get("values", {}),
257
+ next=data.get("next", []),
258
+ config=config or {},
259
+ metadata=data.get("metadata", {}),
260
+ created_at=mem.created_at,
261
+ parent_config=data.get("parent_config"),
262
+ tasks=data.get("tasks", []),
263
+ )
264
+ )
265
+ except Exception:
266
+ continue
267
+ if limit:
268
+ results = results[:limit]
269
+ return results