claude-smart 0.2.23 → 0.2.24

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 (113) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +69 -27
  3. package/bin/claude-smart.js +296 -11
  4. package/package.json +11 -1
  5. package/plugin/.claude-plugin/plugin.json +17 -0
  6. package/plugin/.codex-plugin/plugin.json +35 -0
  7. package/plugin/LICENSE +202 -0
  8. package/plugin/README.md +37 -0
  9. package/plugin/bin/cs-cite +77 -0
  10. package/plugin/commands/clear-all.md +8 -0
  11. package/plugin/commands/dashboard.md +8 -0
  12. package/plugin/commands/learn.md +12 -0
  13. package/plugin/commands/restart.md +8 -0
  14. package/plugin/commands/show.md +8 -0
  15. package/plugin/dashboard/AGENTS.md +6 -0
  16. package/plugin/dashboard/app/api/claude-settings/route.ts +19 -0
  17. package/plugin/dashboard/app/api/config/route.ts +16 -0
  18. package/plugin/dashboard/app/api/health/route.ts +10 -0
  19. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +63 -0
  20. package/plugin/dashboard/app/api/sessions/[id]/route.ts +28 -0
  21. package/plugin/dashboard/app/api/sessions/route.ts +14 -0
  22. package/plugin/dashboard/app/configure/env/page.tsx +318 -0
  23. package/plugin/dashboard/app/configure/layout.tsx +47 -0
  24. package/plugin/dashboard/app/configure/page.tsx +5 -0
  25. package/plugin/dashboard/app/configure/server/page.tsx +258 -0
  26. package/plugin/dashboard/app/dashboard/page.tsx +227 -0
  27. package/plugin/dashboard/app/globals.css +129 -0
  28. package/plugin/dashboard/app/icon.png +0 -0
  29. package/plugin/dashboard/app/layout.tsx +40 -0
  30. package/plugin/dashboard/app/page.tsx +5 -0
  31. package/plugin/dashboard/app/preferences/[id]/page.tsx +531 -0
  32. package/plugin/dashboard/app/preferences/page.tsx +126 -0
  33. package/plugin/dashboard/app/providers.tsx +12 -0
  34. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +321 -0
  35. package/plugin/dashboard/app/sessions/page.tsx +186 -0
  36. package/plugin/dashboard/app/skills/page.tsx +362 -0
  37. package/plugin/dashboard/app/skills/project/[id]/page.tsx +597 -0
  38. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +830 -0
  39. package/plugin/dashboard/components/common/delete-all-button.tsx +45 -0
  40. package/plugin/dashboard/components/common/empty-state.tsx +34 -0
  41. package/plugin/dashboard/components/common/learnings-badge.tsx +34 -0
  42. package/plugin/dashboard/components/common/page-header.tsx +34 -0
  43. package/plugin/dashboard/components/common/page-tabs.tsx +115 -0
  44. package/plugin/dashboard/components/common/stat-card.tsx +38 -0
  45. package/plugin/dashboard/components/layout/nav-items.ts +22 -0
  46. package/plugin/dashboard/components/layout/sidebar.tsx +45 -0
  47. package/plugin/dashboard/components/layout/top-bar.tsx +64 -0
  48. package/plugin/dashboard/components/stall-banner.tsx +53 -0
  49. package/plugin/dashboard/components/ui/badge.tsx +52 -0
  50. package/plugin/dashboard/components/ui/button.tsx +60 -0
  51. package/plugin/dashboard/components/ui/collapsible.tsx +21 -0
  52. package/plugin/dashboard/components/ui/input.tsx +20 -0
  53. package/plugin/dashboard/components/ui/label.tsx +20 -0
  54. package/plugin/dashboard/components/ui/scroll-area.tsx +55 -0
  55. package/plugin/dashboard/components/ui/select.tsx +201 -0
  56. package/plugin/dashboard/components/ui/separator.tsx +25 -0
  57. package/plugin/dashboard/components/ui/sheet.tsx +135 -0
  58. package/plugin/dashboard/components/ui/switch.tsx +32 -0
  59. package/plugin/dashboard/components.json +25 -0
  60. package/plugin/dashboard/eslint.config.mjs +16 -0
  61. package/plugin/dashboard/hooks/use-settings.tsx +88 -0
  62. package/plugin/dashboard/hooks/use-stall-state.ts +59 -0
  63. package/plugin/dashboard/lib/claude-settings-file.ts +114 -0
  64. package/plugin/dashboard/lib/config-file.ts +131 -0
  65. package/plugin/dashboard/lib/format.ts +58 -0
  66. package/plugin/dashboard/lib/reflexio-client.ts +238 -0
  67. package/plugin/dashboard/lib/reflexio-url.ts +17 -0
  68. package/plugin/dashboard/lib/session-reader.ts +245 -0
  69. package/plugin/dashboard/lib/status.ts +24 -0
  70. package/plugin/dashboard/lib/types.ts +145 -0
  71. package/plugin/dashboard/lib/utils.ts +6 -0
  72. package/plugin/dashboard/next.config.ts +7 -0
  73. package/plugin/dashboard/package-lock.json +10275 -0
  74. package/plugin/dashboard/package.json +37 -0
  75. package/plugin/dashboard/postcss.config.mjs +7 -0
  76. package/plugin/dashboard/public/claude-smart-icon.png +0 -0
  77. package/plugin/dashboard/tsconfig.json +34 -0
  78. package/plugin/hooks/codex-hooks.json +67 -0
  79. package/plugin/hooks/hooks.json +111 -0
  80. package/plugin/pyproject.toml +49 -0
  81. package/plugin/scripts/_codex_env.sh +27 -0
  82. package/plugin/scripts/_lib.sh +325 -0
  83. package/plugin/scripts/backend-service.sh +208 -0
  84. package/plugin/scripts/cli.sh +40 -0
  85. package/plugin/scripts/dashboard-build.sh +139 -0
  86. package/plugin/scripts/dashboard-open.sh +107 -0
  87. package/plugin/scripts/dashboard-service.sh +195 -0
  88. package/plugin/scripts/ensure-plugin-root.sh +84 -0
  89. package/plugin/scripts/hook_entry.sh +70 -0
  90. package/plugin/scripts/smart-install.sh +411 -0
  91. package/plugin/src/claude_smart/__init__.py +3 -0
  92. package/plugin/src/claude_smart/cli.py +1273 -0
  93. package/plugin/src/claude_smart/context_format.py +277 -0
  94. package/plugin/src/claude_smart/context_inject.py +92 -0
  95. package/plugin/src/claude_smart/cs_cite.py +236 -0
  96. package/plugin/src/claude_smart/events/__init__.py +1 -0
  97. package/plugin/src/claude_smart/events/post_tool.py +148 -0
  98. package/plugin/src/claude_smart/events/pre_tool.py +52 -0
  99. package/plugin/src/claude_smart/events/session_end.py +20 -0
  100. package/plugin/src/claude_smart/events/session_start.py +119 -0
  101. package/plugin/src/claude_smart/events/stop.py +393 -0
  102. package/plugin/src/claude_smart/events/user_prompt.py +73 -0
  103. package/plugin/src/claude_smart/hook.py +114 -0
  104. package/plugin/src/claude_smart/ids.py +56 -0
  105. package/plugin/src/claude_smart/internal_call.py +89 -0
  106. package/plugin/src/claude_smart/optimizer_assistant.py +203 -0
  107. package/plugin/src/claude_smart/publish.py +71 -0
  108. package/plugin/src/claude_smart/query_compose.py +51 -0
  109. package/plugin/src/claude_smart/reflexio_adapter.py +403 -0
  110. package/plugin/src/claude_smart/runtime.py +52 -0
  111. package/plugin/src/claude_smart/stall_banner.py +61 -0
  112. package/plugin/src/claude_smart/state.py +276 -0
  113. package/plugin/uv.lock +3720 -0
