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.
- package/hooks/auto_capture.py +58 -1
- package/hooks/grounding-hook-v2.py +129 -0
- package/hooks/grounding-hook.py +95 -0
- package/hooks/session_end_hook.py +35 -0
- package/hooks/session_start.py +56 -0
- package/main.py +165 -0
- package/mcp_proxy.py +307 -0
- package/mcp_server_full.py +497 -0
- package/package.json +1 -1
- package/services/native_memory_sync.py +66 -310
package/hooks/auto_capture.py
CHANGED
|
@@ -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()
|
package/hooks/grounding-hook.py
CHANGED
|
@@ -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.
|
package/hooks/session_start.py
CHANGED
|
@@ -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(
|