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 +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/pyproject.toml +1 -1
- package/plugin/scripts/_lib.sh +65 -0
- package/plugin/scripts/backend-service.sh +19 -5
- package/plugin/scripts/cli.sh +3 -2
- package/plugin/scripts/dashboard-service.sh +3 -0
- package/plugin/scripts/hook_entry.sh +21 -6
- package/plugin/scripts/smart-install.sh +9 -31
- package/plugin/src/claude_smart/cli.py +119 -1
- 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 +17 -4
- package/plugin/src/claude_smart/hook.py +62 -4
- package/plugin/src/claude_smart/hook_log.py +301 -0
- package/plugin/uv.lock +1 -1
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.
|
|
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
package/plugin/hooks/hooks.json
CHANGED
|
@@ -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":
|
|
105
|
+
"timeout": 300
|
|
106
106
|
},
|
|
107
107
|
{
|
|
108
108
|
"type": "command",
|
package/plugin/pyproject.toml
CHANGED
package/plugin/scripts/_lib.sh
CHANGED
|
@@ -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;
|
package/plugin/scripts/cli.sh
CHANGED
|
@@ -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="$(
|
|
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="$(
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
+
|
|
56
|
+
_maybe_synthesize_assistant_anchor(
|
|
16
57
|
session_id=session_id,
|
|
17
58
|
project_id=project_id,
|
|
18
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|