claude-memory-agent 2.2.4 → 3.0.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.
@@ -23,7 +23,7 @@ import asyncio
23
23
  import logging
24
24
  from datetime import datetime
25
25
  from pathlib import Path
26
- from typing import Dict, Any, Optional
26
+ from typing import Dict, Any, Optional, List
27
27
 
28
28
  # Add parent to path for imports
29
29
  sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -128,6 +128,53 @@ async def send_to_memory(
128
128
  return False
129
129
 
130
130
 
131
+ async def post_session_activity(session_id: str, project_path: str, event_type: str, summary: str, files: List[str] = None):
132
+ """Post a cross-session activity event and track modified files."""
133
+ if not session_id or not project_path:
134
+ return
135
+
136
+ headers = {"Content-Type": "application/json"}
137
+ if API_KEY:
138
+ headers["X-Memory-Key"] = API_KEY
139
+
140
+ try:
141
+ async with httpx.AsyncClient(timeout=3.0) as client:
142
+ # Post activity event
143
+ await client.post(
144
+ f"{MEMORY_AGENT_URL}/api/sessions/activity",
145
+ json={
146
+ "session_id": session_id,
147
+ "project_path": project_path,
148
+ "event_type": event_type,
149
+ "summary": summary,
150
+ "files": files or [],
151
+ },
152
+ headers=headers,
153
+ )
154
+
155
+ # Append files to session's modified files list
156
+ if files:
157
+ for f in files:
158
+ await client.post(
159
+ f"{MEMORY_AGENT_URL}/a2a",
160
+ json={
161
+ "jsonrpc": "2.0",
162
+ "method": "skills/call",
163
+ "params": {
164
+ "skill_id": "session_append_file",
165
+ "params": {
166
+ "session_id": session_id,
167
+ "file_path": f,
168
+ }
169
+ },
170
+ "id": f"auto-capture-file-{datetime.now().isoformat()}"
171
+ },
172
+ headers=headers,
173
+ )
174
+ except Exception as e:
175
+ logger.debug(f"Cross-session activity post failed: {e}")
176
+
177
+
131
178
  async def capture_tool_use(hook_data: Dict[str, Any]):
132
179
  """Capture a tool execution event."""
133
180
  tool_name = hook_data.get("tool_name", "Unknown")
@@ -182,6 +229,16 @@ async def capture_tool_use(hook_data: Dict[str, Any]):
182
229
 
183
230
  await send_to_memory(content, mem_type, importance, metadata, project_path)
184
231
 
232
+ # ============================================================
233
+ # CROSS-SESSION AWARENESS: Post file changes to activity feed
234
+ # ============================================================
235
+ if tool_name in ("Write", "Edit") and session_id and project_path:
236
+ file_path = tool_input.get("file_path", "")
237
+ if file_path:
238
+ event_type = "file_change"
239
+ summary = f"{'Created' if tool_name == 'Write' else 'Edited'} {file_path}"
240
+ await post_session_activity(session_id, project_path, event_type, summary, [file_path])
241
+
185
242
 
186
243
  async def capture_notification(hook_data: Dict[str, Any]):
187
244
  """Capture a notification/error event."""
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """Slim grounding hook v2 - single HTTP call, compact output.
3
+
4
+ Replaces the original grounding-hook.py (4-6 HTTP calls, verbose output)
5
+ with a single POST to /api/grounding-context that aggregates everything
6
+ server-side.
7
+
8
+ Also replaces: session_start.py, problem-detector.py, memory-first-reminder.py
9
+
10
+ Output: compact [MEM] line (<150 tokens)
11
+ Timeout: 3 seconds, silent fail
12
+ """
13
+
14
+ import os
15
+ import sys
16
+ import json
17
+ import logging
18
+ from pathlib import Path
19
+
20
+ logging.basicConfig(
21
+ level=logging.DEBUG,
22
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
23
+ stream=sys.stderr,
24
+ )
25
+ logger = logging.getLogger("grounding-v2")
26
+
27
+ MEMORY_AGENT_URL = os.getenv("MEMORY_AGENT_URL", "http://localhost:8102")
28
+ TIMEOUT = 3 # seconds
29
+
30
+
31
+ def get_session_id() -> str:
32
+ """Get session ID from env or .claude_session file."""
33
+ sid = os.getenv("CLAUDE_SESSION_ID", "")
34
+ if sid:
35
+ return sid
36
+
37
+ session_file = Path(os.getcwd()) / ".claude_session"
38
+ if session_file.exists():
39
+ try:
40
+ content = session_file.read_text().strip()
41
+ data = json.loads(content)
42
+ return data.get("session_id", "")
43
+ except (json.JSONDecodeError, IOError):
44
+ return content # legacy plain text format
45
+ return ""
46
+
47
+
48
+ def get_user_input() -> str:
49
+ """Extract user input from hook stdin."""
50
+ try:
51
+ if not sys.stdin.isatty():
52
+ data = sys.stdin.read()
53
+ if data:
54
+ hook_data = json.loads(data)
55
+ return hook_data.get("prompt", hook_data.get("user_prompt", ""))
56
+ except Exception:
57
+ pass
58
+ return ""
59
+
60
+
61
+ def main():
62
+ session_id = get_session_id()
63
+ project_path = os.getcwd()
64
+ user_input = get_user_input()
65
+
66
+ if not session_id:
67
+ # No session - try to initialize one via A2A
68
+ try:
69
+ import requests
70
+ resp = requests.post(
71
+ f"{MEMORY_AGENT_URL}/a2a",
72
+ json={
73
+ "jsonrpc": "2.0",
74
+ "id": "grounding-v2-init",
75
+ "method": "tasks/send",
76
+ "params": {
77
+ "message": {"parts": [{"type": "text", "text": ""}]},
78
+ "metadata": {
79
+ "skill_id": "state_init_session",
80
+ "params": {"project_path": project_path},
81
+ },
82
+ },
83
+ },
84
+ timeout=TIMEOUT,
85
+ )
86
+ if resp.status_code == 200:
87
+ result = resp.json()
88
+ try:
89
+ text = result["result"]["artifacts"][0]["parts"][0]["text"]
90
+ data = json.loads(text)
91
+ session_id = data.get("session_id", "")
92
+ if session_id:
93
+ # Save for future hooks
94
+ sf = Path(project_path) / ".claude_session"
95
+ sf.write_text(json.dumps({"session_id": session_id}))
96
+ except (KeyError, IndexError, json.JSONDecodeError):
97
+ pass
98
+ except Exception as e:
99
+ logger.debug(f"Session init failed: {e}")
100
+
101
+ if not session_id:
102
+ sys.exit(0)
103
+
104
+ # Single aggregated call
105
+ try:
106
+ import requests
107
+ resp = requests.post(
108
+ f"{MEMORY_AGENT_URL}/api/grounding-context",
109
+ json={
110
+ "session_id": session_id,
111
+ "project_path": project_path,
112
+ "user_input": user_input,
113
+ },
114
+ timeout=TIMEOUT,
115
+ )
116
+ if resp.status_code == 200:
117
+ data = resp.json()
118
+ context = data.get("context", "")
119
+ if context:
120
+ print(context)
121
+ except Exception as e:
122
+ logger.debug(f"Grounding context call failed: {e}")
123
+ # Silent fail - don't break Claude Code
124
+
125
+ sys.exit(0)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
@@ -299,6 +299,87 @@ def format_curator_context(curator_summary: dict, curator_status: dict) -> str:
299
299
  return "\n".join(lines)
300
300
 
301
301
 
302
+ def call_rest_api(method: str, path: str, json_body: dict = None, params: dict = None, timeout: int = 3):
303
+ """Call the memory agent REST API (for endpoints that aren't A2A skills)."""
304
+ try:
305
+ url = f"{MEMORY_AGENT_URL}{path}"
306
+ if method == "POST":
307
+ response = requests.post(url, json=json_body, timeout=timeout)
308
+ else:
309
+ response = requests.get(url, params=params, timeout=timeout)
310
+ if response.status_code == 200:
311
+ return response.json()
312
+ except requests.RequestException as e:
313
+ logger.debug(f"REST API call failed ({path}): {e}")
314
+ return None
315
+
316
+
317
+ def format_parallel_sessions(heartbeat_result: dict) -> str:
318
+ """Format parallel session info for context injection."""
319
+ if not heartbeat_result or not heartbeat_result.get("success"):
320
+ return ""
321
+
322
+ siblings = heartbeat_result.get("active_siblings", [])
323
+ conflicts = heartbeat_result.get("file_conflicts", [])
324
+
325
+ if not siblings:
326
+ return ""
327
+
328
+ lines = ["[PARALLEL SESSIONS]"]
329
+
330
+ # Build a set of conflicting files per session for quick lookup
331
+ conflict_map = {}
332
+ for c in conflicts:
333
+ conflict_map[c["session_id"]] = c.get("conflicting_files", [])
334
+
335
+ for sib in siblings:
336
+ label = sib.get("session_label") or sib.get("session_id", "")[:12]
337
+ status = sib.get("status", "active")
338
+ goal = sib.get("current_goal", "")
339
+ files = sib.get("files_modified", [])
340
+ decisions = sib.get("key_decisions", [])
341
+
342
+ # Calculate time since last heartbeat
343
+ last_hb = sib.get("last_heartbeat", "")
344
+ time_ago = ""
345
+ if last_hb:
346
+ try:
347
+ from datetime import datetime, timezone
348
+ hb_time = datetime.fromisoformat(last_hb.replace("Z", "+00:00"))
349
+ now = datetime.now(timezone.utc) if hb_time.tzinfo else datetime.now()
350
+ delta = now - hb_time
351
+ minutes = int(delta.total_seconds() / 60)
352
+ time_ago = f" ({minutes}m ago)" if minutes > 0 else " (just now)"
353
+ except (ValueError, TypeError):
354
+ pass
355
+
356
+ lines.append(f'Session "{label}" ({status}{time_ago}):')
357
+
358
+ if goal:
359
+ lines.append(f" Working on: {goal}")
360
+
361
+ if files:
362
+ shown = files[:5]
363
+ lines.append(f" Files changed: {', '.join(shown)}")
364
+ if len(files) > 5:
365
+ lines.append(f" ...and {len(files) - 5} more")
366
+
367
+ if decisions:
368
+ for d in decisions[:2]:
369
+ lines.append(f" Decision: {d}")
370
+
371
+ # Conflict warnings
372
+ sib_conflicts = conflict_map.get(sib.get("session_id"), [])
373
+ if sib_conflicts:
374
+ for f in sib_conflicts:
375
+ lines.append(f" WARNING CONFLICT: You both modified {f}")
376
+
377
+ lines.append("[/PARALLEL SESSIONS]")
378
+ lines.append("")
379
+
380
+ return "\n".join(lines)
381
+
382
+
302
383
  def check_and_trigger_flush(session_id: str, project_path: str):
303
384
  """Check if flush is needed and trigger it."""
304
385
  # Check flush conditions
@@ -339,6 +420,17 @@ def main():
339
420
  # No session, no grounding - exit silently
340
421
  sys.exit(0)
341
422
 
423
+ # ============================================================
424
+ # CROSS-SESSION AWARENESS: Heartbeat + parallel session context
425
+ # ============================================================
426
+ parallel_context = ""
427
+ heartbeat_result = call_rest_api("POST", "/api/sessions/heartbeat", {
428
+ "session_id": session_id,
429
+ "project_path": project_path,
430
+ })
431
+ if heartbeat_result:
432
+ parallel_context = format_parallel_sessions(heartbeat_result)
433
+
342
434
  # ============================================================
343
435
  # MOLTBOT-INSPIRED: Check flush conditions
344
436
  # ============================================================
@@ -400,6 +492,9 @@ def main():
400
492
  # Combine all context
401
493
  output_parts = []
402
494
 
495
+ if parallel_context:
496
+ output_parts.append(parallel_context)
497
+
403
498
  if memory_md_context:
404
499
  output_parts.append(memory_md_context)
405
500
 
@@ -74,6 +74,15 @@ def main():
74
74
  except ImportError:
75
75
  pass
76
76
 
77
+ # ---------------------------------------------------------------
78
+ # Step 1.5: Deregister from cross-session awareness
79
+ # ---------------------------------------------------------------
80
+ if session_id:
81
+ try:
82
+ _deregister_session(session_id, project_path, timeout=2.0)
83
+ except Exception as e:
84
+ print(f"[SessionEnd] Session deregister failed (non-fatal): {e}", file=sys.stderr)
85
+
77
86
  # ---------------------------------------------------------------
78
87
  # Step 2: Trigger the existing session_end.py wrapup logic
79
88
  # (summarization, daily log, MEMORY.md sync, flush)
@@ -97,6 +106,32 @@ def main():
97
106
  sys.exit(0)
98
107
 
99
108
 
109
+ def _deregister_session(session_id: str, project_path: str, timeout: float = 2.0):
110
+ """Deregister this session from cross-session awareness."""
111
+ import urllib.request
112
+ import urllib.error
113
+
114
+ memory_agent_url = os.getenv("MEMORY_AGENT_URL", "http://localhost:8102")
115
+
116
+ payload = json.dumps({
117
+ "session_id": session_id,
118
+ "project_path": project_path,
119
+ }).encode("utf-8")
120
+
121
+ try:
122
+ req = urllib.request.Request(
123
+ f"{memory_agent_url}/api/sessions/deregister",
124
+ data=payload,
125
+ headers={"Content-Type": "application/json"},
126
+ method="POST"
127
+ )
128
+ with urllib.request.urlopen(req, timeout=min(timeout, 2.0)) as resp:
129
+ if resp.status == 200:
130
+ print("[SessionEnd] Session deregistered.", file=sys.stderr)
131
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError) as e:
132
+ print(f"[SessionEnd] Session deregister API call failed: {e}", file=sys.stderr)
133
+
134
+
100
135
  def _trigger_session_wrapup(session_id: str, project_path: str, timeout: float = 3.0):
