eon-memory 1.2.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 +3 -2
- 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,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CWD Context Switch Hook
|
|
4
|
+
==========================
|
|
5
|
+
Automatically loads project context when the working directory changes.
|
|
6
|
+
Helps maintain continuity when switching between projects.
|
|
7
|
+
|
|
8
|
+
Hook Type: CwdChanged
|
|
9
|
+
Version: 1.0.0
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# Add hooks directory
|
|
17
|
+
HOOKS_DIR = str(Path(__file__).parent)
|
|
18
|
+
if HOOKS_DIR not in sys.path:
|
|
19
|
+
sys.path.insert(0, HOOKS_DIR)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def detect_project_from_path(cwd: str) -> str:
|
|
23
|
+
"""Detect project name from working directory path."""
|
|
24
|
+
path = Path(cwd)
|
|
25
|
+
|
|
26
|
+
# Check for common project indicators
|
|
27
|
+
for part in reversed(path.parts):
|
|
28
|
+
part_lower = part.lower()
|
|
29
|
+
# Skip generic directory names
|
|
30
|
+
if part_lower in {"src", "lib", "dist", "build", "node_modules",
|
|
31
|
+
"home", "users", "var", "tmp", "mnt"}:
|
|
32
|
+
continue
|
|
33
|
+
# Use the most specific directory name
|
|
34
|
+
if len(part) > 2:
|
|
35
|
+
return part_lower
|
|
36
|
+
|
|
37
|
+
return "default"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main():
|
|
41
|
+
try:
|
|
42
|
+
input_data = json.load(sys.stdin)
|
|
43
|
+
except Exception:
|
|
44
|
+
sys.exit(0)
|
|
45
|
+
|
|
46
|
+
cwd = input_data.get("cwd", "")
|
|
47
|
+
if not cwd:
|
|
48
|
+
sys.exit(0)
|
|
49
|
+
|
|
50
|
+
project = detect_project_from_path(cwd)
|
|
51
|
+
|
|
52
|
+
# Try to load context from EON
|
|
53
|
+
context_info = ""
|
|
54
|
+
try:
|
|
55
|
+
from eon_client import get_client
|
|
56
|
+
|
|
57
|
+
client = get_client()
|
|
58
|
+
if client:
|
|
59
|
+
ctx = client.get_context(project_id=project)
|
|
60
|
+
recent = ctx.recent_memories
|
|
61
|
+
projects = ctx.projects
|
|
62
|
+
|
|
63
|
+
if recent or projects:
|
|
64
|
+
lines = [
|
|
65
|
+
f"Switched to: {cwd}",
|
|
66
|
+
f"Detected project: {project}",
|
|
67
|
+
]
|
|
68
|
+
if recent:
|
|
69
|
+
lines.append(f"Recent memories: {len(recent)}")
|
|
70
|
+
for m in recent[:3]:
|
|
71
|
+
lines.append(f" - {m.get('title', 'Untitled')}")
|
|
72
|
+
if projects:
|
|
73
|
+
lines.append(f"Known projects: {', '.join(projects[:5])}")
|
|
74
|
+
|
|
75
|
+
context_info = "\n".join(lines)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
if not context_info:
|
|
80
|
+
sys.exit(0)
|
|
81
|
+
|
|
82
|
+
output = {
|
|
83
|
+
"hookSpecificOutput": {
|
|
84
|
+
"hookEventName": "CwdChanged",
|
|
85
|
+
"additionalContext": f"\n[EON Context] {context_info}\n",
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
print(json.dumps(output))
|
|
90
|
+
sys.exit(0)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
main()
|
|
@@ -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
|