claude-smart 0.2.30 → 0.2.31

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 CHANGED
@@ -13,7 +13,7 @@
13
13
  <img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="License">
14
14
  </a>
15
15
  <a href="plugin/pyproject.toml">
16
- <img src="https://img.shields.io/badge/version-0.2.30-green.svg" alt="Version">
16
+ <img src="https://img.shields.io/badge/version-0.2.31-green.svg" alt="Version">
17
17
  </a>
18
18
  <a href="plugin/pyproject.toml">
19
19
  <img src="https://img.shields.io/badge/python-%3E%3D3.12-brightgreen.svg" alt="Python">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-smart",
3
- "version": "0.2.30",
3
+ "version": "0.2.31",
4
4
  "description": "Self-improving Claude Code plugin — learns from corrections via reflexio",
5
5
  "keywords": [
6
6
  "claude",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-smart",
3
- "version": "0.2.30",
3
+ "version": "0.2.31",
4
4
  "description": "Self-improving Claude Code plugin — learns from corrections across sessions via reflexio",
5
5
  "author": {
6
6
  "name": "Yi Lu"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-smart",
3
- "version": "0.2.30",
3
+ "version": "0.2.31",
4
4
  "description": "Self-improving coding assistant plugin — learns from corrections across sessions via reflexio",
5
5
  "author": {
6
6
  "name": "Yi Lu"
@@ -102,7 +102,7 @@
102
102
  {
103
103
  "type": "command",
104
104
  "command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/reflexioai/plugin\"; bash \"$_R/scripts/hook_entry.sh\" claude-code session-end",
105
- "timeout": 60
105
+ "timeout": 300
106
106
  },
107
107
  {
108
108
  "type": "command",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "claude-smart"
3
- version = "0.2.30"
3
+ version = "0.2.31"
4
4
  description = "Self-improving Claude Code plugin — learns from corrections via reflexio"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -32,6 +32,16 @@ claude_smart_prepend_node_bins() {
32
32
  export PATH="$_CS_NODE_ROOT/bin:$_CS_NODE_ROOT:$PATH"
33
33
  }
34
34
 
35
+ claude_smart_is_internal_invocation_env() {
36
+ if [ "${CLAUDE_SMART_INTERNAL:-}" = "1" ]; then
37
+ return 0
38
+ fi
39
+ if [ -n "${CLAUDE_CODE_ENTRYPOINT:-}" ] && [ "${CLAUDE_CODE_ENTRYPOINT:-}" != "cli" ]; then
40
+ return 0
41
+ fi
42
+ return 1
43
+ }
44
+
35
45
  claude_smart_dashboard_unavailable_marker() {
36
46
  printf '%s\n' "$HOME/.claude-smart/dashboard-unavailable"
37
47
  }
@@ -196,6 +206,61 @@ PY
196
206
  return 1
197
207
  }
198
208
 
209
+ # Print a short fingerprint line for a file ("<cksum>:<size>" or "missing").
210
+ claude_smart_fingerprint_file() {
211
+ local path
212
+ path="$1"
213
+ if [ -f "$path" ]; then
214
+ cksum "$path" 2>/dev/null | awk '{print $1 ":" $2}'
215
+ else
216
+ printf 'missing\n'
217
+ fi
218
+ }
219
+
220
+ # Print a multi-line fingerprint covering every input that determines
221
+ # whether smart-install.sh can succeed. Order is stable so the hash is
222
+ # reproducible. Args: $1 = plugin_root, $2 = scripts_dir.
223
+ claude_smart_install_fingerprint() {
224
+ local plugin_root script_dir
225
+ plugin_root="$1"
226
+ script_dir="$2"
227
+ printf 'plugin_root=%s\n' "$plugin_root"
228
+ printf 'smart_install=%s\n' "$(claude_smart_fingerprint_file "$script_dir/smart-install.sh")"
229
+ printf 'lib=%s\n' "$(claude_smart_fingerprint_file "$script_dir/_lib.sh")"
230
+ printf 'pyproject=%s\n' "$(claude_smart_fingerprint_file "$plugin_root/pyproject.toml")"
231
+ printf 'uv_lock=%s\n' "$(claude_smart_fingerprint_file "$plugin_root/uv.lock")"
232
+ # Resolved python interpreter — catches a system upgrade (3.12.4 → 3.12.5)
233
+ # that would otherwise let install_complete return true against a venv
234
+ # built against a now-deleted interpreter.
235
+ if command -v uv >/dev/null 2>&1; then
236
+ printf 'python=%s\n' "$(uv python find 3.12 2>/dev/null || echo missing)"
237
+ else
238
+ printf 'python=no-uv\n'
239
+ fi
240
+ if [ -d "$plugin_root/dashboard" ]; then
241
+ printf 'dashboard_pkg=%s\n' "$(claude_smart_fingerprint_file "$plugin_root/dashboard/package.json")"
242
+ printf 'dashboard_lock=%s\n' "$(claude_smart_fingerprint_file "$plugin_root/dashboard/package-lock.json")"
243
+ else
244
+ printf 'dashboard_pkg=none\n'
245
+ printf 'dashboard_lock=none\n'
246
+ fi
247
+ }
248
+
249
+ # Hash the install fingerprint to a single short hex string. Used by the
250
+ # install-failed marker so hook_entry.sh can detect when inputs change and
251
+ # auto-clear a stale marker. Args: $1 = plugin_root, $2 = scripts_dir.
252
+ claude_smart_install_fingerprint_hash() {
253
+ local plugin_root script_dir tmp rc
254
+ plugin_root="$1"
255
+ script_dir="$2"
256
+ tmp=$(mktemp 2>/dev/null) || return 1
257
+ claude_smart_install_fingerprint "$plugin_root" "$script_dir" > "$tmp"
258
+ claude_smart_sha256_file "$tmp"
259
+ rc=$?
260
+ rm -f "$tmp"
261
+ return $rc
262
+ }
263
+
199
264
  # Return 0 if `node` exists and satisfies the minimum major/minor pair.
200
265
  # Patch versions are intentionally ignored because our requirement is a
201
266
  # floor, not an exact runtime pin.
@@ -25,15 +25,21 @@ claude_smart_prepend_astral_bins
25
25
 
26
26
  CMD="${1:-start}"
27
27
  PORT=8071
28
+ EMBEDDING_PORT="${EMBEDDING_PORT:-8072}"
28
29
  # Pass through to `reflexio services start/stop` so the spawned backend
29
30
  # binds to PORT instead of reflexio's library default (8081).
30
31
  export BACKEND_PORT="$PORT"
32
+ export EMBEDDING_PORT
31
33
 
32
34
  # Default: route extraction through the active host CLI + ONNX embedder
33
35
  # so claude-smart works without any LLM API key. Users can opt out by
34
36
  # pre-exporting these to 0.
35
37
  export CLAUDE_SMART_USE_LOCAL_CLI="${CLAUDE_SMART_USE_LOCAL_CLI:-1}"
36
38
  export CLAUDE_SMART_USE_LOCAL_EMBEDDING="${CLAUDE_SMART_USE_LOCAL_EMBEDDING:-1}"
39
+ if [ "${CLAUDE_SMART_USE_LOCAL_EMBEDDING:-}" = "1" ]; then
40
+ export REFLEXIO_EMBEDDING_PROVIDER="${REFLEXIO_EMBEDDING_PROVIDER:-local_service}"
41
+ export REFLEXIO_EMBEDDING_SERVICE_URL="${REFLEXIO_EMBEDDING_SERVICE_URL:-http://127.0.0.1:$EMBEDDING_PORT}"
42
+ fi
37
43
  # The backend can be spawned from contexts whose PATH lacks the host
38
44
  # CLI dir (commonly ~/.local/bin or /opt/homebrew/bin). Pin the CLI
39
45
  # explicitly if we can resolve it from our own (post-login-path) PATH.
@@ -173,6 +179,9 @@ full_stop() {
173
179
 
174
180
  case "$CMD" in
175
181
  start)
182
+ if claude_smart_is_internal_invocation_env; then
183
+ emit_ok; exit 0
184
+ fi
176
185
  # Opt-out: users who don't want the backend managed by the hook can
177
186
  # set CLAUDE_SMART_BACKEND_AUTOSTART=0.
178
187
  if [ "${CLAUDE_SMART_BACKEND_AUTOSTART:-1}" = "0" ]; then
@@ -206,16 +215,21 @@ case "$CMD" in
206
215
  export INTERACTION_CLEANUP_THRESHOLD="${INTERACTION_CLEANUP_THRESHOLD:-500}"
207
216
  export INTERACTION_CLEANUP_DELETE_COUNT="${INTERACTION_CLEANUP_DELETE_COUNT:-200}"
208
217
 
209
- # --no-reload: uvicorn's reloader forks a supervisor; makes
210
- # bookkeeping harder and we don't need hot-reload for a user-facing
211
- # service. Detach via claude_smart_spawn_detached so the same code
212
- # path covers Linux (setsid), macOS (python3 os.setsid), and Windows
213
218
  # (nohup; no process groups). backend-log-runner.sh owns stdout/stderr
214
219
  # capture so process output cannot grow backend.log past its cap.
220
+ #
221
+ # --workers: reflexio defaults to 2 (zero-downtime worker recycling
222
+ # for server deployments). For a single-user Claude Code plugin
223
+ # that's pure overhead: ~1.1 GB extra RSS, periodic 5–10 s spawn
224
+ # hiccups during worker rotation, and SQLite can't accept concurrent
225
+ # writers anyway. Default to 1 here; opt in to N via
226
+ # CLAUDE_SMART_BACKEND_WORKERS for power users running concurrent
227
+ # Claude Code sessions or wanting zero-downtime recycling.
228
+ workers="${CLAUDE_SMART_BACKEND_WORKERS:-1}"
215
229
  claude_smart_spawn_detached bash "$HERE/backend-log-runner.sh" \
216
230
  "$LOG_FILE" "$LOG_MAX_BYTES" -- \
217
231
  uv run --project "$PLUGIN_ROOT" --quiet \
218
- reflexio services start --only backend --no-reload
232
+ reflexio services start --only backend --no-reload --workers "$workers"
219
233
  svc_pid=$!
220
234
  # Record the spawned pid, not a pgid sampled with ps. On POSIX,
221
235
  # setsid/python os.setsid make this pid the new process group leader;
@@ -24,7 +24,7 @@ PLUGIN_ROOT="$(cd "$HERE/.." && pwd)"
24
24
  # on a broken install.
25
25
  FAILURE_MARKER="$HOME/.claude-smart/install-failed"
26
26
  if [ -f "$FAILURE_MARKER" ]; then
27
- msg="$(cat "$FAILURE_MARKER" 2>/dev/null || echo "")"
27
+ msg="$(head -n 1 "$FAILURE_MARKER" 2>/dev/null || echo "")"
28
28
  [ -n "$msg" ] || msg="unknown error"
29
29
  echo "claude-smart is not installed correctly: $msg" >&2
30
30
  echo "Re-run the plugin's Setup (restart Claude Code) or fix the underlying issue and delete $FAILURE_MARKER to retry." >&2
@@ -50,7 +50,8 @@ if ! command -v uv >/dev/null 2>&1; then
50
50
  fi
51
51
  if ! command -v uv >/dev/null 2>&1; then
52
52
  if [ -f "$FAILURE_MARKER" ]; then
53
- msg="$(cat "$FAILURE_MARKER" 2>/dev/null || echo "unknown error")"
53
+ msg="$(head -n 1 "$FAILURE_MARKER" 2>/dev/null || echo "")"
54
+ [ -n "$msg" ] || msg="unknown error"
54
55
  echo "claude-smart: install failed: $msg" >&2
55
56
  echo "Fix the underlying issue and delete $FAILURE_MARKER to retry." >&2
56
57
  else
@@ -87,6 +87,9 @@ port_occupied() {
87
87
 
88
88
  case "$CMD" in
89
89
  start)
90
+ if claude_smart_is_internal_invocation_env; then
91
+ emit_ok; exit 0
92
+ fi
90
93
  # Opt-out: users who don't want the dashboard long-lived can set
91
94
  # CLAUDE_SMART_DASHBOARD_AUTOSTART=0 in their environment.
92
95
  if [ "${CLAUDE_SMART_DASHBOARD_AUTOSTART:-1}" = "0" ]; then
@@ -26,6 +26,10 @@ fi
26
26
  HERE="$(cd "$(dirname "$0")" && pwd)"
27
27
  # shellcheck source=_lib.sh
28
28
  . "$HERE/_lib.sh"
29
+ if claude_smart_is_internal_invocation_env; then
30
+ echo '{"continue":true,"suppressOutput":true}'
31
+ exit 0
32
+ fi
29
33
  # Pick up uv from the user's login-shell PATH (covers ~/.local/bin populated
30
34
  # by the astral.sh installer) so a fresh install works before the user
31
35
  # restarts their terminal. Matches the pattern used by smart-install.sh.
@@ -39,10 +43,20 @@ PLUGIN_ROOT="$(cd "$HERE/.." && pwd)"
39
43
  FAILURE_MARKER="$HOME/.claude-smart/install-failed"
40
44
  STATE_DIR="$HOME/.claude-smart"
41
45
  if [ -f "$FAILURE_MARKER" ]; then
42
- if [ "$EVENT" = "session-start" ] && command -v python3 >/dev/null 2>&1; then
43
- python3 - "$FAILURE_MARKER" <<'PY'
46
+ # Auto-clear the marker when install inputs have changed since the
47
+ # failure was recorded (pyproject.toml / uv.lock / smart-install.sh /
48
+ # python interpreter etc.). Without this the plugin stays muted forever
49
+ # after a transient sync failure even when the user has fixed it.
50
+ stored_fp="$(awk -F= '$1=="fingerprint"{print $2; exit}' "$FAILURE_MARKER" 2>/dev/null || true)"
51
+ current_fp="$(claude_smart_install_fingerprint_hash "$PLUGIN_ROOT" "$HERE" 2>/dev/null || true)"
52
+ if [ -z "$stored_fp" ] || [ "$stored_fp" != "$current_fp" ]; then
53
+ rm -f "$FAILURE_MARKER"
54
+ else
55
+ if [ "$EVENT" = "session-start" ] && command -v python3 >/dev/null 2>&1; then
56
+ python3 - "$FAILURE_MARKER" <<'PY'
44
57
  import json, pathlib, sys
45
- msg = pathlib.Path(sys.argv[1]).read_text().strip() or "unknown error"
58
+ first = pathlib.Path(sys.argv[1]).read_text().splitlines()
59
+ msg = (first[0].strip() if first else "") or "unknown error"
46
60
  print(json.dumps({
47
61
  "hookSpecificOutput": {
48
62
  "hookEventName": "SessionStart",
@@ -55,10 +69,11 @@ print(json.dumps({
55
69
  }
56
70
  }))
57
71
  PY
58
- else
59
- echo '{"continue":true,"suppressOutput":true}'
72
+ else
73
+ echo '{"continue":true,"suppressOutput":true}'
74
+ fi
75
+ exit 0
60
76
  fi
61
- exit 0
62
77
  fi
63
78
 
64
79
  if ! command -v uv >/dev/null 2>&1; then
@@ -80,45 +80,23 @@ acquire_install_lock
80
80
  rm -f "$FAILURE_MARKER"
81
81
 
82
82
  write_failure() {
83
- local reason
83
+ local reason fp_hash
84
84
  reason="$1"
85
- printf '%s\n' "$reason" > "$FAILURE_MARKER"
85
+ fp_hash="$(claude_smart_install_fingerprint_hash "$PLUGIN_ROOT" "$HERE" 2>/dev/null || true)"
86
+ {
87
+ printf '%s\n' "$reason"
88
+ if [ -n "$fp_hash" ]; then
89
+ printf 'fingerprint=%s\n' "$fp_hash"
90
+ fi
91
+ } > "$FAILURE_MARKER"
86
92
  rm -f "$SUCCESS_MARKER"
87
93
  echo "[claude-smart] install failed: $reason" >&2
88
94
  echo '{"continue":true,"suppressOutput":true}'
89
95
  exit 0
90
96
  }
91
97
 
92
- fingerprint_file() {
93
- local path
94
- path="$1"
95
- if [ -f "$path" ]; then
96
- cksum "$path" 2>/dev/null | awk '{print $1 ":" $2}'
97
- else
98
- printf 'missing\n'
99
- fi
100
- }
101
-
102
98
  install_fingerprint() {
103
- printf 'plugin_root=%s\n' "$PLUGIN_ROOT"
104
- printf 'smart_install=%s\n' "$(fingerprint_file "$HERE/smart-install.sh")"
105
- printf 'pyproject=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/pyproject.toml")"
106
- printf 'uv_lock=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/uv.lock")"
107
- # Resolved python interpreter — catches a system upgrade (3.12.4 → 3.12.5)
108
- # that would otherwise let install_complete return true against a venv
109
- # built against a now-deleted interpreter.
110
- if command -v uv >/dev/null 2>&1; then
111
- printf 'python=%s\n' "$(uv python find 3.12 2>/dev/null || echo missing)"
112
- else
113
- printf 'python=no-uv\n'
114
- fi
115
- if [ -d "$PLUGIN_ROOT/dashboard" ]; then
116
- printf 'dashboard_pkg=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/dashboard/package.json")"
117
- printf 'dashboard_lock=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/dashboard/package-lock.json")"
118
- else
119
- printf 'dashboard_pkg=none\n'
120
- printf 'dashboard_lock=none\n'
121
- fi
99
+ claude_smart_install_fingerprint "$PLUGIN_ROOT" "$HERE"
122
100
  }
123
101
 
124
102
  install_complete() {
@@ -205,7 +205,8 @@ def _bootstrap_claude_code_install() -> tuple[bool, str]:
205
205
  if result.returncode != 0:
206
206
  return False, f"smart-install.sh failed in {plugin_root}"
207
207
  if _INSTALL_FAILURE_MARKER.is_file():
208
- reason = _INSTALL_FAILURE_MARKER.read_text().strip() or "unknown error"
208
+ first_line = _INSTALL_FAILURE_MARKER.read_text().splitlines()
209
+ reason = (first_line[0].strip() if first_line else "") or "unknown error"
209
210
  return False, reason
210
211
  return True, str(plugin_root)
211
212
 
@@ -1561,6 +1562,104 @@ def cmd_clear_all(args: argparse.Namespace) -> int:
1561
1562
  return start_rc or 0
1562
1563
 
1563
1564
 
1565
+ def _resolve_reflexio_url() -> str:
1566
+ """Return the URL endpoint for the local claude-smart reflexio backend.
1567
+
1568
+ Mirrors ``claude_smart.reflexio_adapter`` so the CLI's one-shot
1569
+ ReflexioClient calls hit the same server as the long-lived adapter
1570
+ used by hook handlers.
1571
+
1572
+ Returns:
1573
+ str: The ``REFLEXIO_URL`` env var if set, otherwise the default
1574
+ local backend endpoint.
1575
+ """
1576
+ return os.environ.get("REFLEXIO_URL", "http://localhost:8071/")
1577
+
1578
+
1579
+ def _format_deleted_counts(deleted_counts: object) -> str:
1580
+ """Render ``deleted_counts`` from a ``clear_user_data`` response as a summary.
1581
+
1582
+ The backend returns one count per affected table. We surface the
1583
+ total in the headline string (``"removed N row(s)"``) and append a
1584
+ per-table breakdown when at least one bucket is populated, so the
1585
+ operator can tell *what* got cleared at a glance.
1586
+
1587
+ Args:
1588
+ deleted_counts (object): The ``deleted_counts`` field from the
1589
+ backend response. Expected to be a mapping of
1590
+ ``str -> int``; anything else is rendered as ``"unknown"``.
1591
+
1592
+ Returns:
1593
+ str: A short human-readable summary suitable for echoing to
1594
+ stdout (e.g. ``"3 row(s) (interactions=3)"``).
1595
+ """
1596
+ if not isinstance(deleted_counts, dict):
1597
+ return "unknown row(s)"
1598
+ pairs: list[tuple[str, int]] = []
1599
+ for key, value in deleted_counts.items():
1600
+ if isinstance(key, str) and isinstance(value, int):
1601
+ pairs.append((key, value))
1602
+ total = sum(value for _, value in pairs)
1603
+ if not pairs:
1604
+ return f"{total} row(s)"
1605
+ breakdown = ", ".join(f"{name}={count}" for name, count in sorted(pairs))
1606
+ return f"{total} row(s) ({breakdown})"
1607
+
1608
+
1609
+ def cmd_clear_user(args: argparse.Namespace) -> int:
1610
+ """Delete one user_id's playbooks, profiles, and interactions via the backend.
1611
+
1612
+ Per-user-scoped counterpart of ``clear-all``. Where ``clear-all`` does
1613
+ a filesystem-level wipe of the entire local storage root, ``clear-user``
1614
+ routes through the running reflexio backend's ``clear_user_data``
1615
+ endpoint so it can scope the deletion to a single ``user_id`` —
1616
+ leaving other users' data and globally-shared ``agent_playbooks``
1617
+ untouched. This is the primitive SWE-bench-style benchmark harnesses
1618
+ need when running many parallel evaluations under distinct synthetic
1619
+ user ids against one shared backend.
1620
+
1621
+ Args:
1622
+ args (argparse.Namespace): Parsed CLI args. Requires
1623
+ ``args.user_id`` (positional) and ``args.yes`` (the
1624
+ destructive-action confirmation flag, matching ``clear-all``).
1625
+
1626
+ Returns:
1627
+ int: 0 on success, 1 if confirmation is missing or the backend
1628
+ call fails.
1629
+ """
1630
+ user_id = args.user_id
1631
+ if not args.yes:
1632
+ sys.stdout.write(
1633
+ f"This will permanently delete all reflexio rows for user '{user_id}' "
1634
+ "(playbooks, profiles, interactions). Other users' data and "
1635
+ "agent_playbooks are not affected.\n"
1636
+ "Re-run with --yes to confirm.\n"
1637
+ )
1638
+ return 1
1639
+
1640
+ try:
1641
+ from reflexio import ReflexioClient # type: ignore[import-not-found]
1642
+ except ImportError as exc:
1643
+ sys.stderr.write(f"error: reflexio is not installed: {exc}\n")
1644
+ return 1
1645
+
1646
+ try:
1647
+ client = ReflexioClient(url_endpoint=_resolve_reflexio_url())
1648
+ # The companion reflexio PR adds ``clear_user_data``; keep the
1649
+ # CLI buildable until both sides ship together.
1650
+ response = client.clear_user_data(user_id) # pyright: ignore[reportAttributeAccessIssue]
1651
+ except Exception as exc: # noqa: BLE001 — surface backend failure verbatim.
1652
+ sys.stderr.write(f"error: clear-user failed: {exc}\n")
1653
+ return 1
1654
+
1655
+ deleted_counts = (
1656
+ response.get("deleted_counts") if isinstance(response, dict) else None
1657
+ )
1658
+ summary = _format_deleted_counts(deleted_counts)
1659
+ sys.stdout.write(f"Cleared user '{user_id}': removed {summary}.\n")
1660
+ return 0
1661
+
1662
+
1564
1663
  def _build_parser() -> argparse.ArgumentParser:
1565
1664
  parser = argparse.ArgumentParser(prog="claude-smart")
1566
1665
  sub = parser.add_subparsers(dest="command", required=True)
@@ -1625,6 +1724,25 @@ def _build_parser() -> argparse.ArgumentParser:
1625
1724
  )
1626
1725
  ca.set_defaults(func=cmd_clear_all)
1627
1726
 
1727
+ cu = sub.add_parser(
1728
+ "clear-user",
1729
+ help=(
1730
+ "Delete one user_id's playbooks, profiles, and interactions via "
1731
+ "the backend (leaves other users and agent_playbooks intact)"
1732
+ ),
1733
+ )
1734
+ cu.add_argument(
1735
+ "user_id",
1736
+ help="User id whose reflexio rows should be cleared",
1737
+ )
1738
+ cu.add_argument(
1739
+ "--yes",
1740
+ "-y",
1741
+ action="store_true",
1742
+ help="Confirm the destructive clear without prompting",
1743
+ )
1744
+ cu.set_defaults(func=cmd_clear_user)
1745
+
1628
1746
  rs = sub.add_parser(
1629
1747
  "restart",
1630
1748
  help="Restart the reflexio backend and dashboard to pick up changes",
@@ -1,20 +1,185 @@
1
- """SessionEnd hook — flush any remaining interactions; extraction runs async."""
1
+ """SessionEnd hook — flush any remaining interactions; extraction runs async.
2
+
3
+ When ``Stop`` never fires during a session (autonomous agentic loops
4
+ that don't reach ``stop_reason=end_turn``), the buffer at SessionEnd
5
+ time can contain orphan ``Assistant_tool`` records with no closing
6
+ ``Assistant`` anchor. The ``state.unpublished_slice`` contract folds
7
+ tool records into the *next* Assistant turn's ``tools_used``; without
8
+ that anchor, every buffered tool call is silently dropped at publish
9
+ time and reflexio receives only the original User prompt.
10
+
11
+ This module synthesises a closing Assistant record from whatever
12
+ assistant text the Claude Code transcript still has, so the publish
13
+ carries the full tool record stream instead of just the User prompt.
14
+ A short placeholder string is used when the transcript is unreadable.
15
+ """
2
16
 
3
17
  from __future__ import annotations
4
18
 
19
+ import logging
20
+ import time
21
+ from pathlib import Path
5
22
  from typing import Any
6
23
 
7
- from claude_smart import ids, publish
24
+ from claude_smart import ids, publish, state
25
+ from claude_smart.events.stop import (
26
+ _read_transcript_entries,
27
+ _scan_transcript_for_assistant_text,
28
+ )
29
+
30
+ _LOGGER = logging.getLogger(__name__)
31
+
32
+ # Fallback Assistant content used when the transcript is missing,
33
+ # unreadable, or has no recoverable assistant text. The string is
34
+ # distinctive so a future audit can grep for it and quantify how often
35
+ # a session ended without any model response.
36
+ _PLACEHOLDER_ASSISTANT_TEXT = "<session ended without final assistant response>"
8
37
 
9
38
 
10
- def handle(payload: dict[str, Any]) -> None:
39
+ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
40
+ """Flush any remaining buffered turns to reflexio.
41
+
42
+ Args:
43
+ payload (dict[str, Any]): Claude Code hook payload.
44
+
45
+ Returns:
46
+ tuple[PublishStatus, int] | None: The ``(status, count)`` tuple
47
+ from ``publish.publish_unpublished`` so the dispatcher's
48
+ forensic hook log captures the publish outcome. ``None``
49
+ when the payload had no ``session_id``.
50
+ """
11
51
  session_id = payload.get("session_id")
12
52
  if not session_id:
13
- return
53
+ return None
14
54
  project_id = ids.resolve_project_id(payload.get("cwd"))
15
- publish.publish_unpublished(
55
+
56
+ _maybe_synthesize_assistant_anchor(
16
57
  session_id=session_id,
17
58
  project_id=project_id,
18
- force_extraction=False,
59
+ transcript_path=payload.get("transcript_path"),
60
+ )
61
+
62
+ # ``force_extraction=True`` makes the reflexio server run the
63
+ # extractor synchronously for this publish, so by the time the HTTP
64
+ # call returns the playbook (if any) is already committed. SessionEnd
65
+ # is the final flush — the user/agent is already going away — so
66
+ # adding a few extra seconds here trades nothing for "snapshot_playbooks()
67
+ # reads after this point see the just-extracted rows." Stop hooks
68
+ # (the per-turn flushes) stay async so they don't slow interactive
69
+ # generation.
70
+ return publish.publish_unpublished(
71
+ session_id=session_id,
72
+ project_id=project_id,
73
+ force_extraction=True,
19
74
  skip_aggregation=False,
20
75
  )
76
+
77
+
78
+ def _maybe_synthesize_assistant_anchor(
79
+ *,
80
+ session_id: str,
81
+ project_id: str,
82
+ transcript_path: Any,
83
+ ) -> None:
84
+ """Append a closing Assistant record when the unpublished tail
85
+ holds orphan ``Assistant_tool`` rows.
86
+
87
+ Walks the post-watermark slice of the buffer and looks for a tail
88
+ pattern of ``Assistant_tool`` records that aren't followed by an
89
+ ``Assistant`` record. If that's the case, scans the Claude Code
90
+ transcript for whatever final assistant text is recoverable and
91
+ appends one synthetic ``Assistant`` record so
92
+ ``state.unpublished_slice`` can fold the tools into its
93
+ ``tools_used`` field at publish time.
94
+
95
+ Args:
96
+ session_id (str): Claude Code session id for the state buffer.
97
+ project_id (str): Resolved project_id used as the synthetic
98
+ record's ``user_id`` (matches Stop's convention).
99
+ transcript_path (Any): The ``transcript_path`` value from the
100
+ hook payload — accepted as ``Any`` because Claude Code may
101
+ send a string, ``None``, or omit the key entirely.
102
+
103
+ Returns:
104
+ None
105
+ """
106
+ records = state.read_all(session_id)
107
+ if not _tail_has_orphan_tools(records):
108
+ return
109
+
110
+ assistant_text = _recover_assistant_text(transcript_path)
111
+ state.append(
112
+ session_id,
113
+ {
114
+ "ts": int(time.time()),
115
+ "role": "Assistant",
116
+ "content": assistant_text,
117
+ "user_id": project_id,
118
+ "synthesised_by": "session_end_anchor",
119
+ },
120
+ )
121
+ _LOGGER.info(
122
+ "session_end: synthesised Assistant anchor for %s (len=%d)",
123
+ session_id,
124
+ len(assistant_text),
125
+ )
126
+
127
+
128
+ def _tail_has_orphan_tools(records: list[dict[str, Any]]) -> bool:
129
+ """Return True when the post-watermark tail ends with one or more
130
+ ``Assistant_tool`` records with no closing ``Assistant`` record.
131
+
132
+ Walks records from the latest ``published_up_to`` marker forward;
133
+ tracks the most recent record role. The tail is considered "orphan"
134
+ when at least one ``Assistant_tool`` was seen after the watermark
135
+ and no ``Assistant`` record was seen *after* the last
136
+ ``Assistant_tool``.
137
+
138
+ Args:
139
+ records (list[dict[str, Any]]): The full buffered record list
140
+ as returned by ``state.read_all``.
141
+
142
+ Returns:
143
+ bool: True if a synthetic anchor would unblock publishing tool
144
+ records that would otherwise be dropped.
145
+ """
146
+ # Find the most recent published_up_to watermark.
147
+ published = 0
148
+ for rec in records:
149
+ if "published_up_to" in rec:
150
+ published = rec["published_up_to"]
151
+
152
+ tail = records[published:]
153
+ saw_orphan_tool = False
154
+ for rec in tail:
155
+ role = rec.get("role")
156
+ if role == "Assistant_tool":
157
+ saw_orphan_tool = True
158
+ elif role == "Assistant":
159
+ saw_orphan_tool = False
160
+ return saw_orphan_tool
161
+
162
+
163
+ def _recover_assistant_text(transcript_path: Any) -> str:
164
+ """Return whatever assistant text the transcript still has, or a placeholder.
165
+
166
+ Args:
167
+ transcript_path (Any): Raw value from the hook payload.
168
+
169
+ Returns:
170
+ str: Concatenated current-turn assistant text from the
171
+ transcript, or ``_PLACEHOLDER_ASSISTANT_TEXT`` when the
172
+ transcript is unset, missing, or yields nothing.
173
+ """
174
+ if not isinstance(transcript_path, str) or not transcript_path:
175
+ return _PLACEHOLDER_ASSISTANT_TEXT
176
+ path = Path(transcript_path)
177
+ if not path.is_file():
178
+ return _PLACEHOLDER_ASSISTANT_TEXT
179
+ try:
180
+ entries = _read_transcript_entries(path)
181
+ except OSError as exc:
182
+ _LOGGER.debug("session_end transcript read failed: %s", exc)
183
+ return _PLACEHOLDER_ASSISTANT_TEXT
184
+ text = _scan_transcript_for_assistant_text(entries)
185
+ return text or _PLACEHOLDER_ASSISTANT_TEXT
@@ -15,8 +15,32 @@ from claude_smart.stall_banner import render_banner
15
15
  # Claude-smart's preferred extraction cadence — more frequent, smaller windows
16
16
  # than reflexio's out-of-box 10/5. Applied idempotently to the reflexio server
17
17
  # on every SessionStart via Adapter.apply_extraction_defaults.
18
- _CLAUDE_SMART_WINDOW_SIZE = 5
19
- _CLAUDE_SMART_STRIDE_SIZE = 3
18
+ #
19
+ # Env-var overrides let benchmark harnesses (SWE-bench Verified, in particular)
20
+ # run with ``CLAUDE_SMART_STRIDE_SIZE=1`` so a single short autonomous session
21
+ # triggers extraction on its sole publish instead of being silently filtered
22
+ # out by the stride pre-filter (``new < stride_size``). Production users keep
23
+ # the defaults; the values are only read on SessionStart, so a launcher that
24
+ # sets the env var has predictable effect for the entire Claude Code session.
25
+ #
26
+ # Parsing is defensive: a non-integer env value falls back to the default
27
+ # rather than raising at import time. SessionStart is the hook that wires up
28
+ # every subsequent hook, so an import-time crash here would silently disable
29
+ # the whole plugin for the session.
30
+
31
+
32
+ def _int_env(name: str, default: int) -> int:
33
+ raw = os.environ.get(name)
34
+ if raw is None:
35
+ return default
36
+ try:
37
+ return int(raw)
38
+ except ValueError:
39
+ return default
40
+
41
+
42
+ _CLAUDE_SMART_WINDOW_SIZE = _int_env("CLAUDE_SMART_WINDOW_SIZE", 5)
43
+ _CLAUDE_SMART_STRIDE_SIZE = _int_env("CLAUDE_SMART_STRIDE_SIZE", 3)
20
44
  # Optimizer is on by default. Set this env var to "0" to skip pushing the
21
45
  # claude-smart optimizer defaults on SessionStart (kill switch).
22
46
  _DISABLE_OPTIMIZER_ENV = "CLAUDE_SMART_ENABLE_OPTIMIZER"
@@ -308,10 +308,23 @@ def _resolve_cited_items(session_id: str, cited_ids: list[str]) -> list[dict[str
308
308
  return resolved
309
309
 
310
310
 
311
- def handle(payload: dict[str, Any]) -> None:
311
+ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
312
+ """Drain the buffered assistant turn to reflexio.
313
+
314
+ Args:
315
+ payload (dict[str, Any]): Claude Code hook payload.
316
+
317
+ Returns:
318
+ tuple[PublishStatus, int] | None: The ``(status, count)`` tuple
319
+ from ``publish.publish_unpublished`` so the dispatcher can
320
+ include it in the forensic hook log. ``None`` is returned
321
+ only when the handler short-circuits before publishing (no
322
+ ``session_id`` in the payload, or Codex internal-prompt
323
+ detection skipped the fire).
324
+ """
312
325
  session_id = payload.get("session_id")
313
326
  if not session_id:
314
- return
327
+ return None
315
328
 
316
329
  # Always append an Assistant record, even when the turn emitted only
317
330
  # tool calls and no text. ``state.unpublished_slice`` folds any
@@ -332,7 +345,7 @@ def handle(payload: dict[str, Any]) -> None:
332
345
  )
333
346
  if runtime.is_codex():
334
347
  if internal_call.is_codex_internal_prompt(prompt):
335
- return
348
+ return None
336
349
 
337
350
  last_assistant_message = payload.get("last_assistant_message")
338
351
  assistant_text = (
@@ -374,7 +387,7 @@ def handle(payload: dict[str, Any]) -> None:
374
387
  if cited_items:
375
388
  record["cited_items"] = cited_items
376
389
  state.append(session_id, record)
377
- publish.publish_unpublished(
390
+ return publish.publish_unpublished(
378
391
  session_id=session_id,
379
392
  project_id=project_id,
380
393
  force_extraction=False,
@@ -5,6 +5,13 @@ The plugin's ``hook_entry.sh`` calls either
5
5
  ``python -m claude_smart.hook <host> <event>`` once per hook invocation.
6
6
  This module reads the hook JSON from stdin, routes to the matching handler,
7
7
  and makes sure no unhandled exception ever propagates.
8
+
9
+ Every fire produces one structured JSON line in
10
+ ``~/.claude-smart/hook.log`` via ``hook_log.log_event`` — covers the
11
+ event name, session id, internal-invocation skip, handler outcome
12
+ (``ok`` / ``raised:<Exc>`` / ``unknown_event``), and for Stop/SessionEnd
13
+ the publish status + count. The log is the forensic trail for chasing
14
+ "hook didn't publish" mysteries from a single file.
8
15
  """
9
16
 
10
17
  from __future__ import annotations
@@ -12,15 +19,22 @@ from __future__ import annotations
12
19
  import json
13
20
  import logging
14
21
  import sys
15
- from typing import Any, Callable
22
+ from collections.abc import Callable
23
+ from contextlib import redirect_stdout
24
+ from io import StringIO
25
+ from typing import Any
16
26
 
17
- from claude_smart import runtime
27
+ from claude_smart import hook_log, ids, runtime
18
28
  from claude_smart.internal_call import is_internal_invocation
19
29
 
20
30
  _LOGGER = logging.getLogger(__name__)
21
31
 
22
32
 
23
- def _load_handlers() -> dict[str, Callable[[dict[str, Any]], None]]:
33
+ # Stop and SessionEnd return ``(PublishStatus, int)`` so the dispatcher can
34
+ # log the publish outcome; the other handlers return ``None`` (or nothing).
35
+ # Use ``Any`` here rather than two specialised typedefs — the dispatcher
36
+ # narrows at the call site via ``isinstance(result, tuple)``.
37
+ def _load_handlers() -> dict[str, Callable[[dict[str, Any]], Any]]:
24
38
  from claude_smart.events import (
25
39
  post_tool,
26
40
  pre_tool,
@@ -92,6 +106,15 @@ def main(argv: list[str] | None = None) -> int:
92
106
  # handlers so we don't publish the extractor's system prompt back
93
107
  # into reflexio. See claude_smart.internal_call for detection logic.
94
108
  if is_internal_invocation(payload):
109
+ hook_log.log_event(
110
+ event=event,
111
+ host=host,
112
+ session_id=payload.get("session_id"),
113
+ project_id=ids.resolve_project_id(payload.get("cwd")),
114
+ cwd=payload.get("cwd"),
115
+ internal_skipped=True,
116
+ handler_status="ok",
117
+ )
95
118
  emit_continue()
96
119
  return 0
97
120
 
@@ -99,14 +122,49 @@ def main(argv: list[str] | None = None) -> int:
99
122
  handler = handlers.get(event)
100
123
  if handler is None:
101
124
  _LOGGER.warning("unknown hook event: %s", event)
125
+ hook_log.log_event(
126
+ event=event,
127
+ host=host,
128
+ session_id=payload.get("session_id"),
129
+ project_id=ids.resolve_project_id(payload.get("cwd")),
130
+ cwd=payload.get("cwd"),
131
+ handler_status="unknown_event",
132
+ )
102
133
  emit_continue()
103
134
  return 0
104
135
 
136
+ handler_status = "ok"
137
+ publish_status: str | None = None
138
+ publish_count: int | None = None
139
+ handler_stdout = StringIO()
105
140
  try:
106
- handler(payload)
141
+ with redirect_stdout(handler_stdout):
142
+ result = handler(payload)
143
+ if isinstance(result, tuple) and len(result) == 2:
144
+ publish_status, publish_count = result
107
145
  except Exception as exc: # noqa: BLE001 — hooks must never crash the session.
108
146
  _LOGGER.exception("hook handler %s raised: %s", event, exc)
147
+ handler_status = f"raised:{type(exc).__name__}: {exc}"
109
148
  emit_continue()
149
+ else:
150
+ # Handlers that inject context already emit the full hook JSON. Silent
151
+ # handlers still need the continue response so the parent session does
152
+ # not wait on an empty stdout payload.
153
+ captured = handler_stdout.getvalue()
154
+ if captured.strip():
155
+ sys.stdout.write(captured)
156
+ else:
157
+ emit_continue()
158
+ hook_log.log_event(
159
+ event=event,
160
+ host=host,
161
+ session_id=payload.get("session_id"),
162
+ project_id=ids.resolve_project_id(payload.get("cwd")),
163
+ cwd=payload.get("cwd"),
164
+ handler_status=handler_status,
165
+ publish_status=publish_status,
166
+ publish_count=publish_count,
167
+ )
110
168
  return 0
111
169
 
112
170
 
@@ -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)
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.31"
423
423
  source = { editable = "." }
424
424
  dependencies = [
425
425
  { name = "chromadb" },