@@ -0,0 +1,403 @@
1
+ """Thin wrapper over ``reflexio.ReflexioClient`` for claude-smart's read/write paths.
2
+
3
+ Exists so hook handlers (a) don't import reflexio directly at module scope
4
+ — import failures shouldn't crash hooks — and (b) can be stubbed in tests.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from dataclasses import dataclass
13
+ from typing import Any, Sequence
14
+
15
+ from claude_smart import runtime
16
+
17
+ _LOGGER = logging.getLogger(__name__)
18
+
19
+ _ENV_URL = "REFLEXIO_URL"
20
+ _DEFAULT_URL = "http://localhost:8071/"
21
+ _SEARCH_MODE_HYBRID = "hybrid" # reflexio.models.config_schema.SearchMode.HYBRID
22
+ _UNIFIED_ENTITY_TYPES = ("profiles", "user_playbooks", "agent_playbooks")
23
+ _AGENT_PLAYBOOK_APPROVAL_STATUSES = ("pending", "approved")
24
+ _REJECTED_AGENT_PLAYBOOK_STATUS = "rejected"
25
+
26
+
27
+ @dataclass
28
+ class Adapter:
29
+ """Wraps the reflexio client and absorbs connection errors.
30
+
31
+ All methods degrade to a neutral no-op return (empty list / False) on
32
+ connection failure so a missing or down reflexio server never crashes
33
+ a Claude Code hook.
34
+ """
35
+
36
+ url: str = ""
37
+
38
+ def __post_init__(self) -> None:
39
+ self.url = self.url or os.environ.get(_ENV_URL, _DEFAULT_URL)
40
+ self._client: Any | None = None
41
+
42
+ # -----------------------------------------------------------------
43
+ # Client lazy-initialization
44
+ # -----------------------------------------------------------------
45
+
46
+ def _get_client(self) -> Any | None:
47
+ """Return the ReflexioClient, or None if reflexio is unreachable/unimportable."""
48
+ if self._client is not None:
49
+ return self._client
50
+ try:
51
+ from reflexio import ReflexioClient # type: ignore[import-not-found]
52
+ except ImportError as exc:
53
+ _LOGGER.debug("reflexio not importable: %s", exc)
54
+ return None
55
+ try:
56
+ self._client = ReflexioClient(url_endpoint=self.url)
57
+ except Exception as exc: # noqa: BLE001 — adapter must never raise.
58
+ _LOGGER.warning("Failed to construct ReflexioClient: %s", exc)
59
+ return None
60
+ return self._client
61
+
62
+ # -----------------------------------------------------------------
63
+ # Writes
64
+ # -----------------------------------------------------------------
65
+
66
+ def publish(
67
+ self,
68
+ *,
69
+ session_id: str,
70
+ project_id: str,
71
+ interactions: Sequence[dict[str, Any]],
72
+ force_extraction: bool = False,
73
+ skip_aggregation: bool = False,
74
+ ) -> bool:
75
+ """Publish buffered interactions to reflexio. Returns True on success."""
76
+ if not interactions:
77
+ return True
78
+ client = self._get_client()
79
+ if client is None:
80
+ return False
81
+ try:
82
+ client.publish_interaction(
83
+ user_id=project_id,
84
+ interactions=list(interactions),
85
+ agent_version=runtime.agent_version(),
86
+ session_id=session_id,
87
+ wait_for_response=False,
88
+ force_extraction=force_extraction,
89
+ skip_aggregation=skip_aggregation,
90
+ )
91
+ return True
92
+ except Exception as exc: # noqa: BLE001
93
+ _LOGGER.warning("publish_interaction failed: %s", exc)
94
+ return False
95
+
96
+ def apply_extraction_defaults(self, *, window_size: int, stride_size: int) -> bool:
97
+ """Push claude-smart's preferred extraction defaults to the reflexio server.
98
+
99
+ Reads the current ``Config`` and only issues a ``set_config`` when the
100
+ server-side values differ, so steady state is a single cheap GET.
101
+
102
+ Reflexio persists ``Config`` to disk, so once these values land they
103
+ survive backend restarts. The flip side: if an operator customizes
104
+ ``window_size``/``stride_size`` via the dashboard, this call will
105
+ overwrite those values back to the claude-smart defaults on the next
106
+ SessionStart. To change the defaults, edit the constants at the call
107
+ site in ``events/session_start.py``.
108
+
109
+ Args:
110
+ window_size (int): Desired ``Config.window_size`` on the server.
111
+ stride_size (int): Desired ``Config.stride_size`` on the
112
+ server. Must be ``<= window_size`` (reflexio enforces this).
113
+
114
+ Returns:
115
+ bool: True if the server is already at the target values or the
116
+ write succeeded; False if reflexio is unreachable or the call
117
+ raised.
118
+ """
119
+ client = self._get_client()
120
+ if client is None:
121
+ return False
122
+ try:
123
+ config = client.get_config()
124
+ if (
125
+ getattr(config, "window_size", None) == window_size
126
+ and getattr(config, "stride_size", None) == stride_size
127
+ ):
128
+ return True
129
+ config.window_size = window_size
130
+ config.stride_size = stride_size
131
+ client.set_config(config)
132
+ return True
133
+ except Exception as exc: # noqa: BLE001 — adapter must never raise.
134
+ _LOGGER.warning("apply_extraction_defaults failed: %s", exc)
135
+ return False
136
+
137
+ def apply_optimizer_defaults(
138
+ self, *, script_path: str, timeout_seconds: int = 300
139
+ ) -> bool:
140
+ """Push claude-smart's shared skill optimizer defaults to reflexio.
141
+
142
+ Idempotent compare-then-write: reads ``Config``, only issues a
143
+ ``set_config`` when the server-side values differ from the desired
144
+ dict below. Called unconditionally from SessionStart; the caller's
145
+ only escape hatch is ``CLAUDE_SMART_ENABLE_OPTIMIZER=0``.
146
+ """
147
+ client = self._get_client()
148
+ if client is None:
149
+ return False
150
+ try:
151
+ config = client.get_config()
152
+ opt = getattr(config, "playbook_optimizer_config", None)
153
+ if opt is None:
154
+ return False
155
+
156
+ desired = {
157
+ "enabled": True,
158
+ "optimize_user_playbooks": False,
159
+ "optimize_agent_playbooks": True,
160
+ "auto_update_user_playbooks": True,
161
+ "min_commit_windows": 1,
162
+ "max_metric_calls": 15,
163
+ "assistant_script_path": script_path,
164
+ "assistant_script_args": [],
165
+ "webhook_url": None,
166
+ "webhook_timeout_seconds": timeout_seconds,
167
+ }
168
+ if all(getattr(opt, key, None) == value for key, value in desired.items()):
169
+ return True
170
+ for key, value in desired.items():
171
+ setattr(opt, key, value)
172
+ client.set_config(config)
173
+ return True
174
+ except Exception as exc: # noqa: BLE001 — adapter must never raise.
175
+ _LOGGER.warning("apply_optimizer_defaults failed: %s", exc)
176
+ return False
177
+
178
+ # -----------------------------------------------------------------
179
+ # Stall-state reads/writes (used by SessionStart banner)
180
+ # -----------------------------------------------------------------
181
+
182
+ def fetch_stall_state(self) -> Any | None:
183
+ """Fetch the current learning-stall snapshot from reflexio.
184
+
185
+ Returns:
186
+ Any | None: ``StallStateResponse``-shaped object (attribute access
187
+ for stalled/reason/etc), or None when the reflexio server is
188
+ unreachable. The caller must tolerate either case.
189
+ """
190
+ client = self._get_client()
191
+ if client is None:
192
+ return None
193
+ try:
194
+ return client.get_stall_state()
195
+ except Exception as exc: # noqa: BLE001
196
+ _LOGGER.debug("get_stall_state failed: %s", exc)
197
+ return None
198
+
199
+ def mark_stall_notified(self) -> None:
200
+ """Idempotently flip ``notified_in_cc`` on the active stall row.
201
+
202
+ Returns:
203
+ None
204
+ """
205
+ client = self._get_client()
206
+ if client is None:
207
+ return
208
+ try:
209
+ client.mark_stall_notified()
210
+ except Exception as exc: # noqa: BLE001
211
+ _LOGGER.debug("mark_stall_notified failed: %s", exc)
212
+
213
+ # -----------------------------------------------------------------
214
+ # Broad reads (used by /show)
215
+ # -----------------------------------------------------------------
216
+
217
+ def fetch_user_playbooks(self, *, project_id: str, top_k: int = 10) -> list[Any]:
218
+ """Fetch CURRENT user playbooks for ``project_id``.
219
+
220
+ User playbooks are project-scoped: filtering by ``user_id=project_id``
221
+ mirrors the publish path (``publish_interaction(user_id=project_id, …)``)
222
+ so each project only sees the playbooks it produced.
223
+
224
+ Args:
225
+ project_id (str): reflexio ``user_id`` for this repo.
226
+ top_k (int): Cap on results.
227
+
228
+ Returns:
229
+ list[Any]: User playbook records, possibly empty.
230
+ """
231
+ client = self._get_client()
232
+ if client is None:
233
+ return []
234
+ try:
235
+ response = client.search_user_playbooks(
236
+ user_id=project_id,
237
+ status_filter=[None], # None => CURRENT in reflexio's filter API
238
+ top_k=top_k,
239
+ )
240
+ except Exception as exc: # noqa: BLE001
241
+ _LOGGER.debug("search_user_playbooks failed: %s", exc)
242
+ return []
243
+ return _extract_items(response, "user_playbooks")
244
+
245
+ def fetch_agent_playbooks(self, top_k: int = 10) -> list[Any]:
246
+ """Fetch CURRENT agent playbooks globally (shared across projects).
247
+
248
+ Agent playbooks have no ``user_id`` field — they are aggregated from
249
+ user playbooks across every project so that distilled lessons travel
250
+ with the agent, not with a single repo. Filter by ``agent_version``
251
+ so we only pull in playbooks produced by claude-code sessions.
252
+
253
+ Args:
254
+ top_k (int): Cap on results.
255
+
256
+ Returns:
257
+ list[Any]: Agent playbook records, possibly empty.
258
+ """
259
+ client = self._get_client()
260
+ if client is None:
261
+ return []
262
+ try:
263
+ response = client.search_agent_playbooks(
264
+ agent_version=runtime.agent_version(),
265
+ status_filter=[None],
266
+ top_k=top_k,
267
+ )
268
+ except Exception as exc: # noqa: BLE001
269
+ _LOGGER.debug("search_agent_playbooks failed: %s", exc)
270
+ return []
271
+ return _filter_rejected_agent_playbooks(
272
+ _extract_items(response, "agent_playbooks")
273
+ )
274
+
275
+ def fetch_project_profiles(self, project_id: str, top_k: int = 20) -> list[Any]:
276
+ """Fetch preferences extracted for this project (across sessions)."""
277
+ client = self._get_client()
278
+ if client is None:
279
+ return []
280
+ try:
281
+ response = client.search_user_profiles(
282
+ user_id=project_id,
283
+ query="",
284
+ top_k=top_k,
285
+ )
286
+ except Exception as exc: # noqa: BLE001
287
+ _LOGGER.debug("search_user_profiles failed: %s", exc)
288
+ return []
289
+ return _extract_items(response, "user_profiles")
290
+
291
+ # -----------------------------------------------------------------
292
+ # Query-aware unified search (used by PreToolUse / UserPromptSubmit)
293
+ # -----------------------------------------------------------------
294
+
295
+ def search_all(
296
+ self, *, project_id: str, query: str, top_k: int = 5
297
+ ) -> tuple[list[Any], list[Any], list[Any]]:
298
+ """Unified hybrid search → ``(user_playbooks, agent_playbooks, preferences)``.
299
+
300
+ One round trip to ``/api/search`` fans out all three legs server-side.
301
+ Reflexio's unified ``user_id`` filter scopes ``user_playbooks`` and
302
+ preferences to this project; ``agent_playbooks`` carry no ``user_id``
303
+ column so the same filter silently no-ops on that leg, leaving them
304
+ global across projects.
305
+
306
+ Args:
307
+ project_id (str): reflexio ``user_id`` for this repo.
308
+ query (str): Free-text query routed through BM25 + vector RRF.
309
+ top_k (int): Cap on results per entity type.
310
+
311
+ Returns:
312
+ tuple[list[Any], list[Any], list[Any]]: ``(user_playbooks,
313
+ agent_playbooks, preferences)``. Returns three empty lists on
314
+ connection failure or any unified-search error so this
315
+ wrapper never raises.
316
+ """
317
+ client = self._get_client()
318
+ if client is None:
319
+ return [], [], []
320
+ try:
321
+ response = client.search(
322
+ query=query,
323
+ user_id=project_id,
324
+ agent_version=runtime.agent_version(),
325
+ entity_types=list(_UNIFIED_ENTITY_TYPES),
326
+ agent_playbook_status_filter=list(_AGENT_PLAYBOOK_APPROVAL_STATUSES),
327
+ enable_agent_answer=False,
328
+ top_k=top_k,
329
+ search_mode=_SEARCH_MODE_HYBRID,
330
+ )
331
+ except Exception as exc: # noqa: BLE001
332
+ _LOGGER.debug("unified search failed: %s", exc)
333
+ return [], [], []
334
+ return (
335
+ _extract_items(response, "user_playbooks"),
336
+ _filter_rejected_agent_playbooks(
337
+ _extract_items(response, "agent_playbooks")
338
+ ),
339
+ _extract_items(response, "profiles"),
340
+ )
341
+
342
+ # -----------------------------------------------------------------
343
+ # Broad fetch for explicit audit views (no query → can't use unified /api/search)
344
+ # -----------------------------------------------------------------
345
+
346
+ def fetch_all(
347
+ self,
348
+ *,
349
+ project_id: str,
350
+ user_playbook_top_k: int = 10,
351
+ agent_playbook_top_k: int = 10,
352
+ profile_top_k: int = 20,
353
+ ) -> tuple[list[Any], list[Any], list[Any]]:
354
+ """Parallel broad fetch for /show → ``(user_playbooks,
355
+ agent_playbooks, preferences)``.
356
+
357
+ Unified search rejects empty queries, so explicit audit views use
358
+ per-entity endpoints. User playbooks and preferences are scoped to
359
+ ``project_id``; agent playbooks are global (filtered only by
360
+ ``agent_version``).
361
+
362
+ Each leg absorbs its own exceptions and returns ``[]`` on failure,
363
+ so this wrapper never raises.
364
+ """
365
+ with ThreadPoolExecutor(max_workers=3) as pool:
366
+ up_future = pool.submit(
367
+ self.fetch_user_playbooks,
368
+ project_id=project_id,
369
+ top_k=user_playbook_top_k,
370
+ )
371
+ ap_future = pool.submit(self.fetch_agent_playbooks, agent_playbook_top_k)
372
+ pr_future = pool.submit(
373
+ self.fetch_project_profiles, project_id, profile_top_k
374
+ )
375
+ return up_future.result(), ap_future.result(), pr_future.result()
376
+
377
+
378
+ def _extract_items(response: Any, field: str) -> list[Any]:
379
+ """Pull a list field from a reflexio response object or dict, tolerating shape drift."""
380
+ if response is None:
381
+ return []
382
+ if isinstance(response, dict):
383
+ value = response.get(field)
384
+ else:
385
+ value = getattr(response, field, None)
386
+ return list(value) if value else []
387
+
388
+
389
+ def _filter_rejected_agent_playbooks(items: list[Any]) -> list[Any]:
390
+ """Drop rejected shared skills defensively, even if an older backend ignores filters."""
391
+ return [
392
+ item
393
+ for item in items
394
+ if _agent_playbook_status(item) != _REJECTED_AGENT_PLAYBOOK_STATUS
395
+ ]
396
+
397
+
398
+ def _agent_playbook_status(item: Any) -> str:
399
+ if isinstance(item, dict):
400
+ value = item.get("playbook_status")
401
+ else:
402
+ value = getattr(item, "playbook_status", None)
403
+ return str(value or "").lower()
@@ -0,0 +1,52 @@
1
+ """Host/runtime state shared by claude-smart entrypoints.
2
+
3
+ The plugin can be loaded by Claude Code or Codex, but v1 intentionally keeps
4
+ one memory namespace. The host value is for payload quirks and install UX; the
5
+ Reflexio agent version remains shared so both hosts see the same learned rules.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ HOST_ENV = "CLAUDE_SMART_HOST"
13
+ INTERNAL_ENV = "CLAUDE_SMART_INTERNAL"
14
+
15
+ HOST_CLAUDE_CODE = "claude-code"
16
+ HOST_CODEX = "codex"
17
+ VALID_HOSTS = frozenset({HOST_CLAUDE_CODE, HOST_CODEX})
18
+
19
+ _SHARED_AGENT_VERSION = "claude-code"
20
+ _current_host: str | None = None
21
+
22
+
23
+ def set_host(value: str | None) -> str:
24
+ """Set the current host, returning the normalized value."""
25
+ global _current_host
26
+ host = value if value in VALID_HOSTS else HOST_CLAUDE_CODE
27
+ _current_host = host
28
+ os.environ[HOST_ENV] = host
29
+ return host
30
+
31
+
32
+ def host() -> str:
33
+ """Return the current host, defaulting to Claude Code for compatibility."""
34
+ if _current_host is not None:
35
+ return _current_host
36
+ value = os.environ.get(HOST_ENV)
37
+ return value if value in VALID_HOSTS else HOST_CLAUDE_CODE
38
+
39
+
40
+ def is_codex() -> bool:
41
+ """True when the current hook invocation came from Codex."""
42
+ return host() == HOST_CODEX
43
+
44
+
45
+ def agent_version() -> str:
46
+ """Reflexio agent version used for shared learning across hosts."""
47
+ return _SHARED_AGENT_VERSION
48
+
49
+
50
+ def is_internal_invocation_env() -> bool:
51
+ """Generic recursion guard used by local assistant subprocesses."""
52
+ return os.environ.get(INTERNAL_ENV) == "1"
@@ -0,0 +1,61 @@
1
+ """Render the 1-line SessionStart banner for a credit/auth stall.
2
+
3
+ The template branches on the stall reason; output goes through Claude Code's
4
+ ``additionalContext`` so the model sees it once per stall event.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime
10
+ from typing import Literal
11
+
12
+ StallReason = Literal["billing_error", "auth_error"]
13
+
14
+ _DASHBOARD = "localhost:3001"
15
+
16
+
17
+ def render_banner(*, reason: str | None, reset_estimate: datetime | None) -> str:
18
+ """Format the SessionStart banner for the given stall reason.
19
+
20
+ Args:
21
+ reason (str | None): ``"billing_error"`` or ``"auth_error"``. Other values
22
+ (including ``None``) yield an empty string so callers can pass raw DB
23
+ values safely.
24
+ reset_estimate (datetime | None): Best-effort credit reset time;
25
+ included in the billing-error banner when present.
26
+
27
+ Returns:
28
+ str: A single-line banner, or ``""`` for unknown reasons.
29
+ """
30
+ match reason:
31
+ case "billing_error":
32
+ if reset_estimate is None:
33
+ return (
34
+ f"claude-smart: learning paused — Agent SDK credit "
35
+ f"exhausted. Details: {_DASHBOARD}"
36
+ )
37
+ return (
38
+ f"claude-smart: learning paused — Agent SDK credit "
39
+ f"exhausted (resets ~{_format_reset(reset_estimate)}). "
40
+ f"Details: {_DASHBOARD}"
41
+ )
42
+ case "auth_error":
43
+ return (
44
+ f"claude-smart: learning paused — please run /login. "
45
+ f"Details: {_DASHBOARD}"
46
+ )
47
+ case _:
48
+ return ""
49
+
50
+
51
+ def _format_reset(value: datetime) -> str:
52
+ """Format a reset datetime as e.g. ``Jun 12 9:00`` for the banner.
53
+
54
+ Args:
55
+ value (datetime): The reset time to format.
56
+
57
+ Returns:
58
+ str: The banner-friendly representation, with the hour shown
59
+ without a leading zero (e.g. ``Jun 12 9:00``).
60
+ """
61
+ return f"{value.strftime('%b %d')} {value.hour}:{value.minute:02d}"