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/__init__.py +19 -0
- memctrl/cli.py +443 -0
- memctrl/extractor.py +261 -0
- memctrl/installer.py +122 -0
- memctrl/integrations/langgraph.py +269 -0
- memctrl/mcp_server.py +231 -0
- memctrl/retriever.py +267 -0
- memctrl/rules.py +330 -0
- memctrl/store.py +461 -0
- memctrl/templates/SKILL.md +63 -0
- memctrl/templates/__init__.py +0 -0
- memctrl/tree.py +257 -0
- memctrl-1.0.0.dist-info/METADATA +356 -0
- memctrl-1.0.0.dist-info/RECORD +17 -0
- memctrl-1.0.0.dist-info/WHEEL +4 -0
- memctrl-1.0.0.dist-info/entry_points.txt +2 -0
- memctrl-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|