claude-smart 0.2.22 → 0.2.24
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 +69 -27
- package/bin/claude-smart.js +296 -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 +1273 -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,277 @@
|
|
|
1
|
+
"""Render reflexio preferences + skills as markdown for display or injection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Iterable
|
|
6
|
+
|
|
7
|
+
from claude_smart import cs_cite
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _first_nonempty(*values: Any) -> str:
|
|
11
|
+
"""Return the first truthy string value, or an empty string."""
|
|
12
|
+
for v in values:
|
|
13
|
+
if isinstance(v, str) and v.strip():
|
|
14
|
+
return v.strip()
|
|
15
|
+
return ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render(
|
|
19
|
+
*,
|
|
20
|
+
project_id: str,
|
|
21
|
+
user_playbooks: Iterable[Any],
|
|
22
|
+
agent_playbooks: Iterable[Any],
|
|
23
|
+
profiles: Iterable[Any],
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Render skills + preferences as full audit markdown.
|
|
26
|
+
|
|
27
|
+
Empty sections are omitted. When all sections are empty, returns "".
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
project_id (str): Displayed in the header so the user can tell
|
|
31
|
+
which project is in effect.
|
|
32
|
+
user_playbooks (Iterable[Any]): Skill records scoped to this project
|
|
33
|
+
(``UserPlaybook`` objects or dicts with the same fields).
|
|
34
|
+
agent_playbooks (Iterable[Any]): Skill records shared across projects
|
|
35
|
+
(``AgentPlaybook`` objects or dicts with the same fields).
|
|
36
|
+
profiles (Iterable[Any]): Iterable of preference records
|
|
37
|
+
(``UserProfile`` objects or dicts).
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
str: Markdown, or "" when there is nothing to show.
|
|
41
|
+
"""
|
|
42
|
+
markdown, _ = render_with_registry(
|
|
43
|
+
project_id=project_id,
|
|
44
|
+
user_playbooks=user_playbooks,
|
|
45
|
+
agent_playbooks=agent_playbooks,
|
|
46
|
+
profiles=profiles,
|
|
47
|
+
)
|
|
48
|
+
return markdown
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render_with_registry(
|
|
52
|
+
*,
|
|
53
|
+
project_id: str,
|
|
54
|
+
user_playbooks: Iterable[Any],
|
|
55
|
+
agent_playbooks: Iterable[Any],
|
|
56
|
+
profiles: Iterable[Any],
|
|
57
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
58
|
+
"""Variant of ``render`` that also returns the citation registry.
|
|
59
|
+
|
|
60
|
+
Every skill and preference bullet is tagged with a short ``[cs:ID]``
|
|
61
|
+
prefix. The registry maps those ids back to ``{id, kind, title,
|
|
62
|
+
content}`` entries so ``events.stop`` can resolve citations into
|
|
63
|
+
human-readable titles for the dashboard.
|
|
64
|
+
|
|
65
|
+
Agent playbooks (cross-project, distilled) are listed before user
|
|
66
|
+
playbooks (this project's lessons) under one ``### Project-specific
|
|
67
|
+
skills`` heading. The model doesn't need to reason about the split.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
project_id (str): Displayed in the header so the user can tell
|
|
71
|
+
which project is in effect.
|
|
72
|
+
user_playbooks (Iterable[Any]): Skill records scoped to this project.
|
|
73
|
+
agent_playbooks (Iterable[Any]): Skill records shared across projects.
|
|
74
|
+
profiles (Iterable[Any]): Iterable of preference records
|
|
75
|
+
(``UserProfile`` objects or dicts).
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
tuple[str, list[dict[str, Any]]]: ``(markdown, registry_entries)``.
|
|
79
|
+
When all input lists are empty the markdown is ``""`` and the
|
|
80
|
+
registry is ``[]``.
|
|
81
|
+
"""
|
|
82
|
+
playbook_lines, playbook_entries = _format_combined_playbooks(
|
|
83
|
+
agent_playbooks=agent_playbooks, user_playbooks=user_playbooks
|
|
84
|
+
)
|
|
85
|
+
profile_lines, profile_entries = _format_profiles(profiles)
|
|
86
|
+
if not playbook_lines and not profile_lines:
|
|
87
|
+
return "", []
|
|
88
|
+
|
|
89
|
+
sections: list[str] = [f"## claude-smart — project `{project_id}`"]
|
|
90
|
+
if playbook_lines:
|
|
91
|
+
sections.append("### Project-specific skills")
|
|
92
|
+
sections.extend(playbook_lines)
|
|
93
|
+
if profile_lines:
|
|
94
|
+
sections.append("### Project preferences")
|
|
95
|
+
sections.extend(profile_lines)
|
|
96
|
+
sections.append(cs_cite.CITATION_INSTRUCTION)
|
|
97
|
+
return "\n".join(sections) + "\n", playbook_entries + profile_entries
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def render_inline(
|
|
101
|
+
*,
|
|
102
|
+
project_id: str,
|
|
103
|
+
user_playbooks: Iterable[Any],
|
|
104
|
+
agent_playbooks: Iterable[Any],
|
|
105
|
+
profiles: Iterable[Any],
|
|
106
|
+
) -> str:
|
|
107
|
+
"""Render skills + preferences for mid-session injection.
|
|
108
|
+
|
|
109
|
+
Same bullet format as ``render`` but with no top-level project header.
|
|
110
|
+
This block is injected just-in-time alongside an in-flight user prompt or
|
|
111
|
+
tool call, so the caller already has project context.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
project_id (str): Reserved for future use; currently unused.
|
|
115
|
+
user_playbooks (Iterable[Any]): Relevance-ranked project-scoped hits.
|
|
116
|
+
agent_playbooks (Iterable[Any]): Relevance-ranked global hits.
|
|
117
|
+
profiles (Iterable[Any]): Relevance-ranked preference hits.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
str: Markdown with ``### Relevant project-specific skills`` and/or
|
|
121
|
+
``### Relevant project preferences`` sub-sections, or ``""``
|
|
122
|
+
when all inputs are empty.
|
|
123
|
+
"""
|
|
124
|
+
markdown, _ = render_inline_with_registry(
|
|
125
|
+
project_id=project_id,
|
|
126
|
+
user_playbooks=user_playbooks,
|
|
127
|
+
agent_playbooks=agent_playbooks,
|
|
128
|
+
profiles=profiles,
|
|
129
|
+
)
|
|
130
|
+
return markdown
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def render_inline_with_registry(
|
|
134
|
+
*,
|
|
135
|
+
project_id: str,
|
|
136
|
+
user_playbooks: Iterable[Any],
|
|
137
|
+
agent_playbooks: Iterable[Any],
|
|
138
|
+
profiles: Iterable[Any],
|
|
139
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
140
|
+
"""Variant of ``render_inline`` that also returns the citation registry.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
project_id (str): Reserved for future use; currently unused.
|
|
144
|
+
user_playbooks (Iterable[Any]): Relevance-ranked project-scoped hits.
|
|
145
|
+
agent_playbooks (Iterable[Any]): Relevance-ranked global hits.
|
|
146
|
+
profiles (Iterable[Any]): Relevance-ranked preference hits.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
tuple[str, list[dict[str, Any]]]: ``(markdown, registry_entries)``.
|
|
150
|
+
When all input lists are empty the markdown is ``""`` and the
|
|
151
|
+
registry is ``[]``.
|
|
152
|
+
"""
|
|
153
|
+
del project_id # kept for symmetry with ``render_with_registry``.
|
|
154
|
+
playbook_lines, playbook_entries = _format_combined_playbooks(
|
|
155
|
+
agent_playbooks=agent_playbooks, user_playbooks=user_playbooks
|
|
156
|
+
)
|
|
157
|
+
profile_lines, profile_entries = _format_profiles(profiles)
|
|
158
|
+
if not playbook_lines and not profile_lines:
|
|
159
|
+
return "", []
|
|
160
|
+
sections: list[str] = []
|
|
161
|
+
if playbook_lines:
|
|
162
|
+
sections.append("### Relevant project-specific skills")
|
|
163
|
+
sections.extend(playbook_lines)
|
|
164
|
+
if profile_lines:
|
|
165
|
+
sections.append("### Relevant project preferences")
|
|
166
|
+
sections.extend(profile_lines)
|
|
167
|
+
sections.append(cs_cite.CITATION_INSTRUCTION)
|
|
168
|
+
return "\n".join(sections) + "\n", playbook_entries + profile_entries
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _format_combined_playbooks(
|
|
172
|
+
*,
|
|
173
|
+
agent_playbooks: Iterable[Any],
|
|
174
|
+
user_playbooks: Iterable[Any],
|
|
175
|
+
) -> tuple[list[str], list[dict[str, Any]]]:
|
|
176
|
+
"""Render agent playbooks first, then user playbooks, with one shared rank counter."""
|
|
177
|
+
lines: list[str] = []
|
|
178
|
+
entries: list[dict[str, Any]] = []
|
|
179
|
+
rank = 0
|
|
180
|
+
for pb in agent_playbooks:
|
|
181
|
+
rank = _append_playbook_bullet(
|
|
182
|
+
pb, "agent_playbook_id", "agent_playbook", rank, lines, entries
|
|
183
|
+
)
|
|
184
|
+
for pb in user_playbooks:
|
|
185
|
+
rank = _append_playbook_bullet(
|
|
186
|
+
pb, "user_playbook_id", "user_playbook", rank, lines, entries
|
|
187
|
+
)
|
|
188
|
+
return lines, entries
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _append_playbook_bullet(
|
|
192
|
+
pb: Any,
|
|
193
|
+
id_field: str,
|
|
194
|
+
source_kind: str,
|
|
195
|
+
rank: int,
|
|
196
|
+
lines: list[str],
|
|
197
|
+
entries: list[dict[str, Any]],
|
|
198
|
+
) -> int:
|
|
199
|
+
content = _first_nonempty(_field(pb, "content"))
|
|
200
|
+
if not content:
|
|
201
|
+
return rank
|
|
202
|
+
rank += 1
|
|
203
|
+
trigger = _first_nonempty(_field(pb, "trigger"))
|
|
204
|
+
rationale = _first_nonempty(_field(pb, "rationale"))
|
|
205
|
+
real_id = _field(pb, id_field)
|
|
206
|
+
item_id = cs_cite.rank_id("playbook", rank, real_id)
|
|
207
|
+
title = _title_from_content(content)
|
|
208
|
+
bullet = f"- [cs:{item_id}] {content}"
|
|
209
|
+
if trigger:
|
|
210
|
+
bullet += f" _(when: {trigger})_"
|
|
211
|
+
if rationale:
|
|
212
|
+
bullet += f" — *why:* {rationale}"
|
|
213
|
+
lines.append(bullet)
|
|
214
|
+
entries.append(
|
|
215
|
+
{
|
|
216
|
+
"id": item_id,
|
|
217
|
+
"kind": "playbook",
|
|
218
|
+
"title": title,
|
|
219
|
+
"content": content,
|
|
220
|
+
"real_id": str(real_id) if real_id is not None else None,
|
|
221
|
+
"source_kind": source_kind,
|
|
222
|
+
}
|
|
223
|
+
)
|
|
224
|
+
return rank
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _format_profiles(
|
|
228
|
+
profiles: Iterable[Any],
|
|
229
|
+
) -> tuple[list[str], list[dict[str, Any]]]:
|
|
230
|
+
lines: list[str] = []
|
|
231
|
+
entries: list[dict[str, Any]] = []
|
|
232
|
+
rank = 0
|
|
233
|
+
for p in profiles:
|
|
234
|
+
content = _first_nonempty(_field(p, "content"))
|
|
235
|
+
if not content:
|
|
236
|
+
continue
|
|
237
|
+
rank += 1
|
|
238
|
+
real_id = _field(p, "profile_id")
|
|
239
|
+
item_id = cs_cite.rank_id("profile", rank, real_id)
|
|
240
|
+
title = _title_from_content(content)
|
|
241
|
+
lines.append(f"- [cs:{item_id}] {content}")
|
|
242
|
+
entries.append(
|
|
243
|
+
{
|
|
244
|
+
"id": item_id,
|
|
245
|
+
"kind": "profile",
|
|
246
|
+
"title": title,
|
|
247
|
+
"content": content,
|
|
248
|
+
"real_id": str(real_id) if real_id is not None else None,
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
return lines, entries
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _title_from_content(content: str, limit: int = 80) -> str:
|
|
255
|
+
"""Derive a compact human-readable title from a bullet's content.
|
|
256
|
+
|
|
257
|
+
Truncates at the first sentence boundary when one falls within the
|
|
258
|
+
character limit; otherwise hard-trims with an ellipsis. Used only for
|
|
259
|
+
dashboard display.
|
|
260
|
+
"""
|
|
261
|
+
text = content.strip()
|
|
262
|
+
if not text:
|
|
263
|
+
return ""
|
|
264
|
+
for terminator in (". ", "\n"):
|
|
265
|
+
idx = text.find(terminator)
|
|
266
|
+
if 0 < idx <= limit:
|
|
267
|
+
return text[:idx].rstrip()
|
|
268
|
+
if len(text) <= limit:
|
|
269
|
+
return text
|
|
270
|
+
return text[: limit - 1].rstrip() + "…"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _field(obj: Any, name: str) -> Any:
|
|
274
|
+
"""Read ``name`` from either an attribute or a dict key."""
|
|
275
|
+
if isinstance(obj, dict):
|
|
276
|
+
return obj.get(name)
|
|
277
|
+
return getattr(obj, name, None)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Shared "search reflexio, render markdown, emit hookSpecificOutput" pipeline.
|
|
2
|
+
|
|
3
|
+
PreToolUse and UserPromptSubmit both (a) run a query-aware reflexio
|
|
4
|
+
search, (b) render the hits with ``context_format.render_inline_with_registry``,
|
|
5
|
+
(c) persist the citation registry for the Stop hook to resolve, and
|
|
6
|
+
(d) emit a Claude Code ``hookSpecificOutput.additionalContext`` envelope
|
|
7
|
+
on stdout. This module owns that shared pipeline so the two hook
|
|
8
|
+
handlers keep exactly one source of truth for the injection contract —
|
|
9
|
+
the envelope shape, the registry schema, and the ordering of
|
|
10
|
+
``ensure_installed`` / ``append_injected``.
|
|
11
|
+
|
|
12
|
+
The caller remains responsible for handler-specific framing (PreToolUse
|
|
13
|
+
needs ``hook.emit_continue()`` on the empty path; UserPromptSubmit wraps
|
|
14
|
+
the search in ``try/except`` so a failed reflexio never breaks a user's
|
|
15
|
+
turn) — see the two call sites for the small policy differences.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
from claude_smart import context_format, cs_cite, state
|
|
25
|
+
from claude_smart.reflexio_adapter import Adapter
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def emit_context(
|
|
29
|
+
*,
|
|
30
|
+
session_id: str,
|
|
31
|
+
project_id: str,
|
|
32
|
+
query: str,
|
|
33
|
+
hook_event_name: str,
|
|
34
|
+
top_k: int,
|
|
35
|
+
adapter: Adapter | None = None,
|
|
36
|
+
) -> bool:
|
|
37
|
+
"""Search reflexio, render hits, emit ``additionalContext`` on stdout.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
session_id (str): Claude Code session id; used to scope the
|
|
41
|
+
per-session citation registry.
|
|
42
|
+
project_id (str): reflexio ``user_id`` for this repo.
|
|
43
|
+
query (str): Free-text query routed to reflexio's unified
|
|
44
|
+
``/api/search`` endpoint, which fans out to user playbooks
|
|
45
|
+
(project-scoped), agent playbooks (global), and preferences
|
|
46
|
+
(project-scoped) server-side.
|
|
47
|
+
hook_event_name (str): ``"PreToolUse"`` or ``"UserPromptSubmit"``;
|
|
48
|
+
echoed verbatim in the hook envelope so Claude Code attributes
|
|
49
|
+
the context to the right event.
|
|
50
|
+
top_k (int): Cap on hits per collection.
|
|
51
|
+
adapter (Adapter | None): Injection seam for tests. A fresh
|
|
52
|
+
``Adapter()`` is used when ``None``.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
bool: ``True`` when markdown was emitted to stdout; ``False``
|
|
56
|
+
when the search returned nothing to inject.
|
|
57
|
+
"""
|
|
58
|
+
user_playbooks, agent_playbooks, profiles = (adapter or Adapter()).search_all(
|
|
59
|
+
project_id=project_id,
|
|
60
|
+
query=query,
|
|
61
|
+
top_k=top_k,
|
|
62
|
+
)
|
|
63
|
+
markdown, registry = context_format.render_inline_with_registry(
|
|
64
|
+
project_id=project_id,
|
|
65
|
+
user_playbooks=user_playbooks,
|
|
66
|
+
agent_playbooks=agent_playbooks,
|
|
67
|
+
profiles=profiles,
|
|
68
|
+
)
|
|
69
|
+
if not markdown:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
cs_cite.ensure_installed()
|
|
73
|
+
state.append_injected(
|
|
74
|
+
session_id,
|
|
75
|
+
(dict(entry, ts=int(time.time())) for entry in registry),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
sys.stdout.write(
|
|
79
|
+
json.dumps(
|
|
80
|
+
{
|
|
81
|
+
"hookSpecificOutput": {
|
|
82
|
+
"hookEventName": hook_event_name,
|
|
83
|
+
"additionalContext": markdown,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
sys.stdout.write("\n")
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = ["emit_context"]
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Support helpers for claude-smart citation tracking.
|
|
2
|
+
|
|
3
|
+
Context injected by UserPromptSubmit / PreToolUse tags each skill and
|
|
4
|
+
preference bullet with a rank-based id fingerprinted by the underlying
|
|
5
|
+
real id (``[cs:s1-1a2b]`` for the first skill whose
|
|
6
|
+
``user_playbook_id`` starts with ``1a2b``, ``[cs:p2-c3d4]`` for the
|
|
7
|
+
second preference). The injected instruction asks the assistant to end
|
|
8
|
+
impactful replies with a marker like::
|
|
9
|
+
|
|
10
|
+
✨ 1 claude-smart learning applied [cs:s1-1a2b]
|
|
11
|
+
|
|
12
|
+
The Stop hook later scans the assistant text for those markers and resolves
|
|
13
|
+
the ids against a per-session registry persisted at
|
|
14
|
+
``~/.claude-smart/sessions/<session_id>.injected.jsonl``. Legacy ``cs-cite``
|
|
15
|
+
Bash tool calls are still accepted as a fallback for older instructions.
|
|
16
|
+
|
|
17
|
+
Why rank + fingerprint: rank alone resets at every injection, so a
|
|
18
|
+
later injection's ``s1`` would silently overwrite an earlier entry in
|
|
19
|
+
the append-only registry — if Claude cited ``s1`` across a turn
|
|
20
|
+
boundary, the resolver would pick the wrong skill. Appending the
|
|
21
|
+
first four alphanumeric chars of the real id makes the id stable
|
|
22
|
+
across injections in the common case (distinct real ids → distinct
|
|
23
|
+
fingerprints), so cross-injection collisions become rare.
|
|
24
|
+
|
|
25
|
+
This module holds:
|
|
26
|
+
|
|
27
|
+
- ``rank_id``: ``p{n}-{fp}`` / ``s{n}-{fp}`` tag for a given
|
|
28
|
+
(kind, rank, real_id) tuple. Fingerprint is omitted when no real id
|
|
29
|
+
is available. ``p`` is preference, ``s`` is skill.
|
|
30
|
+
- ``CITATION_CMD_RE``: regex matching a valid legacy ``cs-cite`` command line.
|
|
31
|
+
- ``ensure_installed``: idempotent copy of ``plugin/bin/cs-cite`` to
|
|
32
|
+
``~/.claude-smart/bin/cs-cite`` with the executable bit set.
|
|
33
|
+
- ``CITATION_INSTRUCTION``: the trailer text appended to injected context
|
|
34
|
+
so the assistant knows when and how to emit the citation marker.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import logging
|
|
40
|
+
import re
|
|
41
|
+
import shutil
|
|
42
|
+
import stat as stat_
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
_LOGGER = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
_THIS_DIR = Path(__file__).resolve().parent
|
|
49
|
+
_PLUGIN_ROOT = _THIS_DIR.parents[1] # plugin/src/claude_smart/ -> plugin/
|
|
50
|
+
_SOURCE_SCRIPT = _PLUGIN_ROOT / "bin" / "cs-cite"
|
|
51
|
+
_INSTALL_DIR = Path.home() / ".claude-smart" / "bin"
|
|
52
|
+
INSTALL_PATH = _INSTALL_DIR / "cs-cite"
|
|
53
|
+
|
|
54
|
+
_FINGERPRINT_LEN = 4
|
|
55
|
+
|
|
56
|
+
# Match a bare `cs-cite <ids>` invocation. Ids are rank tokens of the
|
|
57
|
+
# form `p<N>` (preference) or `s<N>` (skill) with an optional
|
|
58
|
+
# `-<fp>` fingerprint (1-4 alphanumeric chars), optionally
|
|
59
|
+
# `cs:`-prefixed (since bullets render as `[cs:p1-ab12]` and the model
|
|
60
|
+
# often copies the tag verbatim). The `(?i:...)` inline flags make the
|
|
61
|
+
# prefix, kind letter, and fingerprint case-insensitive so `CS:P1-AB12`
|
|
62
|
+
# is accepted — matching the `re.IGNORECASE` used by the standalone
|
|
63
|
+
# `cs-cite` script. Tokens may be comma- and/or whitespace-separated.
|
|
64
|
+
# Chained commands (&&, |, ;) and extra trailing tokens remain rejected
|
|
65
|
+
# by the anchored `\s*$` terminator so accidental mentions don't
|
|
66
|
+
# register as citations.
|
|
67
|
+
_ID_TOKEN = r"(?i:cs:)?(?i:[ps])\d+(?:-(?i:[a-z0-9]){1,4})?"
|
|
68
|
+
_ID_SEP = r"[,\s]+"
|
|
69
|
+
CITATION_CMD_RE = re.compile(
|
|
70
|
+
rf"^\s*(?:[^\s]*/)?cs-cite\s+({_ID_TOKEN}(?:{_ID_SEP}{_ID_TOKEN})*)\s*$"
|
|
71
|
+
)
|
|
72
|
+
_CLEAN_ID_RE = re.compile(r"^(?i:cs:)?((?i:[ps])\d+(?:-(?i:[a-z0-9]){1,4})?)$")
|
|
73
|
+
_SPLIT_RE = re.compile(_ID_SEP)
|
|
74
|
+
_TEXT_CITATION_LINE_RE = re.compile(
|
|
75
|
+
r"(?im)^\s*✨\s+\d+\s+claude-smart learning(?:s)? applied\s+"
|
|
76
|
+
r"\[cs:(?P<ids>[^\]]+)\]\s*$"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
CITATION_INSTRUCTION = (
|
|
80
|
+
"_First, fully answer the user — citation does not change what or how "
|
|
81
|
+
"you reply. Then, as a final step, consider whether to cite: if — and "
|
|
82
|
+
"only if — an injected `[cs:…]` item materially changed your reply "
|
|
83
|
+
"(different wording, action, or conclusion than you would have produced "
|
|
84
|
+
"without it), append exactly one final citation line after your answer. "
|
|
85
|
+
"Do not call `cs-cite` or any other tool for citations. Ids come verbatim "
|
|
86
|
+
"from the `[cs:…]` tags — keep the leading `p` (preference) or `s` "
|
|
87
|
+
"(skill) and the `-<fp>` suffix. Use this exact format for one id: "
|
|
88
|
+
"`✨ 1 claude-smart learning applied [cs:s1-ab12]`. Use this exact format "
|
|
89
|
+
"for multiple ids: `✨ 2 claude-smart learnings applied [cs:s1-ab12,p2-cd34]`, "
|
|
90
|
+
"where the number is the count of ids in the brackets. "
|
|
91
|
+
"Never emit a standalone wrapper like `✨s1-ab12✨` or `✨abc123✨`; "
|
|
92
|
+
"those are not claude-smart citations and cannot be resolved. "
|
|
93
|
+
"Default is to skip. If an item is merely on-topic, confirms what you "
|
|
94
|
+
"already planned, or your reply would read the same without it, do not "
|
|
95
|
+
"cite — end the turn normally with your reply. When unsure, skip. Do "
|
|
96
|
+
"not add any other text, tool calls, or role markers after the final "
|
|
97
|
+
"citation line._"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _fingerprint(real_id: Any) -> str:
|
|
102
|
+
"""Return the first ``_FINGERPRINT_LEN`` alphanumeric chars of ``real_id``.
|
|
103
|
+
|
|
104
|
+
The fingerprint disambiguates rank ids across injections: two
|
|
105
|
+
injections both producing ``s1`` for different skills will still
|
|
106
|
+
yield distinct tags when their real ids have different prefixes.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
real_id: The underlying ``user_playbook_id`` or ``profile_id``.
|
|
110
|
+
Accepts anything ``str()`` handles (int, UUID, etc.).
|
|
111
|
+
``None`` yields an empty string.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
str: Up to 4 lowercase alphanumeric chars; empty when the real
|
|
115
|
+
id has no alphanumeric characters or is ``None``.
|
|
116
|
+
"""
|
|
117
|
+
if real_id is None:
|
|
118
|
+
return ""
|
|
119
|
+
return "".join(c for c in str(real_id).lower() if c.isalnum())[:_FINGERPRINT_LEN]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def rank_id(kind: str, rank: int, real_id: Any = None) -> str:
|
|
123
|
+
"""Return the citation id for a skill or preference item.
|
|
124
|
+
|
|
125
|
+
Format is ``{letter}{rank}-{fingerprint}`` where ``letter`` is ``p``
|
|
126
|
+
for preferences and ``s`` for skills, ``rank`` is the 1-based
|
|
127
|
+
position within the current retrieval batch, and ``fingerprint`` is
|
|
128
|
+
up to 4 alphanumeric chars derived from ``real_id``. The fingerprint
|
|
129
|
+
is omitted when no real id is available (falling back to the rank
|
|
130
|
+
form ``s1`` / ``p1``).
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
kind: ``"playbook"`` or ``"profile"``. Unknown values raise
|
|
134
|
+
``ValueError`` — callers never build registry entries for
|
|
135
|
+
other kinds.
|
|
136
|
+
rank: 1-based position within the retrieval batch.
|
|
137
|
+
real_id: The underlying ``user_playbook_id`` or ``profile_id``
|
|
138
|
+
used to derive the fingerprint suffix. Optional.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
str: ``p<rank>-<fp>`` for preferences, ``s<rank>-<fp>`` for
|
|
142
|
+
skills. Suffix is omitted when the real id yields no
|
|
143
|
+
alphanumeric fingerprint.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
ValueError: If ``kind`` is not ``"profile"`` or ``"playbook"``.
|
|
147
|
+
"""
|
|
148
|
+
if kind == "profile":
|
|
149
|
+
prefix = "p"
|
|
150
|
+
elif kind == "playbook":
|
|
151
|
+
prefix = "s"
|
|
152
|
+
else:
|
|
153
|
+
raise ValueError(f"unknown citation kind: {kind!r}")
|
|
154
|
+
fp = _fingerprint(real_id)
|
|
155
|
+
return f"{prefix}{rank}-{fp}" if fp else f"{prefix}{rank}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_citation_command(command: str) -> list[str]:
|
|
159
|
+
"""Extract citation ids from a ``cs-cite`` Bash command string.
|
|
160
|
+
|
|
161
|
+
Returns an empty list when the command does not match the expected
|
|
162
|
+
shape (chained commands, extra arguments, or anything other than a
|
|
163
|
+
bare ``cs-cite <ids>`` invocation are rejected to avoid false
|
|
164
|
+
positives from accidental mentions).
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
command: The raw ``input.command`` value from a Bash tool_use
|
|
168
|
+
block.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
list[str]: Lowercase rank ids (e.g. ``"p1"``, ``"s3"``), in the
|
|
172
|
+
order Claude cited them. Empty when the command does not
|
|
173
|
+
match.
|
|
174
|
+
"""
|
|
175
|
+
match = CITATION_CMD_RE.match(command or "")
|
|
176
|
+
if not match:
|
|
177
|
+
return []
|
|
178
|
+
return _parse_id_tokens(match.group(1))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def parse_text_citations(text: str) -> list[str]:
|
|
182
|
+
"""Extract Codex text-only citation ids from a final learning marker line.
|
|
183
|
+
|
|
184
|
+
The parser intentionally only accepts lines containing the visual
|
|
185
|
+
``claude-smart learning(s) applied`` marker, so ordinary references to
|
|
186
|
+
injected ``[cs:...]`` ids inside an answer do not count as citations.
|
|
187
|
+
When multiple matching lines exist, the last one wins because the
|
|
188
|
+
instruction requires the citation marker to be final.
|
|
189
|
+
"""
|
|
190
|
+
matches = list(_TEXT_CITATION_LINE_RE.finditer(text or ""))
|
|
191
|
+
if not matches:
|
|
192
|
+
return []
|
|
193
|
+
return _parse_id_tokens(matches[-1].group("ids"))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_id_tokens(raw_ids: str) -> list[str]:
|
|
197
|
+
ids: list[str] = []
|
|
198
|
+
for tok in _SPLIT_RE.split(raw_ids.strip()):
|
|
199
|
+
if clean := _CLEAN_ID_RE.match(tok):
|
|
200
|
+
ids.append(clean.group(1).lower())
|
|
201
|
+
return ids
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def ensure_installed() -> Path:
|
|
205
|
+
"""Idempotently install ``cs-cite`` into ``~/.claude-smart/bin/``.
|
|
206
|
+
|
|
207
|
+
Called from every PreToolUse / UserPromptSubmit inject, so we
|
|
208
|
+
short-circuit when the target file already exists with
|
|
209
|
+
the executable bit set — the steady-state path is one ``stat`` syscall
|
|
210
|
+
instead of mkdir + copy + stat + chmod. Keying on filesystem state
|
|
211
|
+
(rather than a module-level boolean) keeps test isolation working when
|
|
212
|
+
tests monkeypatch ``INSTALL_PATH`` to a fresh tmpdir.
|
|
213
|
+
|
|
214
|
+
Never raises — filesystem errors are logged at DEBUG and the caller
|
|
215
|
+
proceeds with injection regardless (the citation feature degrades to
|
|
216
|
+
silent if the script is unreachable).
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Path: Target path, whether or not install succeeded.
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
if (
|
|
223
|
+
INSTALL_PATH.is_file()
|
|
224
|
+
and INSTALL_PATH.stat().st_mode & stat_.S_IXUSR
|
|
225
|
+
and _SOURCE_SCRIPT.is_file()
|
|
226
|
+
and INSTALL_PATH.read_bytes() == _SOURCE_SCRIPT.read_bytes()
|
|
227
|
+
):
|
|
228
|
+
return INSTALL_PATH
|
|
229
|
+
_INSTALL_DIR.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
if _SOURCE_SCRIPT.is_file():
|
|
231
|
+
shutil.copy2(_SOURCE_SCRIPT, INSTALL_PATH)
|
|
232
|
+
mode = INSTALL_PATH.stat().st_mode
|
|
233
|
+
INSTALL_PATH.chmod(mode | stat_.S_IXUSR | stat_.S_IXGRP | stat_.S_IXOTH)
|
|
234
|
+
except OSError as exc:
|
|
235
|
+
_LOGGER.debug("cs-cite install failed: %s", exc)
|
|
236
|
+
return INSTALL_PATH
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Event handlers — one module per Claude Code hook event."""
|