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.
- package/README.md +206 -206
- package/agent_card.py +186 -0
- package/bin/cli.js +327 -185
- package/bin/lib/banner.js +39 -0
- package/bin/lib/environment.js +166 -0
- package/bin/lib/installer.js +291 -0
- package/bin/lib/models.js +95 -0
- package/bin/lib/steps/advanced.js +101 -0
- package/bin/lib/steps/confirm.js +87 -0
- package/bin/lib/steps/model.js +57 -0
- package/bin/lib/steps/provider.js +65 -0
- package/bin/lib/steps/scope.js +59 -0
- package/bin/lib/steps/server.js +74 -0
- package/bin/lib/ui.js +75 -0
- package/bin/onboarding.js +164 -0
- package/bin/postinstall.js +35 -270
- package/config.py +103 -4
- package/dashboard.html +4902 -2689
- package/hooks/extract_memories.py +439 -0
- package/hooks/grounding-hook.py +422 -348
- package/hooks/pre_compact_hook.py +76 -0
- package/hooks/session_end.py +293 -192
- package/hooks/session_end_hook.py +149 -0
- package/hooks/session_start.py +227 -227
- package/hooks/stop_hook.py +372 -0
- package/install.py +972 -902
- package/main.py +5240 -2859
- package/mcp_server.py +451 -0
- package/package.json +58 -47
- package/requirements.txt +12 -8
- package/services/__init__.py +50 -50
- package/services/adaptive_ranker.py +272 -0
- package/services/agent_catalog.json +153 -0
- package/services/agent_registry.py +245 -730
- package/services/claude_md_sync.py +320 -4
- package/services/consolidation.py +417 -0
- package/services/curator.py +1606 -0
- package/services/database.py +4118 -2485
- package/services/embedding_pipeline.py +262 -0
- package/services/embeddings.py +493 -85
- package/services/memory_decay.py +408 -0
- package/services/native_memory_paths.py +86 -0
- package/services/native_memory_sync.py +496 -0
- package/services/response_manager.py +183 -0
- package/services/terminal_ui.py +199 -0
- package/services/tier_manager.py +235 -0
- package/services/websocket.py +26 -6
- package/skills/__init__.py +21 -1
- package/skills/confidence_tracker.py +441 -0
- package/skills/context.py +675 -0
- package/skills/curator.py +348 -0
- package/skills/search.py +444 -213
- package/skills/session_review.py +605 -0
- package/skills/store.py +484 -179
- package/terminal_dashboard.py +474 -0
- package/update_system.py +829 -817
- package/hooks/__pycache__/auto-detect-response.cpython-312.pyc +0 -0
- package/hooks/__pycache__/auto_capture.cpython-312.pyc +0 -0
- package/hooks/__pycache__/session_end.cpython-312.pyc +0 -0
- package/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
- package/services/__pycache__/__init__.cpython-312.pyc +0 -0
- package/services/__pycache__/agent_registry.cpython-312.pyc +0 -0
- package/services/__pycache__/auth.cpython-312.pyc +0 -0
- package/services/__pycache__/auto_inject.cpython-312.pyc +0 -0
- package/services/__pycache__/claude_md_sync.cpython-312.pyc +0 -0
- package/services/__pycache__/cleanup.cpython-312.pyc +0 -0
- package/services/__pycache__/compaction_flush.cpython-312.pyc +0 -0
- package/services/__pycache__/confidence.cpython-312.pyc +0 -0
- package/services/__pycache__/daily_log.cpython-312.pyc +0 -0
- package/services/__pycache__/database.cpython-312.pyc +0 -0
- package/services/__pycache__/embeddings.cpython-312.pyc +0 -0
- package/services/__pycache__/insights.cpython-312.pyc +0 -0
- package/services/__pycache__/llm_analyzer.cpython-312.pyc +0 -0
- package/services/__pycache__/memory_md_sync.cpython-312.pyc +0 -0
- package/services/__pycache__/retry_queue.cpython-312.pyc +0 -0
- package/services/__pycache__/timeline.cpython-312.pyc +0 -0
- package/services/__pycache__/vector_index.cpython-312.pyc +0 -0
- package/services/__pycache__/websocket.cpython-312.pyc +0 -0
- package/skills/__pycache__/__init__.cpython-312.pyc +0 -0
- package/skills/__pycache__/admin.cpython-312.pyc +0 -0
- package/skills/__pycache__/checkpoint.cpython-312.pyc +0 -0
- package/skills/__pycache__/claude_md.cpython-312.pyc +0 -0
- package/skills/__pycache__/cleanup.cpython-312.pyc +0 -0
- package/skills/__pycache__/grounding.cpython-312.pyc +0 -0
- package/skills/__pycache__/insights.cpython-312.pyc +0 -0
- package/skills/__pycache__/natural_language.cpython-312.pyc +0 -0
- package/skills/__pycache__/retrieve.cpython-312.pyc +0 -0
- package/skills/__pycache__/search.cpython-312.pyc +0 -0
- package/skills/__pycache__/state.cpython-312.pyc +0 -0
- package/skills/__pycache__/store.cpython-312.pyc +0 -0
- package/skills/__pycache__/summarize.cpython-312.pyc +0 -0
- package/skills/__pycache__/timeline.cpython-312.pyc +0 -0
- package/skills/__pycache__/verification.cpython-312.pyc +0 -0
- package/test_automation.py +0 -221
- package/test_complete.py +0 -338
- package/test_full.py +0 -322
- 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()
|