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.
Files changed (64) hide show
  1. package/README.md +2 -2
  2. package/bin/claude-smart.js +172 -18
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/dashboard/app/api/config/route.ts +11 -2
  7. package/plugin/dashboard/app/api/health/route.ts +45 -6
  8. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +15 -7
  9. package/plugin/dashboard/app/api/rules/applied/route.ts +27 -0
  10. package/plugin/dashboard/app/configure/env/page.tsx +36 -31
  11. package/plugin/dashboard/app/configure/layout.tsx +1 -1
  12. package/plugin/dashboard/app/configure/server/page.tsx +8 -14
  13. package/plugin/dashboard/app/dashboard/page.tsx +311 -115
  14. package/plugin/dashboard/app/globals.css +80 -66
  15. package/plugin/dashboard/app/layout.tsx +13 -10
  16. package/plugin/dashboard/app/preferences/[id]/page.tsx +21 -32
  17. package/plugin/dashboard/app/preferences/page.tsx +154 -54
  18. package/plugin/dashboard/app/preferences/project/[id]/page.tsx +1 -0
  19. package/plugin/dashboard/app/rules/[id]/page.tsx +51 -0
  20. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +4 -4
  21. package/plugin/dashboard/app/sessions/page.tsx +14 -10
  22. package/plugin/dashboard/app/skills/page.tsx +175 -56
  23. package/plugin/dashboard/app/skills/project/[id]/page.tsx +22 -38
  24. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +20 -37
  25. package/plugin/dashboard/components/common/delete-learning-danger-zone.tsx +166 -0
  26. package/plugin/dashboard/components/common/empty-state.tsx +4 -2
  27. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  28. package/plugin/dashboard/components/common/page-header.tsx +5 -3
  29. package/plugin/dashboard/components/common/page-tabs.tsx +4 -4
  30. package/plugin/dashboard/components/common/stat-card.tsx +9 -5
  31. package/plugin/dashboard/components/layout/sidebar.tsx +24 -9
  32. package/plugin/dashboard/components/layout/top-bar.tsx +37 -25
  33. package/plugin/dashboard/components/ui/input.tsx +1 -0
  34. package/plugin/dashboard/hooks/use-settings.tsx +30 -61
  35. package/plugin/dashboard/hooks/use-stall-state.ts +5 -9
  36. package/plugin/dashboard/lib/config-file.ts +2 -0
  37. package/plugin/dashboard/lib/reflexio-client.ts +23 -48
  38. package/plugin/dashboard/lib/session-reader.ts +222 -6
  39. package/plugin/dashboard/lib/types.ts +20 -1
  40. package/plugin/dashboard/package-lock.json +70 -95
  41. package/plugin/dashboard/package.json +5 -2
  42. package/plugin/hooks/hooks.json +1 -1
  43. package/plugin/pyproject.toml +1 -1
  44. package/plugin/scripts/_lib.sh +126 -0
  45. package/plugin/scripts/backend-service.sh +32 -7
  46. package/plugin/scripts/cli.sh +4 -2
  47. package/plugin/scripts/codex-hook.js +100 -3
  48. package/plugin/scripts/dashboard-service.sh +98 -19
  49. package/plugin/scripts/hook_entry.sh +32 -11
  50. package/plugin/scripts/smart-install.sh +27 -44
  51. package/plugin/src/claude_smart/cli.py +204 -20
  52. package/plugin/src/claude_smart/context_format.py +244 -6
  53. package/plugin/src/claude_smart/context_inject.py +8 -1
  54. package/plugin/src/claude_smart/cs_cite.py +186 -34
  55. package/plugin/src/claude_smart/env_config.py +102 -0
  56. package/plugin/src/claude_smart/events/session_end.py +171 -6
  57. package/plugin/src/claude_smart/events/session_start.py +26 -2
  58. package/plugin/src/claude_smart/events/stop.py +48 -9
  59. package/plugin/src/claude_smart/hook.py +62 -4
  60. package/plugin/src/claude_smart/hook_log.py +301 -0
  61. package/plugin/src/claude_smart/internal_call.py +30 -0
  62. package/plugin/src/claude_smart/publish.py +5 -0
  63. package/plugin/src/claude_smart/reflexio_adapter.py +102 -26
  64. 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, Sequence
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
- self._client = ReflexioClient(url_endpoint=self.url)
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
- 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
- )
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
- 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
- )
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
- _LOGGER.debug("search_user_playbooks failed: %s", exc)
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
- response = client.search_agent_playbooks(
264
- agent_version=runtime.agent_version(),
265
- status_filter=[None],
266
- top_k=top_k,
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
- _LOGGER.debug("search_agent_playbooks failed: %s", exc)
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
- _LOGGER.debug("search_user_profiles failed: %s", exc)
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
- _LOGGER.debug("unified search failed: %s", exc)
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 [
package/plugin/uv.lock CHANGED
@@ -419,7 +419,7 @@ wheels = [
419
419
 
420
420
  [[package]]
421
421
  name = "claude-smart"
422
- version = "0.2.30"
422
+ version = "0.2.32"
423
423
  source = { editable = "." }
424
424
  dependencies = [
425
425
  { name = "chromadb" },