claude-smart 0.2.23 → 0.2.25
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/.agents/plugins/marketplace.json +20 -0
- package/README.md +76 -28
- package/bin/claude-smart.js +355 -11
- package/package.json +11 -1
- package/plugin/.claude-plugin/plugin.json +17 -0
- package/plugin/.codex-plugin/plugin.json +35 -0
- package/plugin/LICENSE +202 -0
- package/plugin/README.md +37 -0
- package/plugin/bin/cs-cite +77 -0
- package/plugin/commands/clear-all.md +8 -0
- package/plugin/commands/dashboard.md +8 -0
- package/plugin/commands/learn.md +12 -0
- package/plugin/commands/restart.md +8 -0
- package/plugin/commands/show.md +8 -0
- package/plugin/dashboard/AGENTS.md +6 -0
- package/plugin/dashboard/app/api/claude-settings/route.ts +19 -0
- package/plugin/dashboard/app/api/config/route.ts +16 -0
- package/plugin/dashboard/app/api/health/route.ts +10 -0
- package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +63 -0
- package/plugin/dashboard/app/api/sessions/[id]/route.ts +28 -0
- package/plugin/dashboard/app/api/sessions/route.ts +14 -0
- package/plugin/dashboard/app/configure/env/page.tsx +318 -0
- package/plugin/dashboard/app/configure/layout.tsx +47 -0
- package/plugin/dashboard/app/configure/page.tsx +5 -0
- package/plugin/dashboard/app/configure/server/page.tsx +258 -0
- package/plugin/dashboard/app/dashboard/page.tsx +227 -0
- package/plugin/dashboard/app/globals.css +129 -0
- package/plugin/dashboard/app/icon.png +0 -0
- package/plugin/dashboard/app/layout.tsx +40 -0
- package/plugin/dashboard/app/page.tsx +5 -0
- package/plugin/dashboard/app/preferences/[id]/page.tsx +531 -0
- package/plugin/dashboard/app/preferences/page.tsx +126 -0
- package/plugin/dashboard/app/providers.tsx +12 -0
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +321 -0
- package/plugin/dashboard/app/sessions/page.tsx +186 -0
- package/plugin/dashboard/app/skills/page.tsx +362 -0
- package/plugin/dashboard/app/skills/project/[id]/page.tsx +597 -0
- package/plugin/dashboard/app/skills/shared/[id]/page.tsx +830 -0
- package/plugin/dashboard/components/common/delete-all-button.tsx +45 -0
- package/plugin/dashboard/components/common/empty-state.tsx +34 -0
- package/plugin/dashboard/components/common/learnings-badge.tsx +34 -0
- package/plugin/dashboard/components/common/page-header.tsx +34 -0
- package/plugin/dashboard/components/common/page-tabs.tsx +115 -0
- package/plugin/dashboard/components/common/stat-card.tsx +38 -0
- package/plugin/dashboard/components/layout/nav-items.ts +22 -0
- package/plugin/dashboard/components/layout/sidebar.tsx +45 -0
- package/plugin/dashboard/components/layout/top-bar.tsx +64 -0
- package/plugin/dashboard/components/stall-banner.tsx +53 -0
- package/plugin/dashboard/components/ui/badge.tsx +52 -0
- package/plugin/dashboard/components/ui/button.tsx +60 -0
- package/plugin/dashboard/components/ui/collapsible.tsx +21 -0
- package/plugin/dashboard/components/ui/input.tsx +20 -0
- package/plugin/dashboard/components/ui/label.tsx +20 -0
- package/plugin/dashboard/components/ui/scroll-area.tsx +55 -0
- package/plugin/dashboard/components/ui/select.tsx +201 -0
- package/plugin/dashboard/components/ui/separator.tsx +25 -0
- package/plugin/dashboard/components/ui/sheet.tsx +135 -0
- package/plugin/dashboard/components/ui/switch.tsx +32 -0
- package/plugin/dashboard/components.json +25 -0
- package/plugin/dashboard/eslint.config.mjs +16 -0
- package/plugin/dashboard/hooks/use-settings.tsx +88 -0
- package/plugin/dashboard/hooks/use-stall-state.ts +59 -0
- package/plugin/dashboard/lib/claude-settings-file.ts +114 -0
- package/plugin/dashboard/lib/config-file.ts +131 -0
- package/plugin/dashboard/lib/format.ts +58 -0
- package/plugin/dashboard/lib/reflexio-client.ts +238 -0
- package/plugin/dashboard/lib/reflexio-url.ts +17 -0
- package/plugin/dashboard/lib/session-reader.ts +245 -0
- package/plugin/dashboard/lib/status.ts +24 -0
- package/plugin/dashboard/lib/types.ts +145 -0
- package/plugin/dashboard/lib/utils.ts +6 -0
- package/plugin/dashboard/next.config.ts +7 -0
- package/plugin/dashboard/package-lock.json +10275 -0
- package/plugin/dashboard/package.json +37 -0
- package/plugin/dashboard/postcss.config.mjs +7 -0
- package/plugin/dashboard/public/claude-smart-icon.png +0 -0
- package/plugin/dashboard/tsconfig.json +34 -0
- package/plugin/hooks/codex-hooks.json +67 -0
- package/plugin/hooks/hooks.json +111 -0
- package/plugin/pyproject.toml +49 -0
- package/plugin/scripts/_codex_env.sh +27 -0
- package/plugin/scripts/_lib.sh +325 -0
- package/plugin/scripts/backend-service.sh +208 -0
- package/plugin/scripts/cli.sh +40 -0
- package/plugin/scripts/dashboard-build.sh +139 -0
- package/plugin/scripts/dashboard-open.sh +107 -0
- package/plugin/scripts/dashboard-service.sh +195 -0
- package/plugin/scripts/ensure-plugin-root.sh +84 -0
- package/plugin/scripts/hook_entry.sh +70 -0
- package/plugin/scripts/smart-install.sh +411 -0
- package/plugin/src/claude_smart/__init__.py +3 -0
- package/plugin/src/claude_smart/cli.py +1342 -0
- package/plugin/src/claude_smart/context_format.py +277 -0
- package/plugin/src/claude_smart/context_inject.py +92 -0
- package/plugin/src/claude_smart/cs_cite.py +236 -0
- package/plugin/src/claude_smart/events/__init__.py +1 -0
- package/plugin/src/claude_smart/events/post_tool.py +148 -0
- package/plugin/src/claude_smart/events/pre_tool.py +52 -0
- package/plugin/src/claude_smart/events/session_end.py +20 -0
- package/plugin/src/claude_smart/events/session_start.py +119 -0
- package/plugin/src/claude_smart/events/stop.py +393 -0
- package/plugin/src/claude_smart/events/user_prompt.py +73 -0
- package/plugin/src/claude_smart/hook.py +114 -0
- package/plugin/src/claude_smart/ids.py +56 -0
- package/plugin/src/claude_smart/internal_call.py +89 -0
- package/plugin/src/claude_smart/optimizer_assistant.py +203 -0
- package/plugin/src/claude_smart/publish.py +71 -0
- package/plugin/src/claude_smart/query_compose.py +51 -0
- package/plugin/src/claude_smart/reflexio_adapter.py +403 -0
- package/plugin/src/claude_smart/runtime.py +52 -0
- package/plugin/src/claude_smart/stall_banner.py +61 -0
- package/plugin/src/claude_smart/state.py +276 -0
- package/plugin/uv.lock +3720 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Stop hook — finalize the current assistant turn, publish to reflexio."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from claude_smart import cs_cite, ids, publish, runtime, state
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Stop fires immediately after Claude Code logs the final assistant message,
|
|
17
|
+
# and at tight gaps (<~10 ms observed) the transcript write hasn't propagated
|
|
18
|
+
# before our read. Retry briefly when the scan returns empty — note the cost
|
|
19
|
+
# is paid on *every* tool-only turn too (we can't tell those apart from a
|
|
20
|
+
# flush race), so keep the total budget small: 100 ms worst case.
|
|
21
|
+
_TRANSCRIPT_RETRY_DELAYS_S = (0.03, 0.07)
|
|
22
|
+
|
|
23
|
+
# Plan-mode approve/reject flows never fire a hook — Claude Code writes the
|
|
24
|
+
# decision as a ``user`` / ``tool_result`` transcript entry whose text begins
|
|
25
|
+
# with one of these markers. Surface them as synthetic User turns so reflexio
|
|
26
|
+
# sees the correction signal (especially rejection-with-comment feedback).
|
|
27
|
+
_PLAN_APPROVAL_MARKER = "User has approved your plan"
|
|
28
|
+
_PLAN_REJECTION_MARKER = "The user doesn't want to proceed"
|
|
29
|
+
_PLAN_REJECTION_COMMENT_MARKER = "the user said:"
|
|
30
|
+
|
|
31
|
+
_EXIT_PLAN_MODE_TOOL = "ExitPlanMode"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_transcript_entries(path: Path) -> list[dict[str, Any]]:
|
|
35
|
+
"""Parse the transcript JSONL once into a list of entries.
|
|
36
|
+
|
|
37
|
+
Stop's three scanners (assistant text, cs-cite ids, plan decisions) all
|
|
38
|
+
need the same parsed view; reading once and passing the list around keeps
|
|
39
|
+
the hook's wall-clock cost to a single ``read_text`` per fire even on
|
|
40
|
+
multi-megabyte transcripts.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
path (Path): Absolute path to the transcript JSONL.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
list[dict[str, Any]]: Parsed entries in chronological order. Empty
|
|
47
|
+
on read failure; malformed lines are silently skipped.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
51
|
+
except OSError as exc:
|
|
52
|
+
_LOGGER.debug("transcript read failed: %s", exc)
|
|
53
|
+
return []
|
|
54
|
+
entries: list[dict[str, Any]] = []
|
|
55
|
+
for raw in lines:
|
|
56
|
+
candidate = raw.strip()
|
|
57
|
+
if not candidate:
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
entries.append(json.loads(candidate))
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
continue
|
|
63
|
+
return entries
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _load_transcript_with_retry(path: Path) -> list[dict[str, Any]]:
|
|
67
|
+
"""Read the transcript, retrying briefly when the assistant text is empty.
|
|
68
|
+
|
|
69
|
+
Stop fires immediately after Claude Code logs the final assistant
|
|
70
|
+
message; at tight gaps the transcript write hasn't propagated. Reread
|
|
71
|
+
a couple of times if the current-turn assistant text comes back empty.
|
|
72
|
+
Total worst-case wait is ~100ms.
|
|
73
|
+
"""
|
|
74
|
+
entries = _read_transcript_entries(path)
|
|
75
|
+
if _scan_transcript_for_assistant_text(entries):
|
|
76
|
+
return entries
|
|
77
|
+
for delay in _TRANSCRIPT_RETRY_DELAYS_S:
|
|
78
|
+
time.sleep(delay)
|
|
79
|
+
entries = _read_transcript_entries(path)
|
|
80
|
+
if _scan_transcript_for_assistant_text(entries):
|
|
81
|
+
return entries
|
|
82
|
+
return entries
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _current_turn_assistant_entries(
|
|
86
|
+
entries: list[dict[str, Any]],
|
|
87
|
+
) -> list[dict[str, Any]]:
|
|
88
|
+
"""Return assistant entries for the in-progress turn, oldest first.
|
|
89
|
+
|
|
90
|
+
Walks ``entries`` from the end backward, collecting every
|
|
91
|
+
``type: assistant`` entry until a real user message is reached (a user
|
|
92
|
+
entry whose content is not purely ``tool_result`` blocks — those continue
|
|
93
|
+
the assistant turn). Restores chronological order before returning.
|
|
94
|
+
"""
|
|
95
|
+
out: list[dict[str, Any]] = []
|
|
96
|
+
for entry in reversed(entries):
|
|
97
|
+
entry_type = entry.get("type")
|
|
98
|
+
if entry_type == "assistant":
|
|
99
|
+
out.append(entry)
|
|
100
|
+
elif _is_user_turn_boundary(entry):
|
|
101
|
+
break
|
|
102
|
+
out.reverse()
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _scan_transcript_for_assistant_text(entries: list[dict[str, Any]]) -> str:
|
|
107
|
+
"""Join every text block from the current-turn assistant entries."""
|
|
108
|
+
parts: list[str] = []
|
|
109
|
+
for entry in _current_turn_assistant_entries(entries):
|
|
110
|
+
message = entry.get("message") or {}
|
|
111
|
+
parts.extend(_extract_text_blocks(message.get("content")))
|
|
112
|
+
return "\n\n".join(parts)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_user_turn_boundary(entry: dict[str, Any]) -> bool:
|
|
116
|
+
"""True if ``entry`` is the user message that opened the current turn.
|
|
117
|
+
|
|
118
|
+
Tool results are delivered as ``type: user`` entries whose content is a
|
|
119
|
+
list of ``tool_result`` blocks — those continue the assistant turn and
|
|
120
|
+
must not be treated as a boundary. A real user message has string
|
|
121
|
+
content or contains at least one non-``tool_result`` block.
|
|
122
|
+
"""
|
|
123
|
+
if entry.get("type") != "user":
|
|
124
|
+
return False
|
|
125
|
+
message = entry.get("message") or {}
|
|
126
|
+
content = message.get("content")
|
|
127
|
+
if isinstance(content, str):
|
|
128
|
+
return True
|
|
129
|
+
if isinstance(content, list):
|
|
130
|
+
return any(
|
|
131
|
+
isinstance(block, dict) and block.get("type") != "tool_result"
|
|
132
|
+
for block in content
|
|
133
|
+
)
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _extract_text_blocks(content: Any) -> list[str]:
|
|
138
|
+
"""Return assistant-visible text from a transcript content list.
|
|
139
|
+
|
|
140
|
+
Picks up plain ``type: "text"`` blocks and the ``plan`` payload of
|
|
141
|
+
``ExitPlanMode`` tool_use blocks. Plan mode emits the plan as a
|
|
142
|
+
tool_use argument rather than a text block, so without the second
|
|
143
|
+
branch the plan is silently dropped from the published turn.
|
|
144
|
+
"""
|
|
145
|
+
if isinstance(content, str):
|
|
146
|
+
return [content]
|
|
147
|
+
if not isinstance(content, list):
|
|
148
|
+
return []
|
|
149
|
+
out: list[str] = []
|
|
150
|
+
for block in content:
|
|
151
|
+
if not isinstance(block, dict):
|
|
152
|
+
continue
|
|
153
|
+
btype = block.get("type")
|
|
154
|
+
if btype == "text":
|
|
155
|
+
text = block.get("text")
|
|
156
|
+
if isinstance(text, str) and text:
|
|
157
|
+
out.append(text)
|
|
158
|
+
elif btype == "tool_use" and block.get("name") == _EXIT_PLAN_MODE_TOOL:
|
|
159
|
+
plan = (block.get("input") or {}).get("plan")
|
|
160
|
+
if isinstance(plan, str) and plan:
|
|
161
|
+
out.append(f"Plan:\n{plan}")
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _scan_transcript_for_plan_decisions(entries: list[dict[str, Any]]) -> list[str]:
|
|
166
|
+
"""Return plan-mode approval/rejection content strings for the current turn.
|
|
167
|
+
|
|
168
|
+
Plan-mode decisions arrive as ``tool_result`` blocks on ``type: user``
|
|
169
|
+
transcript entries (the ExitPlanMode tool's "output"). PostToolUse runs
|
|
170
|
+
*before* the user decides, so the decision text never reaches the hook
|
|
171
|
+
payload — the transcript is the only place it exists. Walks forward from
|
|
172
|
+
the user message that opened the current turn so prior-turn decisions
|
|
173
|
+
(already published) are not re-emitted.
|
|
174
|
+
|
|
175
|
+
The walk tracks the most recent assistant ``tool_use`` name so that only
|
|
176
|
+
``tool_result`` blocks immediately following an ``ExitPlanMode`` call are
|
|
177
|
+
treated as plan decisions — guards against false positives from other
|
|
178
|
+
tools whose output happens to contain the marker text (e.g. a ``Bash``
|
|
179
|
+
that echoes "User has approved your plan").
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
entries (list[dict[str, Any]]): Pre-parsed transcript entries.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
list[str]: Human-readable content strings, e.g. ``"Approved the plan."``
|
|
186
|
+
or ``"Rejected the plan. Instead: <comment>"``, in transcript
|
|
187
|
+
order. Empty when no decisions are found.
|
|
188
|
+
"""
|
|
189
|
+
turn_start = 0
|
|
190
|
+
for idx in range(len(entries) - 1, -1, -1):
|
|
191
|
+
if _is_user_turn_boundary(entries[idx]):
|
|
192
|
+
turn_start = idx
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
decisions: list[str] = []
|
|
196
|
+
pending_tool_name: str | None = None
|
|
197
|
+
for entry in entries[turn_start:]:
|
|
198
|
+
message = entry.get("message") or {}
|
|
199
|
+
content = message.get("content")
|
|
200
|
+
if not isinstance(content, list):
|
|
201
|
+
continue
|
|
202
|
+
if entry.get("type") == "assistant":
|
|
203
|
+
for block in content:
|
|
204
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
205
|
+
pending_tool_name = block.get("name")
|
|
206
|
+
elif entry.get("type") == "user":
|
|
207
|
+
for block in content:
|
|
208
|
+
if not isinstance(block, dict) or block.get("type") != "tool_result":
|
|
209
|
+
continue
|
|
210
|
+
if pending_tool_name != _EXIT_PLAN_MODE_TOOL:
|
|
211
|
+
continue
|
|
212
|
+
text = _tool_result_text(block.get("content"))
|
|
213
|
+
decision = _parse_plan_decision(text)
|
|
214
|
+
if decision:
|
|
215
|
+
decisions.append(decision)
|
|
216
|
+
# Each tool_use → tool_result pair is consumed once.
|
|
217
|
+
pending_tool_name = None
|
|
218
|
+
return decisions
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _tool_result_text(content: Any) -> str:
|
|
222
|
+
"""Flatten a ``tool_result.content`` field into a searchable string.
|
|
223
|
+
|
|
224
|
+
Claude Code emits tool_result content as either a bare string or a list
|
|
225
|
+
of ``{type: "text", text: "…"}`` blocks depending on the tool; we accept
|
|
226
|
+
both so the plan-decision markers match regardless of shape.
|
|
227
|
+
"""
|
|
228
|
+
if isinstance(content, str):
|
|
229
|
+
return content
|
|
230
|
+
if not isinstance(content, list):
|
|
231
|
+
return ""
|
|
232
|
+
parts: list[str] = []
|
|
233
|
+
for item in content:
|
|
234
|
+
if isinstance(item, str):
|
|
235
|
+
parts.append(item)
|
|
236
|
+
elif isinstance(item, dict):
|
|
237
|
+
inner = item.get("text") or item.get("content")
|
|
238
|
+
if isinstance(inner, str):
|
|
239
|
+
parts.append(inner)
|
|
240
|
+
return "\n".join(parts)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _parse_plan_decision(text: str) -> str | None:
|
|
244
|
+
"""Map a plan-mode tool_result text to a User-record content string."""
|
|
245
|
+
if not text:
|
|
246
|
+
return None
|
|
247
|
+
if _PLAN_APPROVAL_MARKER in text:
|
|
248
|
+
return "Approved the plan."
|
|
249
|
+
if _PLAN_REJECTION_MARKER in text:
|
|
250
|
+
_, sep, tail = text.partition(_PLAN_REJECTION_COMMENT_MARKER)
|
|
251
|
+
comment = tail.strip() if sep else ""
|
|
252
|
+
return (
|
|
253
|
+
f"Rejected the plan. Instead: {comment}"
|
|
254
|
+
if comment
|
|
255
|
+
else "Rejected the plan."
|
|
256
|
+
)
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _scan_transcript_for_cs_cite_ids(entries: list[dict[str, Any]]) -> list[str]:
|
|
261
|
+
"""Scan the current assistant turn for ``cs-cite`` Bash tool_use calls.
|
|
262
|
+
|
|
263
|
+
Collects citation ids from every matching Bash ``tool_use`` block in
|
|
264
|
+
the current turn. Multiple calls are merged; order follows Claude's
|
|
265
|
+
emission order (earliest first).
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
entries (list[dict[str, Any]]): Pre-parsed transcript entries.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
list[str]: Rank ids (e.g. ``"s1-ab12"``, ``"p2-cd34"``) in
|
|
272
|
+
emission order. Empty when no ``cs-cite`` call is found.
|
|
273
|
+
"""
|
|
274
|
+
out: list[str] = []
|
|
275
|
+
for entry in _current_turn_assistant_entries(entries):
|
|
276
|
+
message = entry.get("message") or {}
|
|
277
|
+
out.extend(_extract_cs_cite_ids(message.get("content")))
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _extract_cs_cite_ids(content: Any) -> list[str]:
|
|
282
|
+
"""Return citation ids from all Bash ``cs-cite`` tool_use blocks in ``content``."""
|
|
283
|
+
if not isinstance(content, list):
|
|
284
|
+
return []
|
|
285
|
+
out: list[str] = []
|
|
286
|
+
for block in content:
|
|
287
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
288
|
+
continue
|
|
289
|
+
if block.get("name") != "Bash":
|
|
290
|
+
continue
|
|
291
|
+
tool_input = block.get("input") or {}
|
|
292
|
+
command = tool_input.get("command")
|
|
293
|
+
if not isinstance(command, str):
|
|
294
|
+
continue
|
|
295
|
+
out.extend(cs_cite.parse_citation_command(command))
|
|
296
|
+
return out
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _resolve_cited_items(session_id: str, cited_ids: list[str]) -> list[dict[str, Any]]:
|
|
300
|
+
"""Map citation ids to ``{id, kind, title}`` entries via the session registry.
|
|
301
|
+
|
|
302
|
+
Unknown ids (Claude hallucinations, or items injected in a newer
|
|
303
|
+
session than this hook can see) are dropped. Duplicate ids within
|
|
304
|
+
one turn collapse to a single entry — the user-facing badge row
|
|
305
|
+
doesn't need the multiplicity.
|
|
306
|
+
"""
|
|
307
|
+
if not cited_ids:
|
|
308
|
+
return []
|
|
309
|
+
registry = state.read_injected(session_id)
|
|
310
|
+
seen: set[str] = set()
|
|
311
|
+
resolved: list[dict[str, Any]] = []
|
|
312
|
+
for cid in cited_ids:
|
|
313
|
+
if cid in seen:
|
|
314
|
+
continue
|
|
315
|
+
entry = registry.get(cid)
|
|
316
|
+
if not entry:
|
|
317
|
+
continue
|
|
318
|
+
seen.add(cid)
|
|
319
|
+
item: dict[str, Any] = {
|
|
320
|
+
"id": entry.get("id", cid),
|
|
321
|
+
"kind": entry.get("kind", ""),
|
|
322
|
+
"title": entry.get("title", ""),
|
|
323
|
+
}
|
|
324
|
+
real_id = entry.get("real_id")
|
|
325
|
+
if real_id:
|
|
326
|
+
item["real_id"] = real_id
|
|
327
|
+
source_kind = entry.get("source_kind")
|
|
328
|
+
if isinstance(source_kind, str) and source_kind:
|
|
329
|
+
item["source_kind"] = source_kind
|
|
330
|
+
resolved.append(item)
|
|
331
|
+
return resolved
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def handle(payload: dict[str, Any]) -> None:
|
|
335
|
+
session_id = payload.get("session_id")
|
|
336
|
+
if not session_id:
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
# Always append an Assistant record, even when the turn emitted only
|
|
340
|
+
# tool calls and no text. ``state.unpublished_slice`` folds any
|
|
341
|
+
# buffered ``Assistant_tool`` records into this turn's ``tools_used``;
|
|
342
|
+
# without this placeholder, those tools would be misattributed to the
|
|
343
|
+
# next assistant turn.
|
|
344
|
+
transcript_path = payload.get("transcript_path")
|
|
345
|
+
project_id = ids.resolve_project_id(payload.get("cwd"))
|
|
346
|
+
|
|
347
|
+
entries: list[dict[str, Any]] = []
|
|
348
|
+
if transcript_path:
|
|
349
|
+
path = Path(transcript_path)
|
|
350
|
+
if path.is_file():
|
|
351
|
+
entries = _load_transcript_with_retry(path)
|
|
352
|
+
|
|
353
|
+
last_assistant_message = payload.get("last_assistant_message")
|
|
354
|
+
assistant_text = (
|
|
355
|
+
last_assistant_message
|
|
356
|
+
if runtime.is_codex()
|
|
357
|
+
and isinstance(last_assistant_message, str)
|
|
358
|
+
and last_assistant_message
|
|
359
|
+
else _scan_transcript_for_assistant_text(entries)
|
|
360
|
+
)
|
|
361
|
+
transcript_cited_ids = _scan_transcript_for_cs_cite_ids(entries)
|
|
362
|
+
text_cited_ids = cs_cite.parse_text_citations(assistant_text)
|
|
363
|
+
cited_ids = [*text_cited_ids, *transcript_cited_ids]
|
|
364
|
+
cited_items = _resolve_cited_items(session_id, cited_ids)
|
|
365
|
+
plan_decisions = _scan_transcript_for_plan_decisions(entries)
|
|
366
|
+
|
|
367
|
+
now = int(time.time())
|
|
368
|
+
for decision_text in plan_decisions:
|
|
369
|
+
state.append(
|
|
370
|
+
session_id,
|
|
371
|
+
{
|
|
372
|
+
"ts": now,
|
|
373
|
+
"role": "User",
|
|
374
|
+
"content": decision_text,
|
|
375
|
+
"user_id": project_id,
|
|
376
|
+
},
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
record: dict[str, Any] = {
|
|
380
|
+
"ts": now,
|
|
381
|
+
"role": "Assistant",
|
|
382
|
+
"content": assistant_text,
|
|
383
|
+
"user_id": project_id,
|
|
384
|
+
}
|
|
385
|
+
if cited_items:
|
|
386
|
+
record["cited_items"] = cited_items
|
|
387
|
+
state.append(session_id, record)
|
|
388
|
+
publish.publish_unpublished(
|
|
389
|
+
session_id=session_id,
|
|
390
|
+
project_id=project_id,
|
|
391
|
+
force_extraction=False,
|
|
392
|
+
skip_aggregation=False,
|
|
393
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""UserPromptSubmit hook — buffer the user turn and inject matching context.
|
|
2
|
+
|
|
3
|
+
Two responsibilities, in order:
|
|
4
|
+
|
|
5
|
+
1. Buffer the prompt into the session JSONL (this is the sole source of
|
|
6
|
+
"User" role turns downstream — Claude Code replays the rest of the
|
|
7
|
+
transcript via tool events, not UserPromptSubmit).
|
|
8
|
+
2. Use the prompt text as a search query against reflexio's preferences +
|
|
9
|
+
skills and emit the top hits as ``hookSpecificOutput.additionalContext``
|
|
10
|
+
so Claude sees relevant rules before planning the response.
|
|
11
|
+
|
|
12
|
+
The PreToolUse hook does similar retrieval keyed to tool-call text; this
|
|
13
|
+
hook covers the gap where a prompt-only turn (e.g. a question answered
|
|
14
|
+
from context without edits) never fires PreToolUse and so would otherwise
|
|
15
|
+
see no injected context at all. The shared pipeline lives in
|
|
16
|
+
``context_inject.emit_context``.
|
|
17
|
+
|
|
18
|
+
Retrieval is best-effort: any failure from search (reflexio unreachable,
|
|
19
|
+
HTTP timeout, unexpected shape) is caught so the buffered-prompt
|
|
20
|
+
behaviour is always preserved. PreToolUse does not wrap — a tool-call
|
|
21
|
+
injection failure is invisible to the user, whereas a failed user-turn
|
|
22
|
+
would silently lose the prompt.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import time
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from claude_smart import context_inject, ids, state
|
|
32
|
+
|
|
33
|
+
_LOGGER = logging.getLogger(__name__)
|
|
34
|
+
_TOP_K = 3
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def handle(payload: dict[str, Any]) -> None:
|
|
38
|
+
"""UserPromptSubmit dispatcher — buffers the prompt, then injects context.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
payload (dict[str, Any]): The Claude Code hook payload. Expected keys
|
|
42
|
+
``session_id``, ``prompt``, and optionally ``cwd``.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
None: Side effects only — appends to the session buffer and may
|
|
46
|
+
write a ``hookSpecificOutput`` JSON document to stdout.
|
|
47
|
+
"""
|
|
48
|
+
session_id = payload.get("session_id")
|
|
49
|
+
prompt = payload.get("prompt") or ""
|
|
50
|
+
if not session_id or not prompt:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
project_id = ids.resolve_project_id(payload.get("cwd"))
|
|
54
|
+
state.append(
|
|
55
|
+
session_id,
|
|
56
|
+
{
|
|
57
|
+
"ts": int(time.time()),
|
|
58
|
+
"role": "User",
|
|
59
|
+
"content": prompt,
|
|
60
|
+
"user_id": project_id,
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
context_inject.emit_context(
|
|
66
|
+
session_id=session_id,
|
|
67
|
+
project_id=project_id,
|
|
68
|
+
query=prompt,
|
|
69
|
+
hook_event_name="UserPromptSubmit",
|
|
70
|
+
top_k=_TOP_K,
|
|
71
|
+
)
|
|
72
|
+
except Exception as exc: # noqa: BLE001 — never break the user's turn
|
|
73
|
+
_LOGGER.debug("user_prompt context inject failed: %s", exc)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Dispatch table for claude-smart hook events.
|
|
2
|
+
|
|
3
|
+
The plugin's ``hook_entry.sh`` calls either
|
|
4
|
+
``python -m claude_smart.hook <event>`` (legacy Claude Code shape) or
|
|
5
|
+
``python -m claude_smart.hook <host> <event>`` once per hook invocation.
|
|
6
|
+
This module reads the hook JSON from stdin, routes to the matching handler,
|
|
7
|
+
and makes sure no unhandled exception ever propagates.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
|
|
17
|
+
from claude_smart import runtime
|
|
18
|
+
from claude_smart.internal_call import is_internal_invocation
|
|
19
|
+
|
|
20
|
+
_LOGGER = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_handlers() -> dict[str, Callable[[dict[str, Any]], None]]:
|
|
24
|
+
from claude_smart.events import (
|
|
25
|
+
post_tool,
|
|
26
|
+
pre_tool,
|
|
27
|
+
session_end,
|
|
28
|
+
session_start,
|
|
29
|
+
stop,
|
|
30
|
+
user_prompt,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
"session-start": session_start.handle,
|
|
35
|
+
"user-prompt": user_prompt.handle,
|
|
36
|
+
"pre-tool": pre_tool.handle,
|
|
37
|
+
"post-tool": post_tool.handle,
|
|
38
|
+
"stop": stop.handle,
|
|
39
|
+
"session-end": session_end.handle,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_stdin_json() -> dict[str, Any]:
|
|
44
|
+
"""Parse stdin as JSON. Returns {} on empty or malformed input."""
|
|
45
|
+
try:
|
|
46
|
+
raw = sys.stdin.read()
|
|
47
|
+
except Exception as exc: # noqa: BLE001
|
|
48
|
+
_LOGGER.debug("stdin read failed: %s", exc)
|
|
49
|
+
return {}
|
|
50
|
+
if not raw.strip():
|
|
51
|
+
return {}
|
|
52
|
+
try:
|
|
53
|
+
parsed = json.loads(raw)
|
|
54
|
+
except json.JSONDecodeError as exc:
|
|
55
|
+
_LOGGER.debug("stdin JSON decode failed: %s", exc)
|
|
56
|
+
return {}
|
|
57
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def emit_continue() -> None:
|
|
61
|
+
"""Fallback stdout — tells Claude Code to keep going without injection."""
|
|
62
|
+
payload = {"continue": True}
|
|
63
|
+
if not runtime.is_codex():
|
|
64
|
+
payload["suppressOutput"] = True
|
|
65
|
+
sys.stdout.write(json.dumps(payload))
|
|
66
|
+
sys.stdout.write("\n")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_args(argv: list[str]) -> tuple[str, str]:
|
|
70
|
+
"""Return ``(host, event)`` for old and new hook argv shapes."""
|
|
71
|
+
if not argv:
|
|
72
|
+
return runtime.HOST_CLAUDE_CODE, ""
|
|
73
|
+
if len(argv) >= 2 and argv[0] in runtime.VALID_HOSTS:
|
|
74
|
+
return argv[0], argv[1]
|
|
75
|
+
return runtime.HOST_CLAUDE_CODE, argv[0]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main(argv: list[str] | None = None) -> int:
|
|
79
|
+
"""Entry point used by ``python -m claude_smart.hook`` and the console script."""
|
|
80
|
+
argv = argv if argv is not None else sys.argv[1:]
|
|
81
|
+
host, event = _parse_args(argv)
|
|
82
|
+
runtime.set_host(host)
|
|
83
|
+
if not event:
|
|
84
|
+
_LOGGER.warning("hook dispatcher called with no event name")
|
|
85
|
+
emit_continue()
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
payload = _read_stdin_json()
|
|
89
|
+
|
|
90
|
+
# Self-feedback guard: when this hook fires inside reflexio's own
|
|
91
|
+
# `claude -p` subprocess (the claude-code LLM provider), skip all
|
|
92
|
+
# handlers so we don't publish the extractor's system prompt back
|
|
93
|
+
# into reflexio. See claude_smart.internal_call for detection logic.
|
|
94
|
+
if is_internal_invocation(payload):
|
|
95
|
+
emit_continue()
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
handlers = _load_handlers()
|
|
99
|
+
handler = handlers.get(event)
|
|
100
|
+
if handler is None:
|
|
101
|
+
_LOGGER.warning("unknown hook event: %s", event)
|
|
102
|
+
emit_continue()
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
handler(payload)
|
|
107
|
+
except Exception as exc: # noqa: BLE001 — hooks must never crash the session.
|
|
108
|
+
_LOGGER.exception("hook handler %s raised: %s", event, exc)
|
|
109
|
+
emit_continue()
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Resolve stable identifiers for Claude Code sessions.
|
|
2
|
+
|
|
3
|
+
Two identifiers matter to reflexio:
|
|
4
|
+
|
|
5
|
+
- ``session_id``: Claude Code's per-session id, passed in hook stdin. We
|
|
6
|
+
forward it to reflexio's interaction ``session_id`` field so individual
|
|
7
|
+
turns remain attributable to their conversation, but it is no longer
|
|
8
|
+
the scope key for extracted preferences.
|
|
9
|
+
- ``project_id``: a stable, cross-session name for the project. We use
|
|
10
|
+
this as reflexio's ``user_id`` for preferences, so user preferences
|
|
11
|
+
extracted in one session are visible to every later session in the
|
|
12
|
+
same repo. ``agent_version`` is hardcoded to ``"claude-code"`` in the
|
|
13
|
+
adapter so shared skills roll up globally across all projects rather than
|
|
14
|
+
per project.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import subprocess # noqa: S404 — git invocation with a fixed flag set.
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
_LOGGER = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_project_id(cwd: str | os.PathLike[str] | None = None) -> str:
|
|
28
|
+
"""Return a stable project identifier for the given working directory.
|
|
29
|
+
|
|
30
|
+
Prefers the basename of the git toplevel (so worktrees, submodules, and
|
|
31
|
+
`cd src/` all still map to the same project). Falls back to the cwd
|
|
32
|
+
basename when the directory is not inside a git repo.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
cwd: Working directory to resolve. Defaults to ``os.getcwd()``.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
str: A non-empty identifier. Never raises.
|
|
39
|
+
"""
|
|
40
|
+
base = Path(cwd) if cwd is not None else Path.cwd()
|
|
41
|
+
try:
|
|
42
|
+
result = subprocess.run( # noqa: S603, S607 — fixed argv, cwd is a Path.
|
|
43
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
44
|
+
cwd=base,
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
check=False,
|
|
48
|
+
timeout=5,
|
|
49
|
+
)
|
|
50
|
+
if result.returncode == 0:
|
|
51
|
+
toplevel = result.stdout.strip()
|
|
52
|
+
if toplevel:
|
|
53
|
+
return Path(toplevel).name
|
|
54
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
|
55
|
+
_LOGGER.debug("git toplevel resolution failed: %s", exc)
|
|
56
|
+
return base.name or "unknown-project"
|