claude-memory-agent 3.0.3 → 3.2.0
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.
- package/bin/lib/installer.js +3 -1
- package/bin/lib/steps/advanced.js +1 -1
- package/config.py +10 -2
- package/dashboard.html +88 -38
- package/hooks/auto-detect-response.py +3 -6
- package/hooks/detect-correction.py +8 -7
- package/hooks/extract_memories.py +104 -0
- package/hooks/grounding-hook-v2.py +169 -33
- package/hooks/grounding-hook.py +19 -3
- package/hooks/log-tool-use.py +3 -6
- package/hooks/log-user-request.py +14 -6
- package/hooks/pre-tool-decision.py +3 -6
- package/hooks/pre_compact_hook.py +269 -5
- package/hooks/session_end_hook.py +37 -0
- package/hooks/session_start.py +10 -0
- package/hooks/stop_hook.py +315 -14
- package/install.py +141 -46
- package/main.py +522 -13
- package/mcp_proxy.py +93 -6
- package/package.json +2 -2
- package/services/agent_registry.py +260 -12
- package/services/database.py +453 -1
- package/services/embeddings.py +1 -1
- package/services/retry_queue.py +5 -1
- package/services/soul.py +791 -0
- package/services/vector_index.py +5 -1
- package/update_system.py +34 -8
|
@@ -9,19 +9,267 @@ discards conversation turns.
|
|
|
9
9
|
This script:
|
|
10
10
|
1. Reads hook JSON from stdin (session_id, transcript_path, etc.)
|
|
11
11
|
2. Delegates to extract_memories.py for the actual extraction
|
|
12
|
-
3.
|
|
12
|
+
3. Forces a checkpoint creation before compaction
|
|
13
|
+
4. Calls /api/session/brain-dump to get full session state
|
|
14
|
+
5. Writes structured brain dump to native MEMORY.md
|
|
15
|
+
6. Exits 0 on success OR failure (never blocks compaction)
|
|
13
16
|
|
|
14
|
-
Timing budget: <
|
|
17
|
+
Timing budget: < 8 seconds total (5s extraction + 3s brain dump).
|
|
15
18
|
"""
|
|
16
19
|
|
|
17
20
|
import sys
|
|
18
21
|
import json
|
|
19
22
|
import os
|
|
23
|
+
import re
|
|
20
24
|
import time
|
|
25
|
+
import hashlib
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from datetime import datetime
|
|
21
28
|
|
|
22
29
|
# Ensure the hooks directory is on the path
|
|
23
30
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
24
31
|
|
|
32
|
+
MEMORY_AGENT_URL = os.getenv("MEMORY_AGENT_URL", "http://localhost:8102")
|
|
33
|
+
API_KEY = os.getenv("MEMORY_API_KEY", "")
|
|
34
|
+
BRAIN_DUMP_TIMEOUT = 3 # seconds
|
|
35
|
+
|
|
36
|
+
# Import the canonical slug computation from the services layer
|
|
37
|
+
_AGENT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
|
|
38
|
+
sys.path.insert(0, _AGENT_DIR)
|
|
39
|
+
try:
|
|
40
|
+
from services.native_memory_paths import get_native_memory_md as _canonical_get_memory_md
|
|
41
|
+
_HAS_NATIVE_PATHS = True
|
|
42
|
+
except ImportError:
|
|
43
|
+
_HAS_NATIVE_PATHS = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _compute_project_slug(project_path: str) -> str:
|
|
47
|
+
"""Compute the Claude Code project slug for MEMORY.md path.
|
|
48
|
+
|
|
49
|
+
Claude Code stores project memory at:
|
|
50
|
+
~/.claude/projects/<slug>/memory/MEMORY.md
|
|
51
|
+
|
|
52
|
+
Algorithm (reverse-engineered from actual Claude Code directories):
|
|
53
|
+
C:\\foo\\bar -> C--foo-bar (drive colon+sep becomes --)
|
|
54
|
+
/home/foo -> home-foo (leading / stripped)
|
|
55
|
+
"""
|
|
56
|
+
p = project_path.replace("\\", "/").rstrip("/")
|
|
57
|
+
# Handle Windows drive letter: C:/ -> C--
|
|
58
|
+
if len(p) >= 2 and p[1] == ":":
|
|
59
|
+
drive = p[0]
|
|
60
|
+
rest = p[2:].lstrip("/")
|
|
61
|
+
slug = f"{drive}--{rest}"
|
|
62
|
+
else:
|
|
63
|
+
slug = p.lstrip("/")
|
|
64
|
+
return slug.replace("/", "-").replace(" ", "-")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _get_memory_md_path(project_path: str) -> Path:
|
|
68
|
+
"""Get the native MEMORY.md path for a project."""
|
|
69
|
+
if _HAS_NATIVE_PATHS:
|
|
70
|
+
return _canonical_get_memory_md(project_path)
|
|
71
|
+
# Fallback if import failed
|
|
72
|
+
slug = _compute_project_slug(project_path)
|
|
73
|
+
return Path.home() / ".claude" / "projects" / slug / "memory" / "MEMORY.md"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _force_checkpoint(session_id: str, project_path: str):
|
|
77
|
+
"""Force a checkpoint creation via A2A. 1s timeout, silent fail."""
|
|
78
|
+
import urllib.request
|
|
79
|
+
import urllib.error
|
|
80
|
+
|
|
81
|
+
payload = json.dumps({
|
|
82
|
+
"jsonrpc": "2.0",
|
|
83
|
+
"id": f"pre-compact-checkpoint-{int(time.time())}",
|
|
84
|
+
"method": "tasks/send",
|
|
85
|
+
"params": {
|
|
86
|
+
"message": {"parts": [{"type": "text", "text": ""}]},
|
|
87
|
+
"metadata": {
|
|
88
|
+
"skill_id": "checkpoint_create",
|
|
89
|
+
"params": {
|
|
90
|
+
"session_id": session_id,
|
|
91
|
+
"summary": "Pre-compaction checkpoint (auto)",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
}).encode("utf-8")
|
|
96
|
+
|
|
97
|
+
headers = {"Content-Type": "application/json"}
|
|
98
|
+
if API_KEY:
|
|
99
|
+
headers["X-Memory-Key"] = API_KEY
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
req = urllib.request.Request(
|
|
103
|
+
f"{MEMORY_AGENT_URL}/a2a",
|
|
104
|
+
data=payload, headers=headers, method="POST",
|
|
105
|
+
)
|
|
106
|
+
urllib.request.urlopen(req, timeout=1.5)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _get_brain_dump(session_id: str, project_path: str) -> dict:
|
|
112
|
+
"""Call /api/session/brain-dump to get full session state."""
|
|
113
|
+
import urllib.request
|
|
114
|
+
import urllib.error
|
|
115
|
+
|
|
116
|
+
payload = json.dumps({
|
|
117
|
+
"session_id": session_id,
|
|
118
|
+
"project_path": project_path,
|
|
119
|
+
}).encode("utf-8")
|
|
120
|
+
|
|
121
|
+
headers = {"Content-Type": "application/json"}
|
|
122
|
+
if API_KEY:
|
|
123
|
+
headers["X-Memory-Key"] = API_KEY
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
req = urllib.request.Request(
|
|
127
|
+
f"{MEMORY_AGENT_URL}/api/session/brain-dump",
|
|
128
|
+
data=payload, headers=headers, method="POST",
|
|
129
|
+
)
|
|
130
|
+
with urllib.request.urlopen(req, timeout=BRAIN_DUMP_TIMEOUT) as resp:
|
|
131
|
+
if resp.status == 200:
|
|
132
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
return {}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _format_brain_dump(dump: dict) -> str:
|
|
139
|
+
"""Format brain dump dict into structured markdown for MEMORY.md."""
|
|
140
|
+
lines = []
|
|
141
|
+
timestamp = dump.get("timestamp", datetime.now().isoformat())
|
|
142
|
+
ts_short = timestamp[:16].replace("T", " ")
|
|
143
|
+
|
|
144
|
+
lines.append("<!-- SESSION-BRAIN-DUMP START -->")
|
|
145
|
+
lines.append(f"## Last Session ({ts_short})")
|
|
146
|
+
|
|
147
|
+
# Goal
|
|
148
|
+
state = dump.get("state")
|
|
149
|
+
if state and isinstance(state, dict):
|
|
150
|
+
goal = state.get("current_goal", "")
|
|
151
|
+
if goal:
|
|
152
|
+
lines.append(f"**Goal:** {goal[:120]}")
|
|
153
|
+
lines.append("")
|
|
154
|
+
|
|
155
|
+
# Decisions
|
|
156
|
+
if state and isinstance(state, dict):
|
|
157
|
+
decisions = state.get("decisions_summary", "")
|
|
158
|
+
if decisions:
|
|
159
|
+
lines.append("### Decisions")
|
|
160
|
+
for d in decisions.split("\n"):
|
|
161
|
+
d = d.strip()
|
|
162
|
+
if d:
|
|
163
|
+
if not d.startswith("- "):
|
|
164
|
+
d = f"- {d}"
|
|
165
|
+
lines.append(d)
|
|
166
|
+
lines.append("")
|
|
167
|
+
|
|
168
|
+
# Checkpoint info
|
|
169
|
+
cp = dump.get("checkpoint")
|
|
170
|
+
if cp and isinstance(cp, dict):
|
|
171
|
+
summary = cp.get("summary", "")
|
|
172
|
+
if summary:
|
|
173
|
+
lines.append("### Checkpoint")
|
|
174
|
+
lines.append(f"- {summary[:200]}")
|
|
175
|
+
lines.append("")
|
|
176
|
+
key_facts = cp.get("key_facts", [])
|
|
177
|
+
if key_facts:
|
|
178
|
+
lines.append("### Key Facts")
|
|
179
|
+
for f in key_facts[:8]:
|
|
180
|
+
lines.append(f"- {f[:100]}")
|
|
181
|
+
lines.append("")
|
|
182
|
+
|
|
183
|
+
# Workflows
|
|
184
|
+
workflows = dump.get("workflows")
|
|
185
|
+
if workflows and isinstance(workflows, list) and len(workflows) > 0:
|
|
186
|
+
lines.append("### Learned Workflows")
|
|
187
|
+
for wf in workflows[:8]:
|
|
188
|
+
name = wf.get("name", "")
|
|
189
|
+
cmds = wf.get("commands", [])
|
|
190
|
+
steps = wf.get("steps", [])
|
|
191
|
+
if cmds:
|
|
192
|
+
cmd_str = " -> ".join(cmds[:4])
|
|
193
|
+
lines.append(f"- **{name}**: `{cmd_str}`")
|
|
194
|
+
elif steps:
|
|
195
|
+
step_str = " -> ".join(steps[:3])
|
|
196
|
+
lines.append(f"- **{name}**: {step_str}")
|
|
197
|
+
else:
|
|
198
|
+
lines.append(f"- **{name}**")
|
|
199
|
+
lines.append("")
|
|
200
|
+
|
|
201
|
+
# Key entities
|
|
202
|
+
if state and isinstance(state, dict):
|
|
203
|
+
entities = state.get("entity_registry") or {}
|
|
204
|
+
if entities:
|
|
205
|
+
lines.append("### Key Files")
|
|
206
|
+
for k, v in list(entities.items())[:10]:
|
|
207
|
+
lines.append(f"- {k}: `{v[:60]}`")
|
|
208
|
+
lines.append("")
|
|
209
|
+
|
|
210
|
+
# Pending items
|
|
211
|
+
if state and isinstance(state, dict):
|
|
212
|
+
pending = state.get("pending_questions") or []
|
|
213
|
+
if pending:
|
|
214
|
+
lines.append("### Pending")
|
|
215
|
+
for p in pending[:5]:
|
|
216
|
+
lines.append(f"- {p[:80]}")
|
|
217
|
+
lines.append("")
|
|
218
|
+
|
|
219
|
+
# Soul brief
|
|
220
|
+
soul = dump.get("soul") or ""
|
|
221
|
+
if soul and isinstance(soul, str) and len(soul) > 10:
|
|
222
|
+
lines.append("### Soul Brief")
|
|
223
|
+
lines.append(soul[:300])
|
|
224
|
+
lines.append("")
|
|
225
|
+
|
|
226
|
+
lines.append("<!-- SESSION-BRAIN-DUMP END -->")
|
|
227
|
+
return "\n".join(lines)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _write_brain_dump_to_memory_md(project_path: str, brain_dump_md: str):
|
|
231
|
+
"""Write (or replace) the brain dump section in native MEMORY.md.
|
|
232
|
+
|
|
233
|
+
Preserves any existing content outside the brain dump markers.
|
|
234
|
+
Creates the file and directories if they don't exist.
|
|
235
|
+
"""
|
|
236
|
+
md_path = _get_memory_md_path(project_path)
|
|
237
|
+
md_path.parent.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
|
|
239
|
+
existing = ""
|
|
240
|
+
if md_path.exists():
|
|
241
|
+
try:
|
|
242
|
+
existing = md_path.read_text(encoding="utf-8")
|
|
243
|
+
except OSError:
|
|
244
|
+
existing = ""
|
|
245
|
+
|
|
246
|
+
# Replace existing brain dump section if present
|
|
247
|
+
start_marker = "<!-- SESSION-BRAIN-DUMP START -->"
|
|
248
|
+
end_marker = "<!-- SESSION-BRAIN-DUMP END -->"
|
|
249
|
+
|
|
250
|
+
if start_marker in existing and end_marker in existing:
|
|
251
|
+
# Replace between markers (inclusive)
|
|
252
|
+
pattern = re.compile(
|
|
253
|
+
re.escape(start_marker) + r".*?" + re.escape(end_marker),
|
|
254
|
+
re.DOTALL,
|
|
255
|
+
)
|
|
256
|
+
new_content = pattern.sub(brain_dump_md, existing)
|
|
257
|
+
elif existing.strip():
|
|
258
|
+
# Append at top (brain dump should be first thing Claude sees)
|
|
259
|
+
new_content = brain_dump_md + "\n\n" + existing
|
|
260
|
+
else:
|
|
261
|
+
new_content = brain_dump_md
|
|
262
|
+
|
|
263
|
+
# Enforce 200-line limit (Claude Code truncates after 200 lines)
|
|
264
|
+
content_lines = new_content.split("\n")
|
|
265
|
+
if len(content_lines) > 195:
|
|
266
|
+
new_content = "\n".join(content_lines[:195])
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
md_path.write_text(new_content, encoding="utf-8")
|
|
270
|
+
except OSError as e:
|
|
271
|
+
print(f"[PreCompact] Failed to write MEMORY.md: {e}", file=sys.stderr)
|
|
272
|
+
|
|
25
273
|
|
|
26
274
|
def main():
|
|
27
275
|
start = time.time()
|
|
@@ -40,19 +288,23 @@ def main():
|
|
|
40
288
|
|
|
41
289
|
session_id = hook_data.get("session_id", "")
|
|
42
290
|
transcript_path = hook_data.get("transcript_path", "")
|
|
291
|
+
project_path = hook_data.get("cwd") or hook_data.get("project_path", "")
|
|
43
292
|
|
|
44
293
|
if not transcript_path:
|
|
45
|
-
# No transcript available, nothing to extract
|
|
46
294
|
print("[PreCompact] No transcript_path provided, skipping extraction.", file=sys.stderr)
|
|
47
295
|
sys.exit(0)
|
|
48
296
|
|
|
49
|
-
#
|
|
297
|
+
# 1. Force a checkpoint before compaction
|
|
298
|
+
if session_id:
|
|
299
|
+
_force_checkpoint(session_id, project_path)
|
|
300
|
+
|
|
301
|
+
# 2. Run memory extraction (existing behavior)
|
|
50
302
|
from extract_memories import run_extraction
|
|
51
303
|
|
|
52
304
|
results = run_extraction(
|
|
53
305
|
session_id=session_id,
|
|
54
306
|
transcript_path=transcript_path,
|
|
55
|
-
project_path=
|
|
307
|
+
project_path=project_path,
|
|
56
308
|
is_session_end=False,
|
|
57
309
|
)
|
|
58
310
|
|
|
@@ -64,6 +316,18 @@ def main():
|
|
|
64
316
|
file=sys.stderr,
|
|
65
317
|
)
|
|
66
318
|
|
|
319
|
+
# 3. Brain dump: get full session state and write to MEMORY.md
|
|
320
|
+
if session_id and project_path:
|
|
321
|
+
dump = _get_brain_dump(session_id, project_path)
|
|
322
|
+
if dump and dump.get("success"):
|
|
323
|
+
brain_dump_md = _format_brain_dump(dump)
|
|
324
|
+
_write_brain_dump_to_memory_md(project_path, brain_dump_md)
|
|
325
|
+
elapsed2 = round(time.time() - start, 2)
|
|
326
|
+
print(
|
|
327
|
+
f"[PreCompact] Brain dump written to MEMORY.md [{elapsed2}s]",
|
|
328
|
+
file=sys.stderr,
|
|
329
|
+
)
|
|
330
|
+
|
|
67
331
|
except Exception as e:
|
|
68
332
|
elapsed = round(time.time() - start, 2)
|
|
69
333
|
print(f"[PreCompact] Error (non-fatal): {e} [{elapsed}s]", file=sys.stderr)
|
|
@@ -74,6 +74,15 @@ def main():
|
|
|
74
74
|
except ImportError:
|
|
75
75
|
pass
|
|
76
76
|
|
|
77
|
+
# ---------------------------------------------------------------
|
|
78
|
+
# Step 1.7: Trigger soul integration (merge fragments → soul_state)
|
|
79
|
+
# ---------------------------------------------------------------
|
|
80
|
+
if session_id and project_path:
|
|
81
|
+
try:
|
|
82
|
+
_trigger_soul_integration(session_id, project_path, timeout=5.0)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
print(f"[SessionEnd] Soul integration failed (non-fatal): {e}", file=sys.stderr)
|
|
85
|
+
|
|
77
86
|
# ---------------------------------------------------------------
|
|
78
87
|
# Step 1.5: Deregister from cross-session awareness
|
|
79
88
|
# ---------------------------------------------------------------
|
|
@@ -180,5 +189,33 @@ def _trigger_session_wrapup(session_id: str, project_path: str, timeout: float =
|
|
|
180
189
|
print(f"[SessionEnd] Flush API call failed: {e}", file=sys.stderr)
|
|
181
190
|
|
|
182
191
|
|
|
192
|
+
def _trigger_soul_integration(session_id: str, project_path: str, timeout: float = 5.0):
|
|
193
|
+
"""Trigger soul integration — merges session fragments into persistent soul_state."""
|
|
194
|
+
import urllib.request
|
|
195
|
+
import urllib.error
|
|
196
|
+
|
|
197
|
+
memory_agent_url = os.getenv("MEMORY_AGENT_URL", "http://localhost:8102")
|
|
198
|
+
|
|
199
|
+
payload = json.dumps({
|
|
200
|
+
"session_id": session_id,
|
|
201
|
+
"project_path": project_path,
|
|
202
|
+
}).encode("utf-8")
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
req = urllib.request.Request(
|
|
206
|
+
f"{memory_agent_url}/api/soul/integrate",
|
|
207
|
+
data=payload,
|
|
208
|
+
headers={"Content-Type": "application/json"},
|
|
209
|
+
method="POST"
|
|
210
|
+
)
|
|
211
|
+
with urllib.request.urlopen(req, timeout=min(timeout, 5.0)) as resp:
|
|
212
|
+
if resp.status == 200:
|
|
213
|
+
result = json.loads(resp.read().decode("utf-8"))
|
|
214
|
+
integrated = result.get("integrated", 0)
|
|
215
|
+
print(f"[SessionEnd] Soul integration complete: {integrated} fragments integrated.", file=sys.stderr)
|
|
216
|
+
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError) as e:
|
|
217
|
+
print(f"[SessionEnd] Soul integration API call failed: {e}", file=sys.stderr)
|
|
218
|
+
|
|
219
|
+
|
|
183
220
|
if __name__ == "__main__":
|
|
184
221
|
main()
|
package/hooks/session_start.py
CHANGED
|
@@ -86,6 +86,16 @@ async def load_session_context(project_path: str) -> str:
|
|
|
86
86
|
"""Load all relevant context for a session start."""
|
|
87
87
|
context_parts = []
|
|
88
88
|
|
|
89
|
+
# ============================================================
|
|
90
|
+
# SOUL LAYER: Load soul brief (personality + learning context)
|
|
91
|
+
# ============================================================
|
|
92
|
+
soul_brief = await call_rest_api("GET", "/api/soul/brief", params={
|
|
93
|
+
"project_path": project_path,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if soul_brief and soul_brief.get("success") and soul_brief.get("brief"):
|
|
97
|
+
context_parts.append(soul_brief["brief"])
|
|
98
|
+
|
|
89
99
|
# ============================================================
|
|
90
100
|
# CROSS-SESSION AWARENESS: Register this session + catch-up
|
|
91
101
|
# ============================================================
|