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.
@@ -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. Exits 0 on success OR failure (never blocks compaction)
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: < 5 seconds total.
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
- # Import and run extraction
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=hook_data.get("cwd") or hook_data.get("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()
@@ -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
  # ============================================================