101
136
  """
102
137
  Trigger the existing session_end.py summarization via the memory agent API.
@@ -32,6 +32,7 @@ import httpx
32
32
 
33
33
  MEMORY_AGENT_URL = os.getenv("MEMORY_AGENT_URL", "http://localhost:8102")
34
34
  API_KEY = os.getenv("MEMORY_API_KEY", "")
35
+ SESSION_ID = os.getenv("CLAUDE_SESSION_ID", "")
35
36
 
36
37
 
37
38
  async def call_memory_skill(skill_id: str, params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
@@ -65,10 +66,65 @@ async def call_memory_skill(skill_id: str, params: Dict[str, Any]) -> Optional[D
65
66
  return None
66
67
 
67
68
 
69
+ async def call_rest_api(method: str, path: str, json_body: dict = None, params: dict = None) -> Optional[Dict[str, Any]]:
70
+ """Call the memory agent REST API."""
71
+ try:
72
+ async with httpx.AsyncClient(timeout=5.0) as client:
73
+ url = f"{MEMORY_AGENT_URL}{path}"
74
+ if method == "POST":
75
+ response = await client.post(url, json=json_body)
76
+ else:
77
+ response = await client.get(url, params=params)
78
+ if response.status_code == 200:
79
+ return response.json()
80
+ except Exception:
81
+ pass
82
+ return None
83
+
84
+
68
85
  async def load_session_context(project_path: str) -> str:
69
86
  """Load all relevant context for a session start."""
70
87
  context_parts = []
71
88
 
89
+ # ============================================================
90
+ # CROSS-SESSION AWARENESS: Register this session + catch-up
91
+ # ============================================================
92
+ session_id = SESSION_ID or os.getenv("CLAUDE_SESSION_ID", "")
93
+ if session_id:
94
+ register_result = await call_rest_api("POST", "/api/sessions/register", {
95
+ "session_id": session_id,
96
+ "project_path": project_path,
97
+ })
98
+
99
+ if register_result and register_result.get("active_siblings"):
100
+ siblings = register_result["active_siblings"]
101
+ context_parts.append("\n## Active Parallel Sessions")
102
+ for sib in siblings:
103
+ label = sib.get("session_label") or sib.get("session_id", "")[:12]
104
+ goal = sib.get("current_goal", "unknown")
105
+ files = sib.get("files_modified", [])
106
+ context_parts.append(f"- **{label}**: {goal}")
107
+ if files:
108
+ context_parts.append(f" Files: {', '.join(files[:5])}")
109
+
110
+ # Get catch-up: what happened while this session was away
111
+ catchup = await call_rest_api("GET", "/api/sessions/catch-up", params={
112
+ "session_id": session_id,
113
+ "project_path": project_path,
114
+ })
115
+
116
+ if catchup and catchup.get("sessions"):
117
+ context_parts.append("\n## What Happened While You Were Away")
118
+ for sess in catchup["sessions"]:
119
+ label = sess.get("session_label") or sess.get("session_id", "")[:12]
120
+ events = sess.get("events", [])
121
+ if events:
122
+ context_parts.append(f"### Session: {label}")
123
+ for ev in events[:5]:
124
+ etype = ev.get("event_type", "")
125
+ summary = ev.get("summary", "")
126
+ context_parts.append(f"- [{etype}] {summary}")
127
+
72
128
  # ============================================================
73
129
  # MOLTBOT-INSPIRED: Load MEMORY.md first (core facts)
74
130
  # ============================================================
package/main.py CHANGED
@@ -5475,6 +5475,171 @@ async def api_post_session_activity(request: Request):
5475
5475
  return {"success": False, "error": str(e)}
5476
5476
 
5477
5477
 
5478
+ # ============= Aggregated Grounding Context (v2) =============
5479
+
5480
+
5481
+ @app.post("/api/grounding-context")
5482
+ async def api_grounding_context(request: Request):
5483
+ """Aggregated grounding endpoint for slim hooks.
5484
+
5485
+ Single call that runs all grounding queries in parallel and returns
5486
+ a compact text summary (<150 tokens target).
5487
+
5488
+ Body:
5489
+ session_id: str
5490
+ project_path: str
5491
+ user_input: str (optional - enables pattern hints)
5492
+
5493
+ Returns:
5494
+ {"success": true, "context": "[MEM] goal: ... | anchors | sessions ..."}
5495
+ """
5496
+ try:
5497
+ body = await request.json()
5498
+ except Exception:
5499
+ body = {}
5500
+
5501
+ session_id = body.get("session_id", "")
5502
+ project_path = body.get("project_path", "")
5503
+ user_input = body.get("user_input", "")
5504
+
5505
+ parts = []
5506
+ tasks_dict = {}
5507
+
5508
+ # 1. Context refresh (anchors, goal, contradictions)
5509
+ if session_id:
5510
+ async def _get_grounding():
5511
+ try:
5512
+ return await context_refresh(
5513
+ db=db,
5514
+ embeddings=embeddings,
5515
+ session_id=session_id,
5516
+ include_recent_events=3,
5517
+ include_state=True,
5518
+ include_checkpoint=False,
5519
+ include_relevant_memories=False,
5520
+ check_contradictions=True,
5521
+ )
5522
+ except Exception as e:
5523
+ logger.debug(f"Grounding context_refresh failed: {e}")
5524
+ return None
5525
+ tasks_dict["grounding"] = _get_grounding()
5526
+
5527
+ # 2. Session heartbeat (parallel sessions + conflicts)
5528
+ if session_id and project_path:
5529
+ async def _get_sessions():
5530
+ try:
5531
+ awareness = get_session_awareness(db)
5532
+ return await awareness.heartbeat(
5533
+ session_id=session_id,
5534
+ project_path=project_path,
5535
+ )
5536
+ except Exception as e:
5537
+ logger.debug(f"Grounding heartbeat failed: {e}")
5538
+ return None
5539
+ tasks_dict["sessions"] = _get_sessions()
5540
+
5541
+ # 3. Pattern hints (only if user input provided)
5542
+ if user_input and len(user_input) > 10:
5543
+ async def _get_patterns():
5544
+ try:
5545
+ return await search_patterns(
5546
+ db=db,
5547
+ embeddings=embeddings,
5548
+ query=user_input[:300],
5549
+ limit=2,
5550
+ threshold=0.65,
5551
+ )
5552
+ except Exception as e:
5553
+ logger.debug(f"Grounding pattern search failed: {e}")
5554
+ return None
5555
+ tasks_dict["patterns"] = _get_patterns()
5556
+
5557
+ # 4. Curator status (lightweight)
5558
+ async def _get_curator_status():
5559
+ try:
5560
+ from services.curator import get_curator
5561
+ curator = get_curator(db, embeddings)
5562
+ return await curator.get_status()
5563
+ except Exception as e:
5564
+ logger.debug(f"Grounding curator status failed: {e}")
5565
+ return None
5566
+ tasks_dict["curator"] = _get_curator_status()
5567
+
5568
+ # Run all in parallel
5569
+ if tasks_dict:
5570
+ keys = list(tasks_dict.keys())
5571
+ gathered = await asyncio.gather(
5572
+ *[tasks_dict[k] for k in keys],
5573
+ return_exceptions=True,
5574
+ )
5575
+ results = {}
5576
+ for k, v in zip(keys, gathered):
5577
+ results[k] = v if not isinstance(v, Exception) else None
5578
+ else:
5579
+ results = {}
5580
+
5581
+ # -- Build compact output --
5582
+ # Goal
5583
+ grounding = results.get("grounding")
5584
+ if grounding and isinstance(grounding, dict) and grounding.get("success"):
5585
+ g = grounding.get("grounding", {})
5586
+ goal = g.get("current_goal")
5587
+ if goal:
5588
+ parts.append(f"goal: {goal[:80]}")
5589
+
5590
+ anchors = g.get("anchors", [])
5591
+ if anchors:
5592
+ parts.append(f"{len(anchors)} anchor{'s' if len(anchors) != 1 else ''}")
5593
+
5594
+ contradictions = g.get("contradictions", [])
5595
+ if contradictions:
5596
+ c_summaries = [c.get("content", "")[:40] for c in contradictions[:2]]
5597
+ parts.append(f"CONFLICT: {'; '.join(c_summaries)}")
5598
+
5599
+ # Parallel sessions
5600
+ sessions = results.get("sessions")
5601
+ if sessions and isinstance(sessions, dict):
5602
+ siblings = sessions.get("active_siblings", [])
5603
+ conflicts = sessions.get("file_conflicts", [])
5604
+ if siblings:
5605
+ labels = [s.get("session_label", s.get("session_id", "")[:8]) for s in siblings]
5606
+ parts.append(f"sessions: {', '.join(labels)}")
5607
+ if conflicts:
5608
+ conflict_files = []
5609
+ for c in conflicts:
5610
+ conflict_files.extend(c.get("conflicting_files", []))
5611
+ if conflict_files:
5612
+ parts.append(f"FILE CONFLICT: {', '.join(conflict_files[:3])}")
5613
+
5614
+ # Pattern hints
5615
+ patterns = results.get("patterns")
5616
+ if patterns and isinstance(patterns, dict):
5617
+ p_list = patterns.get("patterns", [])
5618
+ if p_list:
5619
+ best = p_list[0]
5620
+ sim = int(best.get("similarity", 0) * 100)
5621
+ name = best.get("name", "")[:30]
5622
+ parts.append(f"pattern({sim}%): {name}")
5623
+
5624
+ # Curator warnings
5625
+ curator = results.get("curator")
5626
+ if curator and isinstance(curator, dict):
5627
+ orphans = curator.get("orphan_count", 0)
5628
+ if orphans > 20:
5629
+ parts.append(f"{orphans} orphans")
5630
+
5631
+ if parts:
5632
+ compact = "[MEM] " + " | ".join(parts)
5633
+ else:
5634
+ compact = ""
5635
+
5636
+ return {
5637
+ "success": True,
5638
+ "context": compact,
5639
+ "token_estimate": len(compact.split()),
5640
+ }
5641
+
5642
+
5478
5643
  if __name__ == "__main__":
5479
5644
  import uvicorn
5480
5645
  uvicorn.run(