eon-memory 1.1.0 → 1.2.1
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/package.json +11 -3
- package/src/init.js +197 -12
- package/src/tier_map.json +97 -0
- package/templates/agents/alignment-validator.md +181 -0
- package/templates/agents/analytics-agent.md +93 -0
- package/templates/agents/code-simplifier.md +75 -0
- package/templates/agents/code-verifier.md +81 -0
- package/templates/agents/communication-agent.md +100 -0
- package/templates/agents/deployment-manager.md +103 -0
- package/templates/agents/incident-responder.md +116 -0
- package/templates/agents/local-llm.md +109 -0
- package/templates/agents/market-analyst.md +86 -0
- package/templates/agents/opportunity-scout.md +103 -0
- package/templates/agents/orchestrator.md +91 -0
- package/templates/agents/reflection-engine.md +157 -0
- package/templates/agents/research-agent.md +76 -0
- package/templates/agents/security-scanner.md +94 -0
- package/templates/agents/system-monitor.md +113 -0
- package/templates/agents/web-designer.md +110 -0
- package/templates/hooks/.omc/state/agent-replay-24ba3c54-a19a-4384-85b9-5c509ae41c2c.jsonl +1 -0
- package/templates/hooks/.omc/state/idle-notif-cooldown.json +3 -0
- package/templates/hooks/.omc/state/subagent-tracking.json +7 -0
- package/templates/hooks/__pycache__/agent_trigger.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/cwd_context_switch.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/eon_client.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/eon_memory_search.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/hook_utils.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/memory_quality_gate.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/post_code_check.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/post_compact_reload.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/session_end_save.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/smart_permissions.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/stop_failure_recovery.cpython-312.pyc +0 -0
- package/templates/hooks/agent_trigger.py +220 -0
- package/templates/hooks/cwd_context_switch.py +94 -0
- package/templates/hooks/eon_client.py +565 -0
- package/templates/hooks/eon_memory_search.py +147 -0
- package/templates/hooks/hook_utils.py +96 -0
- package/templates/hooks/memory_quality_gate.py +97 -0
- package/templates/hooks/post_code_check.py +179 -0
- package/templates/hooks/post_compact_reload.py +59 -0
- package/templates/hooks/session_end_save.py +91 -0
- package/templates/hooks/smart_permissions.py +85 -0
- package/templates/hooks/stop_failure_recovery.py +57 -0
- package/templates/skills/goal-tracker.md +42 -0
- package/templates/skills/health-check.md +50 -0
- package/templates/skills/memory-audit.md +54 -0
- package/templates/skills/self-improvement-loop.md +60 -0
- package/templates/skills/x-alignment-check.md +68 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
EON Memory API Client - Production Grade
|
|
4
|
+
==========================================
|
|
5
|
+
Zero-dependency Python client for EON Memory hooks and agents.
|
|
6
|
+
Uses only Python stdlib (urllib, json, logging, threading, uuid, time).
|
|
7
|
+
|
|
8
|
+
Features:
|
|
9
|
+
- Retry with exponential backoff (3 attempts)
|
|
10
|
+
- Request correlation IDs for debugging/support
|
|
11
|
+
- Structured error handling (EONAPIError)
|
|
12
|
+
- Connection timeout management
|
|
13
|
+
- Response validation
|
|
14
|
+
- Thread-safe singleton
|
|
15
|
+
- Optional file logging
|
|
16
|
+
|
|
17
|
+
Config: ~/.claude/eon_config.json
|
|
18
|
+
{
|
|
19
|
+
"api_key": "eon_xxx...",
|
|
20
|
+
"endpoint": "https://app.ai-developer.ch",
|
|
21
|
+
"tier": "starter|business|enterprise",
|
|
22
|
+
"log_file": "~/.claude/logs/eon_client.log" // optional
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Version: 1.0.0 (2026-04-11)
|
|
26
|
+
License: Proprietary - EON Memory System
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
import uuid
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Dict, List, Optional
|
|
37
|
+
from urllib.error import HTTPError, URLError
|
|
38
|
+
from urllib.request import Request, urlopen
|
|
39
|
+
|
|
40
|
+
__version__ = "1.0.0"
|
|
41
|
+
|
|
42
|
+
# ============================================================
|
|
43
|
+
# Configuration
|
|
44
|
+
# ============================================================
|
|
45
|
+
|
|
46
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".claude" / "eon_config.json"
|
|
47
|
+
DEFAULT_ENDPOINT = "https://app.ai-developer.ch"
|
|
48
|
+
CONNECT_TIMEOUT = 5 # seconds
|
|
49
|
+
READ_TIMEOUT = 30 # seconds
|
|
50
|
+
MAX_RETRIES = 3
|
|
51
|
+
BACKOFF_BASE = 1.0 # seconds (1s, 2s, 4s)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ============================================================
|
|
55
|
+
# Exceptions
|
|
56
|
+
# ============================================================
|
|
57
|
+
|
|
58
|
+
class EONAPIError(Exception):
|
|
59
|
+
"""Structured API error with status, message, and request correlation ID."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, message: str, status: int = 0, request_id: str = "",
|
|
62
|
+
response_body: str = ""):
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
self.status = status
|
|
65
|
+
self.request_id = request_id
|
|
66
|
+
self.response_body = response_body
|
|
67
|
+
|
|
68
|
+
def __str__(self):
|
|
69
|
+
parts = [f"EONAPIError: {self.args[0]}"]
|
|
70
|
+
if self.status:
|
|
71
|
+
parts.append(f"status={self.status}")
|
|
72
|
+
if self.request_id:
|
|
73
|
+
parts.append(f"request_id={self.request_id}")
|
|
74
|
+
return " | ".join(parts)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class EONConfigError(EONAPIError):
|
|
78
|
+
"""Raised when configuration is missing or invalid."""
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class EONAuthError(EONAPIError):
|
|
83
|
+
"""Raised on 401/403 authentication failures."""
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class EONRateLimitError(EONAPIError):
|
|
88
|
+
"""Raised on 429 rate limit responses."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, message: str, retry_after: float = 0, **kwargs):
|
|
91
|
+
super().__init__(message, **kwargs)
|
|
92
|
+
self.retry_after = retry_after
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ============================================================
|
|
96
|
+
# Data Classes (plain dicts with typed accessors)
|
|
97
|
+
# ============================================================
|
|
98
|
+
|
|
99
|
+
class Memory(dict):
|
|
100
|
+
"""Memory record with typed accessors."""
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def id(self) -> Optional[int]:
|
|
104
|
+
return self.get("id") or self.get("memory_id")
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def title(self) -> str:
|
|
108
|
+
return self.get("title", "")
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def content(self) -> str:
|
|
112
|
+
return self.get("content", "")
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def project_id(self) -> str:
|
|
116
|
+
return self.get("project_id", "")
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def category(self) -> str:
|
|
120
|
+
return self.get("category", "")
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def quality_score(self) -> float:
|
|
124
|
+
return float(self.get("quality_score", 0.0))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ProjectContext(dict):
|
|
128
|
+
"""Project context with typed accessors."""
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def recent_memories(self) -> List[Dict]:
|
|
132
|
+
return self.get("recent_memories", [])
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def projects(self) -> List[str]:
|
|
136
|
+
return self.get("projects", [])
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def stats(self) -> Dict:
|
|
140
|
+
return self.get("stats", {})
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class HealthStatus(dict):
|
|
144
|
+
"""Health check result."""
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def ok(self) -> bool:
|
|
148
|
+
return self.get("status") == "ok"
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def service(self) -> str:
|
|
152
|
+
return self.get("service", "")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class AgentConfig(dict):
|
|
156
|
+
"""Tier-based agent configuration."""
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def tier(self) -> str:
|
|
160
|
+
return self.get("tier", "starter")
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def agents(self) -> List[str]:
|
|
164
|
+
return self.get("agents", [])
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def hooks(self) -> List[str]:
|
|
168
|
+
return self.get("hooks", [])
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def skills(self) -> List[str]:
|
|
172
|
+
return self.get("skills", [])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ============================================================
|
|
176
|
+
# EON Client - Thread-safe Singleton
|
|
177
|
+
# ============================================================
|
|
178
|
+
|
|
179
|
+
class EONClient:
|
|
180
|
+
"""
|
|
181
|
+
Production-grade EON Memory API client.
|
|
182
|
+
|
|
183
|
+
Thread-safe, retries with backoff, structured errors,
|
|
184
|
+
correlation IDs for every request.
|
|
185
|
+
|
|
186
|
+
Usage:
|
|
187
|
+
client = EONClient() # loads config from ~/.claude/eon_config.json
|
|
188
|
+
results = client.search("database migration")
|
|
189
|
+
client.create("Fix Applied", "Details...", project_id="myproject")
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
_instance = None
|
|
193
|
+
_lock = threading.Lock()
|
|
194
|
+
|
|
195
|
+
def __new__(cls, config_path: Optional[str] = None):
|
|
196
|
+
"""Thread-safe singleton - one client per process."""
|
|
197
|
+
with cls._lock:
|
|
198
|
+
if cls._instance is None:
|
|
199
|
+
cls._instance = super().__new__(cls)
|
|
200
|
+
cls._instance._initialized = False
|
|
201
|
+
return cls._instance
|
|
202
|
+
|
|
203
|
+
def __init__(self, config_path: Optional[str] = None):
|
|
204
|
+
if self._initialized:
|
|
205
|
+
return
|
|
206
|
+
self._initialized = True
|
|
207
|
+
self._request_lock = threading.Lock()
|
|
208
|
+
|
|
209
|
+
# Load config
|
|
210
|
+
path = Path(config_path) if config_path else DEFAULT_CONFIG_PATH
|
|
211
|
+
self._config = self._load_config(path)
|
|
212
|
+
self._api_key = self._config.get("api_key", "")
|
|
213
|
+
self._endpoint = self._config.get("endpoint", DEFAULT_ENDPOINT).rstrip("/")
|
|
214
|
+
self._tier = self._config.get("tier", "starter")
|
|
215
|
+
|
|
216
|
+
if not self._api_key:
|
|
217
|
+
raise EONConfigError(
|
|
218
|
+
f"No api_key found in {path}. Run 'npx eon-memory init' to configure."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Setup logging
|
|
222
|
+
self._logger = logging.getLogger("eon_client")
|
|
223
|
+
log_file = self._config.get("log_file", "")
|
|
224
|
+
if log_file:
|
|
225
|
+
log_path = Path(os.path.expanduser(log_file))
|
|
226
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
handler = logging.FileHandler(str(log_path), encoding="utf-8")
|
|
228
|
+
handler.setFormatter(logging.Formatter(
|
|
229
|
+
"%(asctime)s [%(levelname)s] %(message)s"
|
|
230
|
+
))
|
|
231
|
+
self._logger.addHandler(handler)
|
|
232
|
+
self._logger.setLevel(logging.DEBUG)
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _load_config(path: Path) -> Dict[str, Any]:
|
|
236
|
+
"""Load config from JSON file."""
|
|
237
|
+
if not path.exists():
|
|
238
|
+
raise EONConfigError(
|
|
239
|
+
f"Config not found: {path}. Run 'npx eon-memory init' to set up."
|
|
240
|
+
)
|
|
241
|
+
try:
|
|
242
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
243
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
244
|
+
raise EONConfigError(f"Invalid config at {path}: {e}")
|
|
245
|
+
|
|
246
|
+
# --------------------------------------------------------
|
|
247
|
+
# HTTP Layer
|
|
248
|
+
# --------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def _request(self, method: str, path: str, body: Optional[Dict] = None,
|
|
251
|
+
timeout: float = READ_TIMEOUT) -> Dict[str, Any]:
|
|
252
|
+
"""
|
|
253
|
+
Make an HTTP request with retry, backoff, and correlation ID.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
method: HTTP method (GET, POST, PUT, DELETE)
|
|
257
|
+
path: API path (e.g., "/api/hooks/search")
|
|
258
|
+
body: JSON body for POST/PUT
|
|
259
|
+
timeout: Read timeout in seconds
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Parsed JSON response as dict
|
|
263
|
+
|
|
264
|
+
Raises:
|
|
265
|
+
EONAPIError: On non-retryable errors
|
|
266
|
+
EONAuthError: On 401/403
|
|
267
|
+
EONRateLimitError: On 429
|
|
268
|
+
"""
|
|
269
|
+
url = f"{self._endpoint}{path}"
|
|
270
|
+
request_id = str(uuid.uuid4())[:8]
|
|
271
|
+
|
|
272
|
+
headers = {
|
|
273
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
274
|
+
"Content-Type": "application/json",
|
|
275
|
+
"Accept": "application/json",
|
|
276
|
+
"X-Request-ID": request_id,
|
|
277
|
+
"X-Client-Version": __version__,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
data = json.dumps(body).encode("utf-8") if body else None
|
|
281
|
+
last_error = None
|
|
282
|
+
|
|
283
|
+
for attempt in range(1, MAX_RETRIES + 1):
|
|
284
|
+
try:
|
|
285
|
+
req = Request(url, data=data, headers=headers, method=method)
|
|
286
|
+
self._logger.debug(
|
|
287
|
+
f"[{request_id}] {method} {path} attempt={attempt}"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
with urlopen(req, timeout=timeout) as resp:
|
|
291
|
+
resp_body = resp.read().decode("utf-8")
|
|
292
|
+
result = json.loads(resp_body) if resp_body else {}
|
|
293
|
+
self._logger.debug(
|
|
294
|
+
f"[{request_id}] {resp.status} OK ({len(resp_body)}B)"
|
|
295
|
+
)
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
except HTTPError as e:
|
|
299
|
+
resp_body = ""
|
|
300
|
+
try:
|
|
301
|
+
resp_body = e.read().decode("utf-8", errors="replace")
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
self._logger.warning(
|
|
306
|
+
f"[{request_id}] HTTP {e.code} on attempt {attempt}: {resp_body[:200]}"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Non-retryable errors
|
|
310
|
+
if e.code == 401 or e.code == 403:
|
|
311
|
+
raise EONAuthError(
|
|
312
|
+
f"Authentication failed ({e.code})",
|
|
313
|
+
status=e.code,
|
|
314
|
+
request_id=request_id,
|
|
315
|
+
response_body=resp_body,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if e.code == 429:
|
|
319
|
+
retry_after = float(e.headers.get("Retry-After", "5"))
|
|
320
|
+
if attempt < MAX_RETRIES:
|
|
321
|
+
time.sleep(retry_after)
|
|
322
|
+
continue
|
|
323
|
+
raise EONRateLimitError(
|
|
324
|
+
"Rate limit exceeded",
|
|
325
|
+
retry_after=retry_after,
|
|
326
|
+
status=429,
|
|
327
|
+
request_id=request_id,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
if e.code == 400 or e.code == 404 or e.code == 422:
|
|
331
|
+
detail = ""
|
|
332
|
+
try:
|
|
333
|
+
detail = json.loads(resp_body).get("detail", resp_body)
|
|
334
|
+
except Exception:
|
|
335
|
+
detail = resp_body
|
|
336
|
+
raise EONAPIError(
|
|
337
|
+
f"Client error: {detail}",
|
|
338
|
+
status=e.code,
|
|
339
|
+
request_id=request_id,
|
|
340
|
+
response_body=resp_body,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Server errors (5xx) - retry
|
|
344
|
+
last_error = EONAPIError(
|
|
345
|
+
f"Server error ({e.code})",
|
|
346
|
+
status=e.code,
|
|
347
|
+
request_id=request_id,
|
|
348
|
+
response_body=resp_body,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
except URLError as e:
|
|
352
|
+
self._logger.warning(
|
|
353
|
+
f"[{request_id}] Network error on attempt {attempt}: {e.reason}"
|
|
354
|
+
)
|
|
355
|
+
last_error = EONAPIError(
|
|
356
|
+
f"Network error: {e.reason}",
|
|
357
|
+
request_id=request_id,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
except Exception as e:
|
|
361
|
+
self._logger.warning(
|
|
362
|
+
f"[{request_id}] Unexpected error on attempt {attempt}: {e}"
|
|
363
|
+
)
|
|
364
|
+
last_error = EONAPIError(
|
|
365
|
+
f"Unexpected error: {e}",
|
|
366
|
+
request_id=request_id,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# Exponential backoff before retry
|
|
370
|
+
if attempt < MAX_RETRIES:
|
|
371
|
+
wait = BACKOFF_BASE * (2 ** (attempt - 1))
|
|
372
|
+
self._logger.debug(f"[{request_id}] Retrying in {wait}s...")
|
|
373
|
+
time.sleep(wait)
|
|
374
|
+
|
|
375
|
+
# All retries exhausted
|
|
376
|
+
raise last_error or EONAPIError(
|
|
377
|
+
"Request failed after all retries",
|
|
378
|
+
request_id=request_id,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# --------------------------------------------------------
|
|
382
|
+
# Public API Methods
|
|
383
|
+
# --------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
def search(self, query: str, project_id: Optional[str] = None,
|
|
386
|
+
n_results: int = 5) -> List[Memory]:
|
|
387
|
+
"""
|
|
388
|
+
Semantic search across memories.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
query: Search query string
|
|
392
|
+
project_id: Optional project filter
|
|
393
|
+
n_results: Number of results (1-20)
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
List of Memory objects sorted by relevance
|
|
397
|
+
"""
|
|
398
|
+
body = {"query": query, "n_results": min(max(n_results, 1), 20)}
|
|
399
|
+
if project_id:
|
|
400
|
+
body["project_id"] = project_id
|
|
401
|
+
|
|
402
|
+
resp = self._request("POST", "/api/hooks/search", body)
|
|
403
|
+
return [Memory(r) for r in resp.get("results", [])]
|
|
404
|
+
|
|
405
|
+
def create(self, title: str, content: str, project_id: str = "default",
|
|
406
|
+
category: str = "update") -> Memory:
|
|
407
|
+
"""
|
|
408
|
+
Create a new memory.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
title: Memory title (min 3 chars)
|
|
412
|
+
content: Memory content (min 20 chars)
|
|
413
|
+
project_id: Project to store in
|
|
414
|
+
category: One of: update, context, decision, error, roadmap
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
Created Memory object with id
|
|
418
|
+
"""
|
|
419
|
+
body = {
|
|
420
|
+
"title": title,
|
|
421
|
+
"content": content,
|
|
422
|
+
"project_id": project_id,
|
|
423
|
+
"category": category,
|
|
424
|
+
}
|
|
425
|
+
resp = self._request("POST", "/api/hooks/create", body)
|
|
426
|
+
return Memory(resp)
|
|
427
|
+
|
|
428
|
+
def update(self, memory_id: int, **updates) -> Memory:
|
|
429
|
+
"""
|
|
430
|
+
Update an existing memory.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
memory_id: ID of memory to update
|
|
434
|
+
**updates: Fields to update (title, content, project_id, category)
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
Updated Memory object
|
|
438
|
+
"""
|
|
439
|
+
body = {"memory_id": memory_id, **updates}
|
|
440
|
+
resp = self._request("POST", "/api/hooks/update", body)
|
|
441
|
+
return Memory(resp)
|
|
442
|
+
|
|
443
|
+
def get(self, memory_id: int) -> Memory:
|
|
444
|
+
"""Get a single memory by ID."""
|
|
445
|
+
resp = self._request("POST", "/api/hooks/search",
|
|
446
|
+
{"query": f"id:{memory_id}", "n_results": 1})
|
|
447
|
+
results = resp.get("results", [])
|
|
448
|
+
if not results:
|
|
449
|
+
raise EONAPIError(f"Memory {memory_id} not found", status=404)
|
|
450
|
+
return Memory(results[0])
|
|
451
|
+
|
|
452
|
+
def list(self, project_id: Optional[str] = None,
|
|
453
|
+
limit: int = 50) -> List[Memory]:
|
|
454
|
+
"""
|
|
455
|
+
List memories, optionally filtered by project.
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
project_id: Optional project filter
|
|
459
|
+
limit: Maximum results (1-100)
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
List of Memory objects
|
|
463
|
+
"""
|
|
464
|
+
body = {"query": "*", "n_results": min(max(limit, 1), 100)}
|
|
465
|
+
if project_id:
|
|
466
|
+
body["project_id"] = project_id
|
|
467
|
+
resp = self._request("POST", "/api/hooks/search", body)
|
|
468
|
+
return [Memory(r) for r in resp.get("results", [])]
|
|
469
|
+
|
|
470
|
+
def get_context(self, project_id: Optional[str] = None) -> ProjectContext:
|
|
471
|
+
"""
|
|
472
|
+
Get project context (recent memories, projects, stats).
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
project_id: Optional project filter
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
ProjectContext with recent_memories, projects, stats
|
|
479
|
+
"""
|
|
480
|
+
resp = self._request("POST", "/api/hooks/context")
|
|
481
|
+
return ProjectContext(resp)
|
|
482
|
+
|
|
483
|
+
def save_session(self, summary: Optional[str] = None,
|
|
484
|
+
decisions: Optional[List[str]] = None,
|
|
485
|
+
blockers: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
486
|
+
"""
|
|
487
|
+
End and save the current session.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
summary: Session summary text
|
|
491
|
+
decisions: List of decisions made
|
|
492
|
+
blockers: List of blockers encountered
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
Session save confirmation
|
|
496
|
+
"""
|
|
497
|
+
body = {"action": "end"}
|
|
498
|
+
if summary:
|
|
499
|
+
body["summary"] = summary
|
|
500
|
+
if decisions:
|
|
501
|
+
body["decisions"] = decisions
|
|
502
|
+
if blockers:
|
|
503
|
+
body["blockers"] = blockers
|
|
504
|
+
|
|
505
|
+
return self._request("POST", "/api/hooks/session", body)
|
|
506
|
+
|
|
507
|
+
def get_agent_config(self) -> AgentConfig:
|
|
508
|
+
"""
|
|
509
|
+
Get tier-based agent/hook/skill configuration.
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
AgentConfig with tier, agents, hooks, skills lists
|
|
513
|
+
"""
|
|
514
|
+
resp = self._request("GET", "/api/hooks/config")
|
|
515
|
+
return AgentConfig(resp)
|
|
516
|
+
|
|
517
|
+
def health(self) -> HealthStatus:
|
|
518
|
+
"""
|
|
519
|
+
Quick health check (< 100ms target).
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
HealthStatus with ok flag and service name
|
|
523
|
+
"""
|
|
524
|
+
resp = self._request("GET", "/api/hooks/health", timeout=CONNECT_TIMEOUT)
|
|
525
|
+
return HealthStatus(resp)
|
|
526
|
+
|
|
527
|
+
# --------------------------------------------------------
|
|
528
|
+
# Convenience
|
|
529
|
+
# --------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
@property
|
|
532
|
+
def tier(self) -> str:
|
|
533
|
+
"""Current subscription tier."""
|
|
534
|
+
return self._tier
|
|
535
|
+
|
|
536
|
+
@property
|
|
537
|
+
def endpoint(self) -> str:
|
|
538
|
+
"""API endpoint URL."""
|
|
539
|
+
return self._endpoint
|
|
540
|
+
|
|
541
|
+
def __repr__(self):
|
|
542
|
+
return f"EONClient(endpoint={self._endpoint!r}, tier={self._tier!r})"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# ============================================================
|
|
546
|
+
# Module-level convenience function
|
|
547
|
+
# ============================================================
|
|
548
|
+
|
|
549
|
+
_default_client: Optional[EONClient] = None
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def get_client(config_path: Optional[str] = None) -> EONClient:
|
|
553
|
+
"""
|
|
554
|
+
Get or create the singleton EON client.
|
|
555
|
+
|
|
556
|
+
Returns EONClient instance, creating it on first call.
|
|
557
|
+
Safe to call from hooks - returns quickly if already initialized.
|
|
558
|
+
"""
|
|
559
|
+
global _default_client
|
|
560
|
+
if _default_client is None:
|
|
561
|
+
try:
|
|
562
|
+
_default_client = EONClient(config_path)
|
|
563
|
+
except EONConfigError:
|
|
564
|
+
return None
|
|
565
|
+
return _default_client
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
EON Memory Search Hook - Semantic Context Injection
|
|
4
|
+
======================================================
|
|
5
|
+
Searches EON Memory on every user prompt and injects relevant
|
|
6
|
+
context into the conversation.
|
|
7
|
+
|
|
8
|
+
Uses eon_client.py for API calls to the EON backend.
|
|
9
|
+
|
|
10
|
+
Hook Type: UserPromptSubmit
|
|
11
|
+
Version: 1.0.0
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# Add hooks directory for eon_client import
|
|
19
|
+
HOOKS_DIR = str(Path(__file__).parent)
|
|
20
|
+
if HOOKS_DIR not in sys.path:
|
|
21
|
+
sys.path.insert(0, HOOKS_DIR)
|
|
22
|
+
|
|
23
|
+
# Common greetings to skip (no search needed)
|
|
24
|
+
SKIP_MESSAGES = {
|
|
25
|
+
"hi", "hello", "hey", "ok", "okay", "yes", "no",
|
|
26
|
+
"thanks", "thank you", "bye", "quit", "exit",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Minimum message length for search
|
|
30
|
+
MIN_LENGTH = 15
|
|
31
|
+
|
|
32
|
+
# Maximum results to inject
|
|
33
|
+
MAX_RESULTS = 5
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract_keywords(text: str) -> str:
|
|
37
|
+
"""Extract meaningful keywords from text for search."""
|
|
38
|
+
# Remove common stop words
|
|
39
|
+
stop_words = {
|
|
40
|
+
"the", "a", "an", "is", "are", "was", "were", "be", "been",
|
|
41
|
+
"being", "have", "has", "had", "do", "does", "did", "will",
|
|
42
|
+
"would", "could", "should", "may", "might", "can", "to",
|
|
43
|
+
"of", "in", "for", "on", "with", "at", "by", "from", "as",
|
|
44
|
+
"into", "through", "during", "before", "after", "and", "but",
|
|
45
|
+
"or", "not", "no", "so", "if", "then", "than", "too", "very",
|
|
46
|
+
"just", "about", "up", "out", "how", "what", "which", "who",
|
|
47
|
+
"when", "where", "why", "all", "each", "every", "both", "few",
|
|
48
|
+
"more", "most", "other", "some", "such", "only", "own", "same",
|
|
49
|
+
"that", "this", "it", "i", "me", "my", "we", "our", "you",
|
|
50
|
+
"your", "he", "she", "they", "them", "please", "help",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
words = text.lower().split()
|
|
54
|
+
keywords = [w for w in words if w not in stop_words and len(w) > 2]
|
|
55
|
+
return " ".join(keywords[:10])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_results(results: list) -> str:
|
|
59
|
+
"""Format search results as context string."""
|
|
60
|
+
if not results:
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
lines = [
|
|
64
|
+
"============================================================",
|
|
65
|
+
"EON MEMORY CONTEXT",
|
|
66
|
+
"============================================================",
|
|
67
|
+
"",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
for r in results:
|
|
71
|
+
memory_id = r.get("memory_id", "?")
|
|
72
|
+
title = r.get("title", "Untitled")
|
|
73
|
+
preview = r.get("content_preview", "")[:80]
|
|
74
|
+
project = r.get("project_id", "")
|
|
75
|
+
similarity = r.get("similarity", 0)
|
|
76
|
+
|
|
77
|
+
lines.append(f"[#{memory_id}] {title}")
|
|
78
|
+
if project:
|
|
79
|
+
lines.append(f" Project: {project} | Relevance: {similarity:.2f}")
|
|
80
|
+
if preview:
|
|
81
|
+
lines.append(f" Preview: {preview}")
|
|
82
|
+
lines.append("")
|
|
83
|
+
|
|
84
|
+
lines.extend([
|
|
85
|
+
"============================================================",
|
|
86
|
+
"Use this context to inform your response.",
|
|
87
|
+
"============================================================",
|
|
88
|
+
])
|
|
89
|
+
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main():
|
|
94
|
+
try:
|
|
95
|
+
input_data = json.load(sys.stdin)
|
|
96
|
+
except Exception:
|
|
97
|
+
sys.exit(0)
|
|
98
|
+
|
|
99
|
+
prompt = input_data.get("prompt", "")
|
|
100
|
+
|
|
101
|
+
# Skip short messages and greetings
|
|
102
|
+
stripped = prompt.strip().lower().rstrip("!.,?")
|
|
103
|
+
if not prompt or len(prompt) < MIN_LENGTH or stripped in SKIP_MESSAGES:
|
|
104
|
+
sys.exit(0)
|
|
105
|
+
|
|
106
|
+
# Try to search via eon_client
|
|
107
|
+
try:
|
|
108
|
+
from eon_client import get_client
|
|
109
|
+
|
|
110
|
+
client = get_client()
|
|
111
|
+
if not client:
|
|
112
|
+
sys.exit(0)
|
|
113
|
+
|
|
114
|
+
# Extract keywords for better search
|
|
115
|
+
query = extract_keywords(prompt)
|
|
116
|
+
if not query:
|
|
117
|
+
query = prompt[:100]
|
|
118
|
+
|
|
119
|
+
results = client.search(query, n_results=MAX_RESULTS)
|
|
120
|
+
|
|
121
|
+
if not results:
|
|
122
|
+
sys.exit(0)
|
|
123
|
+
|
|
124
|
+
# Convert Memory objects to dicts for formatting
|
|
125
|
+
result_dicts = [dict(r) for r in results]
|
|
126
|
+
context = format_results(result_dicts)
|
|
127
|
+
|
|
128
|
+
if not context:
|
|
129
|
+
sys.exit(0)
|
|
130
|
+
|
|
131
|
+
output = {
|
|
132
|
+
"hookSpecificOutput": {
|
|
133
|
+
"hookEventName": "UserPromptSubmit",
|
|
134
|
+
"additionalContext": context,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
print(json.dumps(output))
|
|
139
|
+
|
|
140
|
+
except Exception:
|
|
141
|
+
pass # Fail silently - don't block the user
|
|
142
|
+
|
|
143
|
+
sys.exit(0)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
main()
|