claude-smart 0.2.30 → 0.2.32
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/README.md +2 -2
- package/bin/claude-smart.js +172 -18
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/dashboard/app/api/config/route.ts +11 -2
- package/plugin/dashboard/app/api/health/route.ts +45 -6
- package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +15 -7
- package/plugin/dashboard/app/api/rules/applied/route.ts +27 -0
- package/plugin/dashboard/app/configure/env/page.tsx +36 -31
- package/plugin/dashboard/app/configure/layout.tsx +1 -1
- package/plugin/dashboard/app/configure/server/page.tsx +8 -14
- package/plugin/dashboard/app/dashboard/page.tsx +311 -115
- package/plugin/dashboard/app/globals.css +80 -66
- package/plugin/dashboard/app/layout.tsx +13 -10
- package/plugin/dashboard/app/preferences/[id]/page.tsx +21 -32
- package/plugin/dashboard/app/preferences/page.tsx +154 -54
- package/plugin/dashboard/app/preferences/project/[id]/page.tsx +1 -0
- package/plugin/dashboard/app/rules/[id]/page.tsx +51 -0
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +4 -4
- package/plugin/dashboard/app/sessions/page.tsx +14 -10
- package/plugin/dashboard/app/skills/page.tsx +175 -56
- package/plugin/dashboard/app/skills/project/[id]/page.tsx +22 -38
- package/plugin/dashboard/app/skills/shared/[id]/page.tsx +20 -37
- package/plugin/dashboard/components/common/delete-learning-danger-zone.tsx +166 -0
- package/plugin/dashboard/components/common/empty-state.tsx +4 -2
- package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
- package/plugin/dashboard/components/common/page-header.tsx +5 -3
- package/plugin/dashboard/components/common/page-tabs.tsx +4 -4
- package/plugin/dashboard/components/common/stat-card.tsx +9 -5
- package/plugin/dashboard/components/layout/sidebar.tsx +24 -9
- package/plugin/dashboard/components/layout/top-bar.tsx +37 -25
- package/plugin/dashboard/components/ui/input.tsx +1 -0
- package/plugin/dashboard/hooks/use-settings.tsx +30 -61
- package/plugin/dashboard/hooks/use-stall-state.ts +5 -9
- package/plugin/dashboard/lib/config-file.ts +2 -0
- package/plugin/dashboard/lib/reflexio-client.ts +23 -48
- package/plugin/dashboard/lib/session-reader.ts +222 -6
- package/plugin/dashboard/lib/types.ts +20 -1
- package/plugin/dashboard/package-lock.json +70 -95
- package/plugin/dashboard/package.json +5 -2
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/pyproject.toml +1 -1
- package/plugin/scripts/_lib.sh +126 -0
- package/plugin/scripts/backend-service.sh +32 -7
- package/plugin/scripts/cli.sh +4 -2
- package/plugin/scripts/codex-hook.js +100 -3
- package/plugin/scripts/dashboard-service.sh +98 -19
- package/plugin/scripts/hook_entry.sh +32 -11
- package/plugin/scripts/smart-install.sh +27 -44
- package/plugin/src/claude_smart/cli.py +204 -20
- package/plugin/src/claude_smart/context_format.py +244 -6
- package/plugin/src/claude_smart/context_inject.py +8 -1
- package/plugin/src/claude_smart/cs_cite.py +186 -34
- package/plugin/src/claude_smart/env_config.py +102 -0
- package/plugin/src/claude_smart/events/session_end.py +171 -6
- package/plugin/src/claude_smart/events/session_start.py +26 -2
- package/plugin/src/claude_smart/events/stop.py +48 -9
- package/plugin/src/claude_smart/hook.py +62 -4
- package/plugin/src/claude_smart/hook_log.py +301 -0
- package/plugin/src/claude_smart/internal_call.py +30 -0
- package/plugin/src/claude_smart/publish.py +5 -0
- package/plugin/src/claude_smart/reflexio_adapter.py +102 -26
- package/plugin/uv.lock +1 -1
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Structured, append-only log of every hook fire and its outcome.
|
|
2
|
+
|
|
3
|
+
Why a dedicated log rather than relying on the Python ``logging`` module:
|
|
4
|
+
hooks run as short-lived subprocesses with no preconfigured handler, so
|
|
5
|
+
``_LOGGER.warning(...)`` calls silently vanish to stderr that Claude Code
|
|
6
|
+
captures but typically does not surface. The dispatcher already wraps
|
|
7
|
+
handlers in a broad ``except`` (so a hook crash never breaks the user's
|
|
8
|
+
session) — without a forensic trail the silent-fail mode is invisible.
|
|
9
|
+
|
|
10
|
+
This module writes one JSON line per hook fire to
|
|
11
|
+
``~/.claude-smart/hook.log`` from ``hook.main`` after the handler returns.
|
|
12
|
+
The Stop and SessionEnd handlers additionally piggyback their publish
|
|
13
|
+
outcome into the record so the chain hook→adapter→backend is fully
|
|
14
|
+
observable from a single file.
|
|
15
|
+
|
|
16
|
+
Failure policy: every public function in this module swallows all
|
|
17
|
+
``OSError``/``Exception`` paths. Logging is best-effort; a stuck disk
|
|
18
|
+
or read-only ``$HOME`` must never abort an in-progress hook.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Iterator
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import IO, Any
|
|
31
|
+
|
|
32
|
+
# ``fcntl`` is POSIX-only. On Windows we skip the cross-process lock and
|
|
33
|
+
# accept the unlikely rotation race (claude-smart is primarily a POSIX
|
|
34
|
+
# tool for Claude Code's plugin runtime).
|
|
35
|
+
try:
|
|
36
|
+
import fcntl as _fcntl
|
|
37
|
+
except ImportError: # pragma: no cover — exercised on Windows only
|
|
38
|
+
_fcntl = None # type: ignore[assignment]
|
|
39
|
+
|
|
40
|
+
_LOGGER = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
# Anchored on $HOME so unit tests can monkeypatch ``_LOG_PATH`` to a
|
|
43
|
+
# tmp_path. The directory is created lazily on first write — keeping
|
|
44
|
+
# import side-effect-free matches the rest of the package.
|
|
45
|
+
_LOG_PATH = Path.home() / ".claude-smart" / "hook.log"
|
|
46
|
+
|
|
47
|
+
_TRUNCATE_LIMIT = 4096
|
|
48
|
+
|
|
49
|
+
# Hard cap on ``hook.log`` size. At ~400 bytes/line and ~50 lines per
|
|
50
|
+
# typical session, 5 MB ≈ 12k records ≈ a few hundred sessions of recent
|
|
51
|
+
# forensic history — enough to debug the last week, bounded so a power
|
|
52
|
+
# user's log can't grow unbounded.
|
|
53
|
+
_MAX_LOG_BYTES = 5 * 1024 * 1024
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def log_event(
|
|
57
|
+
*,
|
|
58
|
+
event: str,
|
|
59
|
+
host: str,
|
|
60
|
+
session_id: Any = None,
|
|
61
|
+
project_id: Any = None,
|
|
62
|
+
cwd: Any = None,
|
|
63
|
+
internal_skipped: bool = False,
|
|
64
|
+
handler_status: str = "ok",
|
|
65
|
+
publish_status: str | None = None,
|
|
66
|
+
publish_count: int | None = None,
|
|
67
|
+
extra: dict[str, Any] | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Append one JSON record describing a hook fire.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
event (str): Hook event name (``"stop"``, ``"user-prompt"``, …).
|
|
73
|
+
host (str): Host identifier (``"claude-code"`` / ``"codex"``).
|
|
74
|
+
session_id (Any): Claude Code session id from the payload, or
|
|
75
|
+
``None`` when the payload didn't carry one.
|
|
76
|
+
project_id (Any): Project-id resolved from ``cwd``. Optional.
|
|
77
|
+
cwd (Any): Working directory reported by the hook payload.
|
|
78
|
+
Optional — preserved verbatim so a future audit can correlate
|
|
79
|
+
fires against workspace directories.
|
|
80
|
+
internal_skipped (bool): True when ``is_internal_invocation``
|
|
81
|
+
short-circuited the dispatcher (reflexio-internal sub-claude
|
|
82
|
+
calls, or invocations from inside the reflexio submodule).
|
|
83
|
+
handler_status (str): ``"ok"`` for a clean return, ``"unknown_event"``
|
|
84
|
+
when no handler is registered, or ``"raised:<ExcClass>: <msg>"``
|
|
85
|
+
when the handler raised — formatted by ``hook.main``.
|
|
86
|
+
publish_status (str | None): Status returned by
|
|
87
|
+
``publish.publish_unpublished`` (``"ok"`` / ``"failed"`` /
|
|
88
|
+
``"nothing"``). Only emitted by Stop + SessionEnd.
|
|
89
|
+
publish_count (int | None): Interaction count from the same
|
|
90
|
+
tuple. ``None`` when the handler doesn't publish.
|
|
91
|
+
extra (dict[str, Any] | None): Free-form fields preserved as-is.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
None
|
|
95
|
+
"""
|
|
96
|
+
record: dict[str, Any] = {
|
|
97
|
+
"ts": int(time.time()),
|
|
98
|
+
"event": event,
|
|
99
|
+
"host": host,
|
|
100
|
+
"session_id": _truncate(session_id),
|
|
101
|
+
"project_id": _truncate(project_id),
|
|
102
|
+
"cwd": _truncate(cwd),
|
|
103
|
+
"internal_skipped": bool(internal_skipped),
|
|
104
|
+
"handler_status": _truncate(handler_status),
|
|
105
|
+
}
|
|
106
|
+
if publish_status is not None:
|
|
107
|
+
record["publish_status"] = publish_status
|
|
108
|
+
try:
|
|
109
|
+
record["publish_count"] = int(
|
|
110
|
+
publish_count if publish_count is not None else 0
|
|
111
|
+
)
|
|
112
|
+
except (TypeError, ValueError):
|
|
113
|
+
# Defensive: a hostile caller passing an unparseable publish_count
|
|
114
|
+
# must not abort the hook fire. Treat as zero, the record still
|
|
115
|
+
# writes with publish_status preserved.
|
|
116
|
+
record["publish_count"] = 0
|
|
117
|
+
if extra:
|
|
118
|
+
for key, value in extra.items():
|
|
119
|
+
if key in record:
|
|
120
|
+
continue
|
|
121
|
+
record[key] = _truncate(value)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
line = json.dumps(record, ensure_ascii=False, default=str) + "\n"
|
|
125
|
+
except (TypeError, ValueError) as exc:
|
|
126
|
+
_LOGGER.debug("hook_log: json encode failed: %s", exc)
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
path = log_path()
|
|
130
|
+
try:
|
|
131
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
except OSError as exc:
|
|
133
|
+
_LOGGER.debug("hook_log: mkdir for %s failed: %s", path.parent, exc)
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
# Hold an exclusive cross-process flock through the entire append +
|
|
137
|
+
# rotate critical section. The original rationale ("POSIX atomic
|
|
138
|
+
# append below PIPE_BUF") protects the *append* alone but not the
|
|
139
|
+
# ``read_bytes → write tmp → rename`` rotation sequence: a concurrent
|
|
140
|
+
# appender that lands between rotation's read and rename has its
|
|
141
|
+
# record silently dropped by the rename. The lock closes that gap.
|
|
142
|
+
#
|
|
143
|
+
# On Windows ``_fcntl`` is None and writes proceed unlocked — same
|
|
144
|
+
# behaviour as before. The original PIPE_BUF-based append atomicity
|
|
145
|
+
# is preserved by ``_TRUNCATE_LIMIT`` keeping each record below 4 KiB.
|
|
146
|
+
with _rotation_lock(path):
|
|
147
|
+
try:
|
|
148
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
149
|
+
fh.write(line)
|
|
150
|
+
except OSError as exc:
|
|
151
|
+
_LOGGER.debug("hook_log: write to %s failed: %s", path, exc)
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
# Rotate AFTER the append so the just-written record always survives,
|
|
155
|
+
# even on the call that crosses the cap. A single oversized record
|
|
156
|
+
# still gets through and is dropped by the *next* rotation when the
|
|
157
|
+
# file is read back.
|
|
158
|
+
_maybe_rotate(path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def log_path() -> Path:
|
|
162
|
+
"""Return the active log path, honouring ``CLAUDE_SMART_HOOK_LOG``.
|
|
163
|
+
|
|
164
|
+
Resolution order:
|
|
165
|
+
|
|
166
|
+
1. ``CLAUDE_SMART_HOOK_LOG`` env var — escape hatch for tests and
|
|
167
|
+
advanced users who want the log elsewhere.
|
|
168
|
+
2. ``_LOG_PATH`` module attribute — what monkeypatched tests override.
|
|
169
|
+
3. ``~/.claude-smart/hook.log`` — the default.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Path: The absolute path to write to.
|
|
173
|
+
"""
|
|
174
|
+
override = os.environ.get("CLAUDE_SMART_HOOK_LOG")
|
|
175
|
+
if override:
|
|
176
|
+
return Path(override)
|
|
177
|
+
return _LOG_PATH
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _truncate(value: Any) -> Any:
|
|
181
|
+
"""Cap string values to ``_TRUNCATE_LIMIT`` chars so single-line writes
|
|
182
|
+
stay below the POSIX atomic-append limit.
|
|
183
|
+
|
|
184
|
+
Non-string values pass through unchanged; the json encoder handles
|
|
185
|
+
them. ``None`` becomes ``null`` in the JSON, which is what we want.
|
|
186
|
+
"""
|
|
187
|
+
if isinstance(value, str) and len(value) > _TRUNCATE_LIMIT:
|
|
188
|
+
return value[:_TRUNCATE_LIMIT] + "…"
|
|
189
|
+
return value
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@contextlib.contextmanager
|
|
193
|
+
def _rotation_lock(path: Path) -> Iterator[None]:
|
|
194
|
+
"""Hold an exclusive cross-process flock on a sibling ``.lock`` file.
|
|
195
|
+
|
|
196
|
+
Serialises every ``log_event`` call against itself so the rotation
|
|
197
|
+
``read_bytes → write tmp → rename`` sequence cannot interleave with a
|
|
198
|
+
concurrent append. On Windows or when the lock cannot be acquired
|
|
199
|
+
(no ``fcntl``, read-only parent, etc.) the lock degrades to a no-op
|
|
200
|
+
and the caller proceeds unlocked — matches the pre-existing
|
|
201
|
+
best-effort policy of this module.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
path (Path): The log file path; the lock file is a sibling at
|
|
205
|
+
``<path>.lock``.
|
|
206
|
+
|
|
207
|
+
Yields:
|
|
208
|
+
None.
|
|
209
|
+
"""
|
|
210
|
+
if _fcntl is None:
|
|
211
|
+
yield
|
|
212
|
+
return
|
|
213
|
+
lock_path = path.with_name(path.name + ".lock")
|
|
214
|
+
fh: IO[bytes] | None = None
|
|
215
|
+
try:
|
|
216
|
+
fh = lock_path.open("ab")
|
|
217
|
+
except OSError as exc:
|
|
218
|
+
_LOGGER.debug("hook_log: lock open %s failed: %s", lock_path, exc)
|
|
219
|
+
yield
|
|
220
|
+
return
|
|
221
|
+
try:
|
|
222
|
+
_fcntl.flock(fh.fileno(), _fcntl.LOCK_EX)
|
|
223
|
+
except OSError as exc:
|
|
224
|
+
_LOGGER.debug("hook_log: flock acquire failed: %s", exc)
|
|
225
|
+
fh.close()
|
|
226
|
+
yield
|
|
227
|
+
return
|
|
228
|
+
try:
|
|
229
|
+
yield
|
|
230
|
+
finally:
|
|
231
|
+
with contextlib.suppress(OSError):
|
|
232
|
+
_fcntl.flock(fh.fileno(), _fcntl.LOCK_UN)
|
|
233
|
+
with contextlib.suppress(OSError):
|
|
234
|
+
fh.close()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _maybe_rotate(path: Path) -> None:
|
|
238
|
+
"""Cap ``path`` at ``_MAX_LOG_BYTES`` by dropping the oldest half in place.
|
|
239
|
+
|
|
240
|
+
The fast path is a single ``stat`` syscall — when the file is under
|
|
241
|
+
the cap (the overwhelmingly common case on every fire) we do nothing.
|
|
242
|
+
When over the cap we read the file, find the first newline at-or-after
|
|
243
|
+
the midpoint byte, and atomically replace the original with the bytes
|
|
244
|
+
*after* that newline. Splitting on a newline boundary preserves the
|
|
245
|
+
JSON Lines invariant: every retained line is a complete record.
|
|
246
|
+
|
|
247
|
+
We picked this over ``logging.handlers.RotatingFileHandler`` because:
|
|
248
|
+
|
|
249
|
+
1. ``hook_log`` deliberately avoids the ``logging`` framework — see
|
|
250
|
+
module docstring. Adding a handler reintroduces the configuration
|
|
251
|
+
fragility we were dodging.
|
|
252
|
+
2. ``RotatingFileHandler`` produces ``hook.log.1``, ``hook.log.2`` …
|
|
253
|
+
sidecar files; our forensic story is "one file, tail it". Users
|
|
254
|
+
have already wired up tooling around that path.
|
|
255
|
+
3. In-place truncate-to-second-half is O(n) on the rare rotation
|
|
256
|
+
event and zero-cost on every other write. Backup file management
|
|
257
|
+
would add steady-state inode pressure for no benefit.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
path (Path): The log file to inspect. Need not exist — the
|
|
261
|
+
``FileNotFoundError`` branch of the ``stat`` call returns
|
|
262
|
+
cleanly via the ``OSError`` guard.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
None
|
|
266
|
+
"""
|
|
267
|
+
try:
|
|
268
|
+
size = path.stat().st_size
|
|
269
|
+
except OSError as exc:
|
|
270
|
+
_LOGGER.debug("hook_log: stat for rotation failed: %s", exc)
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
if size < _MAX_LOG_BYTES:
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
data = path.read_bytes()
|
|
278
|
+
except OSError as exc:
|
|
279
|
+
_LOGGER.debug("hook_log: read for rotation failed: %s", exc)
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
midpoint = len(data) // 2
|
|
283
|
+
cut = data.find(b"\n", midpoint)
|
|
284
|
+
# ``find`` returns -1 when the second half has no newline at all,
|
|
285
|
+
# meaning the file is one giant record with no framing left to
|
|
286
|
+
# preserve. Drop the lot rather than split a record mid-byte.
|
|
287
|
+
tail = b"" if cut == -1 else data[cut + 1 :]
|
|
288
|
+
|
|
289
|
+
# Atomic replace: write to a sibling temp file then rename. This
|
|
290
|
+
# avoids the truncation window where a concurrent reader would see
|
|
291
|
+
# an empty file, and matches the contract that ``hook.log`` always
|
|
292
|
+
# contains complete JSONL framing.
|
|
293
|
+
tmp_path = path.with_name(path.name + ".rot")
|
|
294
|
+
try:
|
|
295
|
+
tmp_path.write_bytes(tail)
|
|
296
|
+
tmp_path.replace(path)
|
|
297
|
+
except OSError as exc:
|
|
298
|
+
_LOGGER.debug("hook_log: rotation rename failed: %s", exc)
|
|
299
|
+
# Best-effort cleanup of the temp file; ignore failures.
|
|
300
|
+
with contextlib.suppress(OSError):
|
|
301
|
+
tmp_path.unlink(missing_ok=True)
|
|
@@ -32,6 +32,8 @@ Detection signals, OR'd:
|
|
|
32
32
|
- Known Codex-internal prompt templates (title generation and home-screen
|
|
33
33
|
suggestions). These are model calls made by Codex itself, not user
|
|
34
34
|
coding turns, and must never be reflected into claude-smart memory.
|
|
35
|
+
- Known orphan Codex-internal response bodies, for cases where the Stop
|
|
36
|
+
payload contains only the generated metadata and not the internal prompt.
|
|
35
37
|
"""
|
|
36
38
|
|
|
37
39
|
from __future__ import annotations
|
|
@@ -140,3 +142,31 @@ def is_codex_title_response(content: Any) -> bool:
|
|
|
140
142
|
and isinstance(parsed.get("title"), str)
|
|
141
143
|
and bool(parsed["title"].strip())
|
|
142
144
|
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_codex_suggestions_response(content: Any) -> bool:
|
|
148
|
+
"""True for Codex's home-screen suggestion-generator response body.
|
|
149
|
+
|
|
150
|
+
Codex can run a separate suggestion task whose Stop payload contains
|
|
151
|
+
generated JSON like ``{"suggestions": [...]}`` with no corresponding user
|
|
152
|
+
turn. Those suggestions are UI metadata, not a user interaction.
|
|
153
|
+
"""
|
|
154
|
+
if not isinstance(content, str):
|
|
155
|
+
return False
|
|
156
|
+
try:
|
|
157
|
+
parsed = json.loads(content)
|
|
158
|
+
except json.JSONDecodeError:
|
|
159
|
+
return False
|
|
160
|
+
if not isinstance(parsed, dict) or set(parsed) != {"suggestions"}:
|
|
161
|
+
return False
|
|
162
|
+
suggestions = parsed.get("suggestions")
|
|
163
|
+
if not isinstance(suggestions, list):
|
|
164
|
+
return False
|
|
165
|
+
for item in suggestions:
|
|
166
|
+
if not isinstance(item, dict):
|
|
167
|
+
return False
|
|
168
|
+
if set(item) != {"title", "description", "prompt", "appId"}:
|
|
169
|
+
return False
|
|
170
|
+
if not all(isinstance(item.get(key), str) for key in item):
|
|
171
|
+
return False
|
|
172
|
+
return True
|
|
@@ -22,6 +22,7 @@ def publish_unpublished(
|
|
|
22
22
|
project_id: str,
|
|
23
23
|
force_extraction: bool,
|
|
24
24
|
skip_aggregation: bool,
|
|
25
|
+
override_learning_stall: bool = False,
|
|
25
26
|
adapter: Adapter | None = None,
|
|
26
27
|
) -> tuple[PublishStatus, int]:
|
|
27
28
|
"""Drain the session buffer to reflexio and stamp the high-water mark.
|
|
@@ -35,6 +36,9 @@ def publish_unpublished(
|
|
|
35
36
|
globally per agent rather than per project.
|
|
36
37
|
force_extraction (bool): Whether to ask reflexio to run extraction
|
|
37
38
|
synchronously instead of queuing for the next sweep.
|
|
39
|
+
override_learning_stall (bool): Whether to bypass a recorded
|
|
40
|
+
provider auth/billing stall. Automatic hooks must leave this
|
|
41
|
+
False; explicit manual retries set it True.
|
|
38
42
|
skip_aggregation (bool): When True, reflexio extracts preferences and
|
|
39
43
|
raw project-specific skill entries but skips the rollup into
|
|
40
44
|
shared skills. claude-smart passes False on every publish
|
|
@@ -63,6 +67,7 @@ def publish_unpublished(
|
|
|
63
67
|
project_id=project_id,
|
|
64
68
|
interactions=interactions,
|
|
65
69
|
force_extraction=force_extraction,
|
|
70
|
+
override_learning_stall=override_learning_stall,
|
|
66
71
|
skip_aggregation=skip_aggregation,
|
|
67
72
|
)
|
|
68
73
|
if ok:
|
|
@@ -8,16 +8,26 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import logging
|
|
10
10
|
import os
|
|
11
|
+
import inspect
|
|
12
|
+
from collections.abc import Sequence
|
|
11
13
|
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
-
from dataclasses import dataclass
|
|
13
|
-
from typing import Any
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
14
16
|
|
|
17
|
+
from claude_smart import env_config
|
|
15
18
|
from claude_smart import runtime
|
|
16
19
|
|
|
17
20
|
_LOGGER = logging.getLogger(__name__)
|
|
18
21
|
|
|
19
22
|
_ENV_URL = "REFLEXIO_URL"
|
|
23
|
+
_ENV_API_KEY = "REFLEXIO_API_KEY"
|
|
20
24
|
_DEFAULT_URL = "http://localhost:8071/"
|
|
25
|
+
# Cap every HTTP round-trip from a hook so a hung backend can't stall the
|
|
26
|
+
# Claude Code / Codex session. reflexio's client default is 300s, which is
|
|
27
|
+
# fine for batch workloads but unacceptable on the hook path — a single
|
|
28
|
+
# unhealthy POST would freeze the user's prompt. 5s matches the precedent
|
|
29
|
+
# in ``ids.py`` for short-lived hook HTTP calls.
|
|
30
|
+
_HTTP_TIMEOUT_SECONDS = 5
|
|
21
31
|
_SEARCH_MODE_HYBRID = "hybrid" # reflexio.models.config_schema.SearchMode.HYBRID
|
|
22
32
|
_UNIFIED_ENTITY_TYPES = ("profiles", "user_playbooks", "agent_playbooks")
|
|
23
33
|
_AGENT_PLAYBOOK_APPROVAL_STATUSES = ("pending", "approved")
|
|
@@ -34,9 +44,13 @@ class Adapter:
|
|
|
34
44
|
"""
|
|
35
45
|
|
|
36
46
|
url: str = ""
|
|
47
|
+
api_key: str = ""
|
|
48
|
+
read_errors: list[str] = field(default_factory=list, init=False)
|
|
37
49
|
|
|
38
50
|
def __post_init__(self) -> None:
|
|
51
|
+
env_config.load_reflexio_env()
|
|
39
52
|
self.url = self.url or os.environ.get(_ENV_URL, _DEFAULT_URL)
|
|
53
|
+
self.api_key = self.api_key or os.environ.get(_ENV_API_KEY, "")
|
|
40
54
|
self._client: Any | None = None
|
|
41
55
|
|
|
42
56
|
# -----------------------------------------------------------------
|
|
@@ -53,7 +67,18 @@ class Adapter:
|
|
|
53
67
|
_LOGGER.debug("reflexio not importable: %s", exc)
|
|
54
68
|
return None
|
|
55
69
|
try:
|
|
56
|
-
|
|
70
|
+
try:
|
|
71
|
+
self._client = ReflexioClient(
|
|
72
|
+
url_endpoint=self.url,
|
|
73
|
+
api_key=self.api_key,
|
|
74
|
+
timeout=_HTTP_TIMEOUT_SECONDS,
|
|
75
|
+
)
|
|
76
|
+
except TypeError:
|
|
77
|
+
# Pre-0.2.x reflexio releases didn't accept ``timeout``;
|
|
78
|
+
# fall back so hooks still work against older pinned clients.
|
|
79
|
+
self._client = ReflexioClient(
|
|
80
|
+
url_endpoint=self.url, api_key=self.api_key
|
|
81
|
+
)
|
|
57
82
|
except Exception as exc: # noqa: BLE001 — adapter must never raise.
|
|
58
83
|
_LOGGER.warning("Failed to construct ReflexioClient: %s", exc)
|
|
59
84
|
return None
|
|
@@ -70,6 +95,7 @@ class Adapter:
|
|
|
70
95
|
project_id: str,
|
|
71
96
|
interactions: Sequence[dict[str, Any]],
|
|
72
97
|
force_extraction: bool = False,
|
|
98
|
+
override_learning_stall: bool = False,
|
|
73
99
|
skip_aggregation: bool = False,
|
|
74
100
|
) -> bool:
|
|
75
101
|
"""Publish buffered interactions to reflexio. Returns True on success."""
|
|
@@ -79,15 +105,22 @@ class Adapter:
|
|
|
79
105
|
if client is None:
|
|
80
106
|
return False
|
|
81
107
|
try:
|
|
82
|
-
|
|
83
|
-
user_id
|
|
84
|
-
interactions
|
|
85
|
-
agent_version
|
|
86
|
-
session_id
|
|
87
|
-
wait_for_response
|
|
88
|
-
force_extraction
|
|
89
|
-
skip_aggregation
|
|
90
|
-
|
|
108
|
+
kwargs = {
|
|
109
|
+
"user_id": project_id,
|
|
110
|
+
"interactions": list(interactions),
|
|
111
|
+
"agent_version": runtime.agent_version(),
|
|
112
|
+
"session_id": session_id,
|
|
113
|
+
"wait_for_response": False,
|
|
114
|
+
"force_extraction": force_extraction,
|
|
115
|
+
"skip_aggregation": skip_aggregation,
|
|
116
|
+
}
|
|
117
|
+
if _supports_keyword(client.publish_interaction, "override_learning_stall"):
|
|
118
|
+
kwargs["override_learning_stall"] = override_learning_stall
|
|
119
|
+
elif override_learning_stall:
|
|
120
|
+
_LOGGER.debug(
|
|
121
|
+
"publish_interaction client does not support override_learning_stall"
|
|
122
|
+
)
|
|
123
|
+
client.publish_interaction(**kwargs)
|
|
91
124
|
return True
|
|
92
125
|
except Exception as exc: # noqa: BLE001
|
|
93
126
|
_LOGGER.warning("publish_interaction failed: %s", exc)
|
|
@@ -232,13 +265,20 @@ class Adapter:
|
|
|
232
265
|
if client is None:
|
|
233
266
|
return []
|
|
234
267
|
try:
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
268
|
+
if hasattr(client, "get_user_playbooks"):
|
|
269
|
+
response = client.get_user_playbooks(
|
|
270
|
+
user_id=project_id,
|
|
271
|
+
status_filter=[None], # None => CURRENT in reflexio's filter API
|
|
272
|
+
limit=top_k,
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
response = client.search_user_playbooks(
|
|
276
|
+
user_id=project_id,
|
|
277
|
+
status_filter=[None],
|
|
278
|
+
top_k=top_k,
|
|
279
|
+
)
|
|
240
280
|
except Exception as exc: # noqa: BLE001
|
|
241
|
-
|
|
281
|
+
self._record_read_error("fetch_user_playbooks", exc)
|
|
242
282
|
return []
|
|
243
283
|
return _extract_items(response, "user_playbooks")
|
|
244
284
|
|
|
@@ -260,13 +300,20 @@ class Adapter:
|
|
|
260
300
|
if client is None:
|
|
261
301
|
return []
|
|
262
302
|
try:
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
303
|
+
if hasattr(client, "get_agent_playbooks"):
|
|
304
|
+
response = client.get_agent_playbooks(
|
|
305
|
+
agent_version=runtime.agent_version(),
|
|
306
|
+
status_filter=[None],
|
|
307
|
+
limit=top_k,
|
|
308
|
+
)
|
|
309
|
+
else:
|
|
310
|
+
response = client.search_agent_playbooks(
|
|
311
|
+
agent_version=runtime.agent_version(),
|
|
312
|
+
status_filter=[None],
|
|
313
|
+
top_k=top_k,
|
|
314
|
+
)
|
|
268
315
|
except Exception as exc: # noqa: BLE001
|
|
269
|
-
|
|
316
|
+
self._record_read_error("fetch_agent_playbooks", exc)
|
|
270
317
|
return []
|
|
271
318
|
return _filter_rejected_agent_playbooks(
|
|
272
319
|
_extract_items(response, "agent_playbooks")
|
|
@@ -278,13 +325,20 @@ class Adapter:
|
|
|
278
325
|
if client is None:
|
|
279
326
|
return []
|
|
280
327
|
try:
|
|
328
|
+
if hasattr(client, "get_all_profiles"):
|
|
329
|
+
response = client.get_all_profiles(limit=max(top_k * 20, 100))
|
|
330
|
+
return [
|
|
331
|
+
item
|
|
332
|
+
for item in _extract_items(response, "user_profiles")
|
|
333
|
+
if _field(item, "user_id") == project_id
|
|
334
|
+
][:top_k]
|
|
281
335
|
response = client.search_user_profiles(
|
|
282
336
|
user_id=project_id,
|
|
283
337
|
query="",
|
|
284
338
|
top_k=top_k,
|
|
285
339
|
)
|
|
286
340
|
except Exception as exc: # noqa: BLE001
|
|
287
|
-
|
|
341
|
+
self._record_read_error("fetch_project_profiles", exc)
|
|
288
342
|
return []
|
|
289
343
|
return _extract_items(response, "user_profiles")
|
|
290
344
|
|
|
@@ -329,7 +383,7 @@ class Adapter:
|
|
|
329
383
|
search_mode=_SEARCH_MODE_HYBRID,
|
|
330
384
|
)
|
|
331
385
|
except Exception as exc: # noqa: BLE001
|
|
332
|
-
|
|
386
|
+
self._record_read_error("unified search", exc)
|
|
333
387
|
return [], [], []
|
|
334
388
|
return (
|
|
335
389
|
_extract_items(response, "user_playbooks"),
|
|
@@ -374,6 +428,11 @@ class Adapter:
|
|
|
374
428
|
)
|
|
375
429
|
return up_future.result(), ap_future.result(), pr_future.result()
|
|
376
430
|
|
|
431
|
+
def _record_read_error(self, operation: str, exc: Exception) -> None:
|
|
432
|
+
message = f"{operation}: {exc}"
|
|
433
|
+
self.read_errors.append(message)
|
|
434
|
+
_LOGGER.debug("%s failed: %s", operation, exc)
|
|
435
|
+
|
|
377
436
|
|
|
378
437
|
def _extract_items(response: Any, field: str) -> list[Any]:
|
|
379
438
|
"""Pull a list field from a reflexio response object or dict, tolerating shape drift."""
|
|
@@ -386,6 +445,23 @@ def _extract_items(response: Any, field: str) -> list[Any]:
|
|
|
386
445
|
return list(value) if value else []
|
|
387
446
|
|
|
388
447
|
|
|
448
|
+
def _field(item: Any, field: str) -> Any:
|
|
449
|
+
if isinstance(item, dict):
|
|
450
|
+
return item.get(field)
|
|
451
|
+
return getattr(item, field, None)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _supports_keyword(callable_obj: Any, keyword: str) -> bool:
|
|
455
|
+
try:
|
|
456
|
+
signature = inspect.signature(callable_obj)
|
|
457
|
+
except (TypeError, ValueError):
|
|
458
|
+
return False
|
|
459
|
+
for parameter in signature.parameters.values():
|
|
460
|
+
if parameter.kind == inspect.Parameter.VAR_KEYWORD:
|
|
461
|
+
return True
|
|
462
|
+
return keyword in signature.parameters
|
|
463
|
+
|
|
464
|
+
|
|
389
465
|
def _filter_rejected_agent_playbooks(items: list[Any]) -> list[Any]:
|
|
390
466
|
"""Drop rejected shared skills defensively, even if an older backend ignores filters."""
|
|
391
467
|
return [
|