claude-smart 0.2.30 → 0.2.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/claude-smart.js +172 -18
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/dashboard/app/api/config/route.ts +11 -2
- package/plugin/dashboard/app/api/health/route.ts +45 -6
- package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +15 -7
- package/plugin/dashboard/app/api/rules/applied/route.ts +27 -0
- package/plugin/dashboard/app/configure/env/page.tsx +36 -31
- package/plugin/dashboard/app/configure/layout.tsx +1 -1
- package/plugin/dashboard/app/configure/server/page.tsx +8 -14
- package/plugin/dashboard/app/dashboard/page.tsx +311 -115
- package/plugin/dashboard/app/globals.css +80 -66
- package/plugin/dashboard/app/layout.tsx +13 -10
- package/plugin/dashboard/app/preferences/[id]/page.tsx +21 -32
- package/plugin/dashboard/app/preferences/page.tsx +154 -54
- package/plugin/dashboard/app/preferences/project/[id]/page.tsx +1 -0
- package/plugin/dashboard/app/rules/[id]/page.tsx +51 -0
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +4 -4
- package/plugin/dashboard/app/sessions/page.tsx +14 -10
- package/plugin/dashboard/app/skills/page.tsx +175 -56
- package/plugin/dashboard/app/skills/project/[id]/page.tsx +22 -38
- package/plugin/dashboard/app/skills/shared/[id]/page.tsx +20 -37
- package/plugin/dashboard/components/common/delete-learning-danger-zone.tsx +166 -0
- package/plugin/dashboard/components/common/empty-state.tsx +4 -2
- package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
- package/plugin/dashboard/components/common/page-header.tsx +5 -3
- package/plugin/dashboard/components/common/page-tabs.tsx +4 -4
- package/plugin/dashboard/components/common/stat-card.tsx +9 -5
- package/plugin/dashboard/components/layout/sidebar.tsx +24 -9
- package/plugin/dashboard/components/layout/top-bar.tsx +37 -25
- package/plugin/dashboard/components/ui/input.tsx +1 -0
- package/plugin/dashboard/hooks/use-settings.tsx +30 -61
- package/plugin/dashboard/hooks/use-stall-state.ts +5 -9
- package/plugin/dashboard/lib/config-file.ts +2 -0
- package/plugin/dashboard/lib/reflexio-client.ts +23 -48
- package/plugin/dashboard/lib/session-reader.ts +222 -6
- package/plugin/dashboard/lib/types.ts +20 -1
- package/plugin/dashboard/package-lock.json +70 -95
- package/plugin/dashboard/package.json +5 -2
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/pyproject.toml +1 -1
- package/plugin/scripts/_lib.sh +126 -0
- package/plugin/scripts/backend-service.sh +32 -7
- package/plugin/scripts/cli.sh +4 -2
- package/plugin/scripts/codex-hook.js +100 -3
- package/plugin/scripts/dashboard-service.sh +98 -19
- package/plugin/scripts/hook_entry.sh +32 -11
- package/plugin/scripts/smart-install.sh +27 -44
- package/plugin/src/claude_smart/cli.py +204 -20
- package/plugin/src/claude_smart/context_format.py +244 -6
- package/plugin/src/claude_smart/context_inject.py +8 -1
- package/plugin/src/claude_smart/cs_cite.py +186 -34
- package/plugin/src/claude_smart/env_config.py +102 -0
- package/plugin/src/claude_smart/events/session_end.py +171 -6
- package/plugin/src/claude_smart/events/session_start.py +26 -2
- package/plugin/src/claude_smart/events/stop.py +48 -9
- package/plugin/src/claude_smart/hook.py +62 -4
- package/plugin/src/claude_smart/hook_log.py +301 -0
- package/plugin/src/claude_smart/internal_call.py +30 -0
- package/plugin/src/claude_smart/publish.py +5 -0
- package/plugin/src/claude_smart/reflexio_adapter.py +102 -26
- package/plugin/uv.lock +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Shared ``~/.reflexio/.env`` helpers for claude-smart."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
REFLEXIO_ENV_PATH = Path.home() / ".reflexio" / ".env"
|
|
9
|
+
MANAGED_REFLEXIO_URL = "https://www.reflexio.ai/"
|
|
10
|
+
REFLEXIO_URL_ENV = "REFLEXIO_URL"
|
|
11
|
+
REFLEXIO_API_KEY_ENV = "REFLEXIO_API_KEY"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_env_line(line: str) -> tuple[str, str] | None:
|
|
15
|
+
"""Parse a simple dotenv ``KEY=value`` line.
|
|
16
|
+
|
|
17
|
+
This intentionally ignores comments, blank lines, and shell features. The
|
|
18
|
+
Reflexio env writer emits plain quoted assignments, which is all
|
|
19
|
+
claude-smart needs here.
|
|
20
|
+
"""
|
|
21
|
+
stripped = line.strip()
|
|
22
|
+
if not stripped or stripped.startswith("#"):
|
|
23
|
+
return None
|
|
24
|
+
if stripped.startswith("export "):
|
|
25
|
+
stripped = stripped[len("export ") :].lstrip()
|
|
26
|
+
key, sep, raw_value = stripped.partition("=")
|
|
27
|
+
if not sep:
|
|
28
|
+
return None
|
|
29
|
+
key = key.strip()
|
|
30
|
+
if not key or not key.replace("_", "").isalnum() or key[0].isdigit():
|
|
31
|
+
return None
|
|
32
|
+
value = raw_value.strip()
|
|
33
|
+
if len(value) >= 2 and (
|
|
34
|
+
(value[0] == value[-1] == '"') or (value[0] == value[-1] == "'")
|
|
35
|
+
):
|
|
36
|
+
value = value[1:-1]
|
|
37
|
+
return key, value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_reflexio_env(path: Path | None = None) -> None:
|
|
41
|
+
"""Load ``~/.reflexio/.env`` into ``os.environ`` without overriding values."""
|
|
42
|
+
path = path or REFLEXIO_ENV_PATH
|
|
43
|
+
try:
|
|
44
|
+
text = path.read_text()
|
|
45
|
+
except OSError:
|
|
46
|
+
return
|
|
47
|
+
for line in text.splitlines():
|
|
48
|
+
parsed = parse_env_line(line)
|
|
49
|
+
if parsed is None:
|
|
50
|
+
continue
|
|
51
|
+
key, value = parsed
|
|
52
|
+
os.environ.setdefault(key, value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def set_env_vars(path: Path, values: dict[str, str]) -> list[str]:
|
|
56
|
+
"""Upsert dotenv keys while preserving comments and unrelated entries."""
|
|
57
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
try:
|
|
59
|
+
existing = path.read_text()
|
|
60
|
+
except OSError:
|
|
61
|
+
existing = ""
|
|
62
|
+
|
|
63
|
+
lines = existing.splitlines()
|
|
64
|
+
seen: set[str] = set()
|
|
65
|
+
out: list[str] = []
|
|
66
|
+
for line in lines:
|
|
67
|
+
parsed = parse_env_line(line)
|
|
68
|
+
if parsed is None:
|
|
69
|
+
out.append(line)
|
|
70
|
+
continue
|
|
71
|
+
key, _old = parsed
|
|
72
|
+
if key in values:
|
|
73
|
+
out.append(f'{key}="{_escape_env_value(values[key])}"')
|
|
74
|
+
seen.add(key)
|
|
75
|
+
else:
|
|
76
|
+
out.append(line)
|
|
77
|
+
|
|
78
|
+
added: list[str] = []
|
|
79
|
+
for key, value in values.items():
|
|
80
|
+
if key in seen:
|
|
81
|
+
continue
|
|
82
|
+
out.append(f'{key}="{_escape_env_value(value)}"')
|
|
83
|
+
added.append(key)
|
|
84
|
+
|
|
85
|
+
content = "\n".join(out)
|
|
86
|
+
path.write_text(content + ("\n" if content else ""), encoding="utf-8")
|
|
87
|
+
path.chmod(0o600)
|
|
88
|
+
return added
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def mask_secret(value: str) -> str:
|
|
92
|
+
"""Return a display-safe API key preview."""
|
|
93
|
+
if not value:
|
|
94
|
+
return ""
|
|
95
|
+
if len(value) <= 8:
|
|
96
|
+
return "*" * len(value)
|
|
97
|
+
prefix = value[:5] if "-" in value[:8] else value[:4]
|
|
98
|
+
return f"{prefix}****{value[-4:]}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _escape_env_value(value: str) -> str:
|
|
102
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
@@ -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"
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
+
import os
|
|
7
8
|
import time
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any
|
|
@@ -289,7 +290,7 @@ def _resolve_cited_items(session_id: str, cited_ids: list[str]) -> list[dict[str
|
|
|
289
290
|
for cid in cited_ids:
|
|
290
291
|
if cid in seen:
|
|
291
292
|
continue
|
|
292
|
-
entry = registry
|
|
293
|
+
entry = _registry_entry_for_citation(registry, cid)
|
|
293
294
|
if not entry:
|
|
294
295
|
continue
|
|
295
296
|
seen.add(cid)
|
|
@@ -308,10 +309,44 @@ def _resolve_cited_items(session_id: str, cited_ids: list[str]) -> list[dict[str
|
|
|
308
309
|
return resolved
|
|
309
310
|
|
|
310
311
|
|
|
311
|
-
def
|
|
312
|
+
def _registry_entry_for_citation(
|
|
313
|
+
registry: dict[str, dict[str, Any]], citation: str
|
|
314
|
+
) -> dict[str, Any] | None:
|
|
315
|
+
"""Resolve a legacy rank id or a dashboard-route citation token."""
|
|
316
|
+
if not citation.startswith("route:"):
|
|
317
|
+
return registry.get(citation)
|
|
318
|
+
try:
|
|
319
|
+
_, kind, source_kind, real_id = citation.split(":", 3)
|
|
320
|
+
except ValueError:
|
|
321
|
+
return None
|
|
322
|
+
for entry in registry.values():
|
|
323
|
+
if entry.get("kind") != kind:
|
|
324
|
+
continue
|
|
325
|
+
if str(entry.get("real_id") or "") != real_id:
|
|
326
|
+
continue
|
|
327
|
+
if kind == "playbook" and entry.get("source_kind") != source_kind:
|
|
328
|
+
continue
|
|
329
|
+
return entry
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
|
|
334
|
+
"""Drain the buffered assistant turn to reflexio.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
payload (dict[str, Any]): Claude Code hook payload.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
tuple[PublishStatus, int] | None: The ``(status, count)`` tuple
|
|
341
|
+
from ``publish.publish_unpublished`` so the dispatcher can
|
|
342
|
+
include it in the forensic hook log. ``None`` is returned
|
|
343
|
+
only when the handler short-circuits before publishing (no
|
|
344
|
+
``session_id`` in the payload, or Codex internal-prompt
|
|
345
|
+
detection skipped the fire).
|
|
346
|
+
"""
|
|
312
347
|
session_id = payload.get("session_id")
|
|
313
348
|
if not session_id:
|
|
314
|
-
return
|
|
349
|
+
return None
|
|
315
350
|
|
|
316
351
|
# Always append an Assistant record, even when the turn emitted only
|
|
317
352
|
# tool calls and no text. ``state.unpublished_slice`` folds any
|
|
@@ -330,9 +365,8 @@ def handle(payload: dict[str, Any]) -> None:
|
|
|
330
365
|
prompt = payload.get("prompt") or (
|
|
331
366
|
_scan_transcript_for_user_text(entries) if runtime.is_codex() else ""
|
|
332
367
|
)
|
|
333
|
-
if runtime.is_codex():
|
|
334
|
-
|
|
335
|
-
return
|
|
368
|
+
if runtime.is_codex() and internal_call.is_codex_internal_prompt(prompt):
|
|
369
|
+
return None
|
|
336
370
|
|
|
337
371
|
last_assistant_message = payload.get("last_assistant_message")
|
|
338
372
|
assistant_text = (
|
|
@@ -344,11 +378,16 @@ def handle(payload: dict[str, Any]) -> None:
|
|
|
344
378
|
)
|
|
345
379
|
if (
|
|
346
380
|
runtime.is_codex()
|
|
347
|
-
and
|
|
381
|
+
and (
|
|
382
|
+
internal_call.is_codex_title_response(assistant_text)
|
|
383
|
+
or internal_call.is_codex_suggestions_response(assistant_text)
|
|
384
|
+
)
|
|
348
385
|
and not prompt
|
|
349
386
|
and not _has_unpublished_user_turn(session_id)
|
|
350
387
|
):
|
|
351
|
-
return
|
|
388
|
+
return None
|
|
389
|
+
if os.environ.get("CLAUDE_SMART_CITATIONS", "on") == "off":
|
|
390
|
+
assistant_text = cs_cite.strip_marker_lines(assistant_text)
|
|
352
391
|
text_cited_ids = cs_cite.parse_text_citations(assistant_text)
|
|
353
392
|
cited_items = _resolve_cited_items(session_id, text_cited_ids)
|
|
354
393
|
plan_decisions = _scan_transcript_for_plan_decisions(entries)
|
|
@@ -374,7 +413,7 @@ def handle(payload: dict[str, Any]) -> None:
|
|
|
374
413
|
if cited_items:
|
|
375
414
|
record["cited_items"] = cited_items
|
|
376
415
|
state.append(session_id, record)
|
|
377
|
-
publish.publish_unpublished(
|
|
416
|
+
return publish.publish_unpublished(
|
|
378
417
|
session_id=session_id,
|
|
379
418
|
project_id=project_id,
|
|
380
419
|
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
|
|