claude-memory-agent 2.0.1 → 2.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.
Files changed (97) hide show
  1. package/README.md +206 -206
  2. package/agent_card.py +186 -0
  3. package/bin/cli.js +327 -185
  4. package/bin/lib/banner.js +39 -0
  5. package/bin/lib/environment.js +166 -0
  6. package/bin/lib/installer.js +291 -0
  7. package/bin/lib/models.js +95 -0
  8. package/bin/lib/steps/advanced.js +101 -0
  9. package/bin/lib/steps/confirm.js +87 -0
  10. package/bin/lib/steps/model.js +57 -0
  11. package/bin/lib/steps/provider.js +65 -0
  12. package/bin/lib/steps/scope.js +59 -0
  13. package/bin/lib/steps/server.js +74 -0
  14. package/bin/lib/ui.js +75 -0
  15. package/bin/onboarding.js +164 -0
  16. package/bin/postinstall.js +35 -270
  17. package/config.py +103 -4
  18. package/dashboard.html +4902 -2689
  19. package/hooks/extract_memories.py +439 -0
  20. package/hooks/grounding-hook.py +422 -348
  21. package/hooks/pre_compact_hook.py +76 -0
  22. package/hooks/session_end.py +293 -192
  23. package/hooks/session_end_hook.py +149 -0
  24. package/hooks/session_start.py +227 -227
  25. package/hooks/stop_hook.py +372 -0
  26. package/install.py +972 -902
  27. package/main.py +5240 -2859
  28. package/mcp_server.py +451 -0
  29. package/package.json +58 -47
  30. package/requirements.txt +12 -8
  31. package/services/__init__.py +50 -50
  32. package/services/adaptive_ranker.py +272 -0
  33. package/services/agent_catalog.json +153 -0
  34. package/services/agent_registry.py +245 -730
  35. package/services/claude_md_sync.py +320 -4
  36. package/services/consolidation.py +417 -0
  37. package/services/curator.py +1606 -0
  38. package/services/database.py +4118 -2485
  39. package/services/embedding_pipeline.py +262 -0
  40. package/services/embeddings.py +493 -85
  41. package/services/memory_decay.py +408 -0
  42. package/services/native_memory_paths.py +86 -0
  43. package/services/native_memory_sync.py +496 -0
  44. package/services/response_manager.py +183 -0
  45. package/services/terminal_ui.py +199 -0
  46. package/services/tier_manager.py +235 -0
  47. package/services/websocket.py +26 -6
  48. package/skills/__init__.py +21 -1
  49. package/skills/confidence_tracker.py +441 -0
  50. package/skills/context.py +675 -0
  51. package/skills/curator.py +348 -0
  52. package/skills/search.py +444 -213
  53. package/skills/session_review.py +605 -0
  54. package/skills/store.py +484 -179
  55. package/terminal_dashboard.py +474 -0
  56. package/update_system.py +829 -817
  57. package/hooks/__pycache__/auto-detect-response.cpython-312.pyc +0 -0
  58. package/hooks/__pycache__/auto_capture.cpython-312.pyc +0 -0
  59. package/hooks/__pycache__/session_end.cpython-312.pyc +0 -0
  60. package/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
  61. package/services/__pycache__/__init__.cpython-312.pyc +0 -0
  62. package/services/__pycache__/agent_registry.cpython-312.pyc +0 -0
  63. package/services/__pycache__/auth.cpython-312.pyc +0 -0
  64. package/services/__pycache__/auto_inject.cpython-312.pyc +0 -0
  65. package/services/__pycache__/claude_md_sync.cpython-312.pyc +0 -0
  66. package/services/__pycache__/cleanup.cpython-312.pyc +0 -0
  67. package/services/__pycache__/compaction_flush.cpython-312.pyc +0 -0
  68. package/services/__pycache__/confidence.cpython-312.pyc +0 -0
  69. package/services/__pycache__/daily_log.cpython-312.pyc +0 -0
  70. package/services/__pycache__/database.cpython-312.pyc +0 -0
  71. package/services/__pycache__/embeddings.cpython-312.pyc +0 -0
  72. package/services/__pycache__/insights.cpython-312.pyc +0 -0
  73. package/services/__pycache__/llm_analyzer.cpython-312.pyc +0 -0
  74. package/services/__pycache__/memory_md_sync.cpython-312.pyc +0 -0
  75. package/services/__pycache__/retry_queue.cpython-312.pyc +0 -0
  76. package/services/__pycache__/timeline.cpython-312.pyc +0 -0
  77. package/services/__pycache__/vector_index.cpython-312.pyc +0 -0
  78. package/services/__pycache__/websocket.cpython-312.pyc +0 -0
  79. package/skills/__pycache__/__init__.cpython-312.pyc +0 -0
  80. package/skills/__pycache__/admin.cpython-312.pyc +0 -0
  81. package/skills/__pycache__/checkpoint.cpython-312.pyc +0 -0
  82. package/skills/__pycache__/claude_md.cpython-312.pyc +0 -0
  83. package/skills/__pycache__/cleanup.cpython-312.pyc +0 -0
  84. package/skills/__pycache__/grounding.cpython-312.pyc +0 -0
  85. package/skills/__pycache__/insights.cpython-312.pyc +0 -0
  86. package/skills/__pycache__/natural_language.cpython-312.pyc +0 -0
  87. package/skills/__pycache__/retrieve.cpython-312.pyc +0 -0
  88. package/skills/__pycache__/search.cpython-312.pyc +0 -0
  89. package/skills/__pycache__/state.cpython-312.pyc +0 -0
  90. package/skills/__pycache__/store.cpython-312.pyc +0 -0
  91. package/skills/__pycache__/summarize.cpython-312.pyc +0 -0
  92. package/skills/__pycache__/timeline.cpython-312.pyc +0 -0
  93. package/skills/__pycache__/verification.cpython-312.pyc +0 -0
  94. package/test_automation.py +0 -221
  95. package/test_complete.py +0 -338
  96. package/test_full.py +0 -322
  97. package/verify_db.py +0 -134
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Stop hook for Claude Code.
4
+
5
+ Fires after every Claude response. Unlike PreCompact/SessionEnd hooks which
6
+ scan the full transcript, this hook analyzes ONLY the latest assistant
7
+ response for high-signal content worth persisting immediately.
8
+
9
+ Design constraints:
10
+ - Runs after EVERY response -- must complete in < 2 seconds
11
+ - Extracts at most 2 memories per invocation
12
+ - Focuses only on explicit, high-confidence signals (decisions, error
13
+ resolutions, architecture notes)
14
+ - Shares the cursor dedup hash list with extract_memories.py so the
15
+ heavier hooks don't re-extract the same content
16
+ - Uses stdlib only (no pip dependencies)
17
+ - Always exits 0 -- never blocks the user
18
+
19
+ Stdin JSON schema (provided by Claude Code):
20
+ {
21
+ "session_id": "...",
22
+ "transcript_path": "...",
23
+ "hook_event_name": "Stop",
24
+ "cwd": "...",
25
+ "stop_hook_active": true,
26
+ ... (assistant's last response in transcript)
27
+ }
28
+ """
29
+
30
+ import os
31
+ import sys
32
+ import json
33
+ import re
34
+ import time
35
+ import hashlib
36
+ from pathlib import Path
37
+ from typing import Dict, Any, List, Optional
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Configuration
41
+ # ---------------------------------------------------------------------------
42
+
43
+ MEMORY_AGENT_URL = os.getenv("MEMORY_AGENT_URL", "http://localhost:8102")
44
+ API_KEY = os.getenv("MEMORY_API_KEY", "")
45
+ CURSOR_DIR = Path.home() / ".claude"
46
+ CURSOR_FILE = CURSOR_DIR / "memory-agent-cursor.json"
47
+ MAX_MEMORIES_PER_STOP = 2 # Hard cap -- stay fast
48
+ MAX_CONTENT_LENGTH = 500 # Truncate for storage
49
+ API_TIMEOUT_SECONDS = 1.5 # Tight timeout for API calls
50
+ TOTAL_TIME_BUDGET = 2.0 # Total wall-clock budget
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # High-signal extraction patterns (intentionally narrow)
54
+ #
55
+ # These are stricter than the ones in extract_memories.py because the Stop
56
+ # hook runs on every response and must avoid false positives. The heavier
57
+ # PreCompact/SessionEnd hooks catch the rest.
58
+ # ---------------------------------------------------------------------------
59
+
60
+ # Explicit decisions -- strong first-person phrasing
61
+ DECISION_PATTERNS = [
62
+ re.compile(
63
+ r"(?:^|\n)\s*(?:I decided to|I've decided to|Let's go with|The approach will be|"
64
+ r"We(?:'ll| will) go with|The decision is to) (.{20,}?)(?:\.|$)",
65
+ re.IGNORECASE | re.MULTILINE,
66
+ ),
67
+ ]
68
+
69
+ # Error resolutions -- explicit fix language
70
+ ERROR_RESOLUTION_PATTERNS = [
71
+ re.compile(
72
+ r"(?:^|\n)\s*(?:The fix is|The fix was|Root cause was|Root cause:|"
73
+ r"This was caused by|The bug was|The issue was|Resolution:) (.{20,}?)(?:\.|$)",
74
+ re.IGNORECASE | re.MULTILINE,
75
+ ),
76
+ ]
77
+
78
+ # Architecture / convention notes
79
+ ARCHITECTURE_PATTERNS = [
80
+ re.compile(
81
+ r"(?:^|\n)\s*(?:The architecture|This pattern|Convention:|"
82
+ r"The convention is|Key pattern:|Architecture note:) (.{20,}?)(?:\.|$)",
83
+ re.IGNORECASE | re.MULTILINE,
84
+ ),
85
+ ]
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Cursor interaction (reuses same file as extract_memories.py)
90
+ # ---------------------------------------------------------------------------
91
+
92
+ def _load_cursor_hashes(session_id: str) -> set:
93
+ """Load the set of already-extracted content hashes for this session."""
94
+ try:
95
+ if CURSOR_FILE.exists():
96
+ data = json.loads(CURSOR_FILE.read_text(encoding="utf-8"))
97
+ session = data.get(session_id, {})
98
+ return set(session.get("extracted_hashes", []))
99
+ except (json.JSONDecodeError, OSError):
100
+ pass
101
+ return set()
102
+
103
+
104
+ def _save_cursor_hashes(session_id: str, new_hashes: List[str]):
105
+ """Append new hashes to the session's cursor entry."""
106
+ try:
107
+ CURSOR_DIR.mkdir(parents=True, exist_ok=True)
108
+ data = {}
109
+ if CURSOR_FILE.exists():
110
+ try:
111
+ data = json.loads(CURSOR_FILE.read_text(encoding="utf-8"))
112
+ except (json.JSONDecodeError, OSError):
113
+ data = {}
114
+
115
+ session = data.get(session_id, {"byte_offset": 0, "extracted_hashes": []})
116
+ existing = set(session.get("extracted_hashes", []))
117
+ merged = list(existing | set(new_hashes))
118
+ # Cap to prevent unbounded growth
119
+ if len(merged) > 200:
120
+ merged = merged[-200:]
121
+ session["extracted_hashes"] = merged
122
+ data[session_id] = session
123
+
124
+ CURSOR_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
125
+ except OSError:
126
+ pass # Fail silently
127
+
128
+
129
+ def _content_hash(text: str) -> str:
130
+ """Short MD5 prefix for dedup -- matches extract_memories.content_hash."""
131
+ return hashlib.md5(text.strip().lower().encode("utf-8")).hexdigest()[:12]
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Response extraction
136
+ # ---------------------------------------------------------------------------
137
+
138
+ def _get_latest_response(transcript_path: str) -> str:
139
+ """
140
+ Read the transcript file and return only the last assistant response.
141
+
142
+ Claude Code transcripts are JSONL where each line is a message object.
143
+ We read the file from the end backwards to find the last assistant turn.
144
+ For speed we only read the trailing portion of the file (last 32 KB max).
145
+ """
146
+ path = Path(transcript_path)
147
+ if not path.exists():
148
+ return ""
149
+
150
+ try:
151
+ file_size = path.stat().st_size
152
+ if file_size == 0:
153
+ return ""
154
+
155
+ # Read at most the last 32 KB -- the latest response should be there
156
+ read_start = max(0, file_size - 32768)
157
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
158
+ if read_start > 0:
159
+ f.seek(read_start)
160
+ # Skip partial line
161
+ f.readline()
162
+ tail = f.read()
163
+
164
+ if not tail.strip():
165
+ return ""
166
+
167
+ # Walk lines in reverse to find last assistant message
168
+ lines = tail.strip().split('\n')
169
+ for line in reversed(lines):
170
+ line = line.strip()
171
+ if not line:
172
+ continue
173
+ try:
174
+ msg = json.loads(line)
175
+ # Claude Code JSONL format: {"role": "assistant", "content": ...}
176
+ if msg.get("role") == "assistant":
177
+ content = msg.get("content", "")
178
+ if isinstance(content, list):
179
+ # Multi-part content (text blocks)
180
+ parts = []
181
+ for part in content:
182
+ if isinstance(part, dict) and part.get("type") == "text":
183
+ parts.append(part.get("text", ""))
184
+ elif isinstance(part, str):
185
+ parts.append(part)
186
+ return "\n".join(parts)
187
+ elif isinstance(content, str):
188
+ return content
189
+ except (json.JSONDecodeError, TypeError):
190
+ continue
191
+
192
+ # Fallback: if JSONL parsing fails, return last chunk of raw text
193
+ # (transcript might be plain text rather than JSONL)
194
+ return tail[-8192:] if len(tail) > 8192 else tail
195
+
196
+ except OSError:
197
+ return ""
198
+
199
+
200
+ def _extract_high_signal(text: str, existing_hashes: set) -> List[Dict[str, Any]]:
201
+ """
202
+ Scan text for high-signal patterns. Returns at most MAX_MEMORIES_PER_STOP items.
203
+ """
204
+ extractions: List[Dict[str, Any]] = []
205
+ seen = set(existing_hashes)
206
+
207
+ def _try_add(content: str, mem_type: str, importance: int, tags: List[str]):
208
+ if len(extractions) >= MAX_MEMORIES_PER_STOP:
209
+ return
210
+ h = _content_hash(content)
211
+ if h in seen:
212
+ return
213
+ seen.add(h)
214
+ if len(content) > MAX_CONTENT_LENGTH:
215
+ content = content[:MAX_CONTENT_LENGTH] + "..."
216
+ extractions.append({
217
+ "content": content,
218
+ "type": mem_type,
219
+ "importance": importance,
220
+ "tags": tags + ["auto-extracted", "stop-hook"],
221
+ "hash": h,
222
+ })
223
+
224
+ def _context_around(match_obj, source_text: str, chars: int = 200) -> str:
225
+ """Grab surrounding context aligned to line boundaries."""
226
+ start = max(0, match_obj.start() - chars)
227
+ end = min(len(source_text), match_obj.end() + chars)
228
+ while start > 0 and source_text[start] != '\n':
229
+ start -= 1
230
+ while end < len(source_text) and source_text[end] != '\n':
231
+ end += 1
232
+ return source_text[start:end].strip()
233
+
234
+ # --- Decisions (importance 7 -- higher than extract_memories' 6 because
235
+ # these patterns are narrower / higher confidence) ---
236
+ for pat in DECISION_PATTERNS:
237
+ for m in pat.finditer(text):
238
+ ctx = _context_around(m, text)
239
+ if len(ctx) > 30:
240
+ _try_add(ctx, "decision", 7, ["decision"])
241
+
242
+ # --- Error resolutions (importance 7) ---
243
+ for pat in ERROR_RESOLUTION_PATTERNS:
244
+ for m in pat.finditer(text):
245
+ ctx = _context_around(m, text)
246
+ if len(ctx) > 30:
247
+ _try_add(ctx, "error", 7, ["error", "resolution"])
248
+
249
+ # --- Architecture notes (importance 6) ---
250
+ for pat in ARCHITECTURE_PATTERNS:
251
+ for m in pat.finditer(text):
252
+ ctx = _context_around(m, text)
253
+ if len(ctx) > 30:
254
+ _try_add(ctx, "decision", 6, ["architecture", "pattern"])
255
+
256
+ return extractions
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # API call (mirrors extract_memories.store_memory_sync, tighter timeout)
261
+ # ---------------------------------------------------------------------------
262
+
263
+ def _store_memory(extraction: Dict[str, Any], project_path: Optional[str] = None) -> bool:
264
+ """Store a single memory via the memory agent A2A endpoint."""
265
+ import urllib.request
266
+ import urllib.error
267
+
268
+ payload = {
269
+ "jsonrpc": "2.0",
270
+ "method": "tasks/send",
271
+ "params": {
272
+ "message": {"parts": [{"type": "text", "text": ""}]},
273
+ "metadata": {
274
+ "skill_id": "store_memory",
275
+ "params": {
276
+ "content": extraction["content"],
277
+ "type": extraction["type"],
278
+ "importance": extraction["importance"],
279
+ "tags": extraction["tags"],
280
+ "project_path": project_path,
281
+ "agent_type": "stop-hook",
282
+ "outcome_status": "pending",
283
+ "confidence": 0.45, # Slightly above auto-extracted (0.4)
284
+ },
285
+ },
286
+ },
287
+ "id": f"stop-{extraction['hash']}-{int(time.time())}",
288
+ }
289
+
290
+ headers = {"Content-Type": "application/json"}
291
+ if API_KEY:
292
+ headers["X-Memory-Key"] = API_KEY
293
+
294
+ try:
295
+ data = json.dumps(payload).encode("utf-8")
296
+ req = urllib.request.Request(
297
+ f"{MEMORY_AGENT_URL}/a2a",
298
+ data=data,
299
+ headers=headers,
300
+ method="POST",
301
+ )
302
+ with urllib.request.urlopen(req, timeout=API_TIMEOUT_SECONDS) as resp:
303
+ return resp.status == 200
304
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError):
305
+ return False
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # Main
310
+ # ---------------------------------------------------------------------------
311
+
312
+ def main():
313
+ start = time.time()
314
+
315
+ try:
316
+ # --- Read stdin JSON ---
317
+ hook_data: Dict[str, Any] = {}
318
+ if not sys.stdin.isatty():
319
+ raw = sys.stdin.read()
320
+ if raw.strip():
321
+ hook_data = json.loads(raw)
322
+
323
+ session_id = hook_data.get("session_id", "")
324
+ transcript_path = hook_data.get("transcript_path", "")
325
+ project_path = hook_data.get("cwd") or hook_data.get("project_path", "")
326
+
327
+ if not transcript_path or not session_id:
328
+ sys.exit(0)
329
+
330
+ # --- Load existing hashes for dedup ---
331
+ existing_hashes = _load_cursor_hashes(session_id)
332
+
333
+ # --- Get only the latest assistant response ---
334
+ response_text = _get_latest_response(transcript_path)
335
+ if not response_text or len(response_text) < 40:
336
+ sys.exit(0)
337
+
338
+ # --- Extract high-signal content ---
339
+ extractions = _extract_high_signal(response_text, existing_hashes)
340
+ if not extractions:
341
+ sys.exit(0)
342
+
343
+ # --- Store via API (with time budget) ---
344
+ stored_hashes: List[str] = []
345
+ for extraction in extractions:
346
+ elapsed = time.time() - start
347
+ if elapsed >= TOTAL_TIME_BUDGET:
348
+ break
349
+ if _store_memory(extraction, project_path):
350
+ stored_hashes.append(extraction["hash"])
351
+
352
+ # --- Persist new hashes to cursor file ---
353
+ if stored_hashes:
354
+ _save_cursor_hashes(session_id, stored_hashes)
355
+
356
+ elapsed_total = round(time.time() - start, 3)
357
+ print(
358
+ f"[Stop] session={session_id} "
359
+ f"found={len(extractions)} stored={len(stored_hashes)} "
360
+ f"elapsed={elapsed_total}s",
361
+ file=sys.stderr,
362
+ )
363
+
364
+ except Exception as e:
365
+ elapsed = round(time.time() - start, 3)
366
+ print(f"[Stop] Error (non-fatal): {e} [{elapsed}s]", file=sys.stderr)
367
+
368
+ sys.exit(0)
369
+
370
+
371
+ if __name__ == "__main__":
372
+ main()