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.
Files changed (47) hide show
  1. package/package.json +3 -2
  2. package/templates/agents/alignment-validator.md +181 -0
  3. package/templates/agents/analytics-agent.md +93 -0
  4. package/templates/agents/code-simplifier.md +75 -0
  5. package/templates/agents/code-verifier.md +81 -0
  6. package/templates/agents/communication-agent.md +100 -0
  7. package/templates/agents/deployment-manager.md +103 -0
  8. package/templates/agents/incident-responder.md +116 -0
  9. package/templates/agents/local-llm.md +109 -0
  10. package/templates/agents/market-analyst.md +86 -0
  11. package/templates/agents/opportunity-scout.md +103 -0
  12. package/templates/agents/orchestrator.md +91 -0
  13. package/templates/agents/reflection-engine.md +157 -0
  14. package/templates/agents/research-agent.md +76 -0
  15. package/templates/agents/security-scanner.md +94 -0
  16. package/templates/agents/system-monitor.md +113 -0
  17. package/templates/agents/web-designer.md +110 -0
  18. package/templates/hooks/.omc/state/agent-replay-24ba3c54-a19a-4384-85b9-5c509ae41c2c.jsonl +1 -0
  19. package/templates/hooks/.omc/state/idle-notif-cooldown.json +3 -0
  20. package/templates/hooks/.omc/state/subagent-tracking.json +7 -0
  21. package/templates/hooks/__pycache__/agent_trigger.cpython-312.pyc +0 -0
  22. package/templates/hooks/__pycache__/cwd_context_switch.cpython-312.pyc +0 -0
  23. package/templates/hooks/__pycache__/eon_client.cpython-312.pyc +0 -0
  24. package/templates/hooks/__pycache__/eon_memory_search.cpython-312.pyc +0 -0
  25. package/templates/hooks/__pycache__/hook_utils.cpython-312.pyc +0 -0
  26. package/templates/hooks/__pycache__/memory_quality_gate.cpython-312.pyc +0 -0
  27. package/templates/hooks/__pycache__/post_code_check.cpython-312.pyc +0 -0
  28. package/templates/hooks/__pycache__/post_compact_reload.cpython-312.pyc +0 -0
  29. package/templates/hooks/__pycache__/session_end_save.cpython-312.pyc +0 -0
  30. package/templates/hooks/__pycache__/smart_permissions.cpython-312.pyc +0 -0
  31. package/templates/hooks/__pycache__/stop_failure_recovery.cpython-312.pyc +0 -0
  32. package/templates/hooks/agent_trigger.py +220 -0
  33. package/templates/hooks/cwd_context_switch.py +94 -0
  34. package/templates/hooks/eon_client.py +565 -0
  35. package/templates/hooks/eon_memory_search.py +147 -0
  36. package/templates/hooks/hook_utils.py +96 -0
  37. package/templates/hooks/memory_quality_gate.py +97 -0
  38. package/templates/hooks/post_code_check.py +179 -0
  39. package/templates/hooks/post_compact_reload.py +59 -0
  40. package/templates/hooks/session_end_save.py +91 -0
  41. package/templates/hooks/smart_permissions.py +85 -0
  42. package/templates/hooks/stop_failure_recovery.py +57 -0
  43. package/templates/skills/goal-tracker.md +42 -0
  44. package/templates/skills/health-check.md +50 -0
  45. package/templates/skills/memory-audit.md +54 -0
  46. package/templates/skills/self-improvement-loop.md +60 -0
  47. 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