claude-smart 0.2.23 → 0.2.25
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/.agents/plugins/marketplace.json +20 -0
- package/README.md +76 -28
- package/bin/claude-smart.js +355 -11
- package/package.json +11 -1
- package/plugin/.claude-plugin/plugin.json +17 -0
- package/plugin/.codex-plugin/plugin.json +35 -0
- package/plugin/LICENSE +202 -0
- package/plugin/README.md +37 -0
- package/plugin/bin/cs-cite +77 -0
- package/plugin/commands/clear-all.md +8 -0
- package/plugin/commands/dashboard.md +8 -0
- package/plugin/commands/learn.md +12 -0
- package/plugin/commands/restart.md +8 -0
- package/plugin/commands/show.md +8 -0
- package/plugin/dashboard/AGENTS.md +6 -0
- package/plugin/dashboard/app/api/claude-settings/route.ts +19 -0
- package/plugin/dashboard/app/api/config/route.ts +16 -0
- package/plugin/dashboard/app/api/health/route.ts +10 -0
- package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +63 -0
- package/plugin/dashboard/app/api/sessions/[id]/route.ts +28 -0
- package/plugin/dashboard/app/api/sessions/route.ts +14 -0
- package/plugin/dashboard/app/configure/env/page.tsx +318 -0
- package/plugin/dashboard/app/configure/layout.tsx +47 -0
- package/plugin/dashboard/app/configure/page.tsx +5 -0
- package/plugin/dashboard/app/configure/server/page.tsx +258 -0
- package/plugin/dashboard/app/dashboard/page.tsx +227 -0
- package/plugin/dashboard/app/globals.css +129 -0
- package/plugin/dashboard/app/icon.png +0 -0
- package/plugin/dashboard/app/layout.tsx +40 -0
- package/plugin/dashboard/app/page.tsx +5 -0
- package/plugin/dashboard/app/preferences/[id]/page.tsx +531 -0
- package/plugin/dashboard/app/preferences/page.tsx +126 -0
- package/plugin/dashboard/app/providers.tsx +12 -0
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +321 -0
- package/plugin/dashboard/app/sessions/page.tsx +186 -0
- package/plugin/dashboard/app/skills/page.tsx +362 -0
- package/plugin/dashboard/app/skills/project/[id]/page.tsx +597 -0
- package/plugin/dashboard/app/skills/shared/[id]/page.tsx +830 -0
- package/plugin/dashboard/components/common/delete-all-button.tsx +45 -0
- package/plugin/dashboard/components/common/empty-state.tsx +34 -0
- package/plugin/dashboard/components/common/learnings-badge.tsx +34 -0
- package/plugin/dashboard/components/common/page-header.tsx +34 -0
- package/plugin/dashboard/components/common/page-tabs.tsx +115 -0
- package/plugin/dashboard/components/common/stat-card.tsx +38 -0
- package/plugin/dashboard/components/layout/nav-items.ts +22 -0
- package/plugin/dashboard/components/layout/sidebar.tsx +45 -0
- package/plugin/dashboard/components/layout/top-bar.tsx +64 -0
- package/plugin/dashboard/components/stall-banner.tsx +53 -0
- package/plugin/dashboard/components/ui/badge.tsx +52 -0
- package/plugin/dashboard/components/ui/button.tsx +60 -0
- package/plugin/dashboard/components/ui/collapsible.tsx +21 -0
- package/plugin/dashboard/components/ui/input.tsx +20 -0
- package/plugin/dashboard/components/ui/label.tsx +20 -0
- package/plugin/dashboard/components/ui/scroll-area.tsx +55 -0
- package/plugin/dashboard/components/ui/select.tsx +201 -0
- package/plugin/dashboard/components/ui/separator.tsx +25 -0
- package/plugin/dashboard/components/ui/sheet.tsx +135 -0
- package/plugin/dashboard/components/ui/switch.tsx +32 -0
- package/plugin/dashboard/components.json +25 -0
- package/plugin/dashboard/eslint.config.mjs +16 -0
- package/plugin/dashboard/hooks/use-settings.tsx +88 -0
- package/plugin/dashboard/hooks/use-stall-state.ts +59 -0
- package/plugin/dashboard/lib/claude-settings-file.ts +114 -0
- package/plugin/dashboard/lib/config-file.ts +131 -0
- package/plugin/dashboard/lib/format.ts +58 -0
- package/plugin/dashboard/lib/reflexio-client.ts +238 -0
- package/plugin/dashboard/lib/reflexio-url.ts +17 -0
- package/plugin/dashboard/lib/session-reader.ts +245 -0
- package/plugin/dashboard/lib/status.ts +24 -0
- package/plugin/dashboard/lib/types.ts +145 -0
- package/plugin/dashboard/lib/utils.ts +6 -0
- package/plugin/dashboard/next.config.ts +7 -0
- package/plugin/dashboard/package-lock.json +10275 -0
- package/plugin/dashboard/package.json +37 -0
- package/plugin/dashboard/postcss.config.mjs +7 -0
- package/plugin/dashboard/public/claude-smart-icon.png +0 -0
- package/plugin/dashboard/tsconfig.json +34 -0
- package/plugin/hooks/codex-hooks.json +67 -0
- package/plugin/hooks/hooks.json +111 -0
- package/plugin/pyproject.toml +49 -0
- package/plugin/scripts/_codex_env.sh +27 -0
- package/plugin/scripts/_lib.sh +325 -0
- package/plugin/scripts/backend-service.sh +208 -0
- package/plugin/scripts/cli.sh +40 -0
- package/plugin/scripts/dashboard-build.sh +139 -0
- package/plugin/scripts/dashboard-open.sh +107 -0
- package/plugin/scripts/dashboard-service.sh +195 -0
- package/plugin/scripts/ensure-plugin-root.sh +84 -0
- package/plugin/scripts/hook_entry.sh +70 -0
- package/plugin/scripts/smart-install.sh +411 -0
- package/plugin/src/claude_smart/__init__.py +3 -0
- package/plugin/src/claude_smart/cli.py +1342 -0
- package/plugin/src/claude_smart/context_format.py +277 -0
- package/plugin/src/claude_smart/context_inject.py +92 -0
- package/plugin/src/claude_smart/cs_cite.py +236 -0
- package/plugin/src/claude_smart/events/__init__.py +1 -0
- package/plugin/src/claude_smart/events/post_tool.py +148 -0
- package/plugin/src/claude_smart/events/pre_tool.py +52 -0
- package/plugin/src/claude_smart/events/session_end.py +20 -0
- package/plugin/src/claude_smart/events/session_start.py +119 -0
- package/plugin/src/claude_smart/events/stop.py +393 -0
- package/plugin/src/claude_smart/events/user_prompt.py +73 -0
- package/plugin/src/claude_smart/hook.py +114 -0
- package/plugin/src/claude_smart/ids.py +56 -0
- package/plugin/src/claude_smart/internal_call.py +89 -0
- package/plugin/src/claude_smart/optimizer_assistant.py +203 -0
- package/plugin/src/claude_smart/publish.py +71 -0
- package/plugin/src/claude_smart/query_compose.py +51 -0
- package/plugin/src/claude_smart/reflexio_adapter.py +403 -0
- package/plugin/src/claude_smart/runtime.py +52 -0
- package/plugin/src/claude_smart/stall_banner.py +61 -0
- package/plugin/src/claude_smart/state.py +276 -0
- 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}"
|