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
|
@@ -2,9 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import quote, urlparse
|
|
6
9
|
|
|
7
|
-
from claude_smart import cs_cite
|
|
10
|
+
from claude_smart import cs_cite, env_config
|
|
11
|
+
|
|
12
|
+
_DASHBOARD_URL_ENV = "CLAUDE_SMART_DASHBOARD_URL"
|
|
13
|
+
_CITATION_LINK_STYLE_ENV = "CLAUDE_SMART_CITATION_LINK_STYLE"
|
|
14
|
+
_DEFAULT_DASHBOARD_URL = "http://localhost:3001"
|
|
15
|
+
_REFLEXIO_URL_ENV = "REFLEXIO_URL"
|
|
16
|
+
_LOCAL_REFLEXIO_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1"}
|
|
8
17
|
|
|
9
18
|
|
|
10
19
|
def _first_nonempty(*values: Any) -> str:
|
|
@@ -93,7 +102,17 @@ def render_with_registry(
|
|
|
93
102
|
if profile_lines:
|
|
94
103
|
sections.append("### Project preferences")
|
|
95
104
|
sections.extend(profile_lines)
|
|
96
|
-
|
|
105
|
+
instruction = _citation_instruction(
|
|
106
|
+
os.environ.get("CLAUDE_SMART_CITATIONS", "on"),
|
|
107
|
+
os.environ.get(_CITATION_LINK_STYLE_ENV, "markdown"),
|
|
108
|
+
)
|
|
109
|
+
if instruction:
|
|
110
|
+
sections.append(instruction)
|
|
111
|
+
exact_osc8_marker = _exact_osc8_marker_instruction(
|
|
112
|
+
playbook_entries + profile_entries
|
|
113
|
+
)
|
|
114
|
+
if exact_osc8_marker:
|
|
115
|
+
sections.append(exact_osc8_marker)
|
|
97
116
|
return "\n".join(sections) + "\n", playbook_entries + profile_entries
|
|
98
117
|
|
|
99
118
|
|
|
@@ -164,16 +183,159 @@ def render_inline_with_registry(
|
|
|
164
183
|
if profile_lines:
|
|
165
184
|
sections.append("### Relevant project preferences")
|
|
166
185
|
sections.extend(profile_lines)
|
|
167
|
-
|
|
186
|
+
instruction = _citation_instruction(
|
|
187
|
+
os.environ.get("CLAUDE_SMART_CITATIONS", "on"),
|
|
188
|
+
os.environ.get(_CITATION_LINK_STYLE_ENV, "markdown"),
|
|
189
|
+
)
|
|
190
|
+
if instruction:
|
|
191
|
+
sections.append(instruction)
|
|
192
|
+
exact_osc8_marker = _exact_osc8_marker_instruction(
|
|
193
|
+
playbook_entries + profile_entries
|
|
194
|
+
)
|
|
195
|
+
if exact_osc8_marker:
|
|
196
|
+
sections.append(exact_osc8_marker)
|
|
168
197
|
return "\n".join(sections) + "\n", playbook_entries + profile_entries
|
|
169
198
|
|
|
170
199
|
|
|
200
|
+
def render_inline_compact_with_registry(
|
|
201
|
+
*,
|
|
202
|
+
project_id: str,
|
|
203
|
+
user_playbooks: Iterable[Any],
|
|
204
|
+
agent_playbooks: Iterable[Any],
|
|
205
|
+
profiles: Iterable[Any],
|
|
206
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
207
|
+
"""Render mid-session context as one compact logical line.
|
|
208
|
+
|
|
209
|
+
Codex currently displays ``UserPromptSubmit.additionalContext`` in the TUI.
|
|
210
|
+
This renderer preserves the model-visible ids and rule URLs needed for
|
|
211
|
+
citation tracking while avoiding the multi-line markdown block used by
|
|
212
|
+
hosts that can hide hook context.
|
|
213
|
+
"""
|
|
214
|
+
del project_id # kept for symmetry with ``render_inline_with_registry``.
|
|
215
|
+
_, playbook_entries = _format_combined_playbooks(
|
|
216
|
+
agent_playbooks=agent_playbooks, user_playbooks=user_playbooks
|
|
217
|
+
)
|
|
218
|
+
_, profile_entries = _format_profiles(profiles)
|
|
219
|
+
entries = playbook_entries + profile_entries
|
|
220
|
+
if not entries:
|
|
221
|
+
return "", []
|
|
222
|
+
|
|
223
|
+
skill_parts = []
|
|
224
|
+
preference_parts = []
|
|
225
|
+
marker_parts = []
|
|
226
|
+
link_style = os.environ.get(_CITATION_LINK_STYLE_ENV, "markdown")
|
|
227
|
+
for entry in entries:
|
|
228
|
+
title = _one_line(str(entry.get("title") or entry["content"]))
|
|
229
|
+
content = _one_line(str(entry["content"]))
|
|
230
|
+
rule_url = str(entry.get("rule_url") or "")
|
|
231
|
+
if link_style == "osc8" and rule_url:
|
|
232
|
+
linked_title = _osc8_link(
|
|
233
|
+
rule_url, _strip_trailing_sentence_punctuation(title)
|
|
234
|
+
)
|
|
235
|
+
item = _osc8_link(rule_url, _strip_trailing_sentence_punctuation(content))
|
|
236
|
+
marker_parts.append(linked_title)
|
|
237
|
+
else:
|
|
238
|
+
item = f"{content} (title: {title}"
|
|
239
|
+
if rule_url:
|
|
240
|
+
item += f"; open: {rule_url}"
|
|
241
|
+
item += ")"
|
|
242
|
+
if entry.get("kind") == "profile":
|
|
243
|
+
preference_parts.append(item)
|
|
244
|
+
else:
|
|
245
|
+
skill_parts.append(item)
|
|
246
|
+
|
|
247
|
+
memory_sections = []
|
|
248
|
+
if skill_parts:
|
|
249
|
+
label = "Skill" if len(skill_parts) == 1 else "Skills"
|
|
250
|
+
memory_sections.append(f"{label}: {'; '.join(skill_parts)}")
|
|
251
|
+
if preference_parts:
|
|
252
|
+
label = "Preference" if len(preference_parts) == 1 else "Preferences"
|
|
253
|
+
memory_sections.append(f"{label}: {'; '.join(preference_parts)}")
|
|
254
|
+
sections = [f"claude-smart: using relevant memory. {'. '.join(memory_sections)}."]
|
|
255
|
+
instruction = _compact_citation_instruction(marker_parts)
|
|
256
|
+
if instruction:
|
|
257
|
+
sections.append(instruction)
|
|
258
|
+
return " ".join(sections) + "\n", entries
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _compact_citation_instruction(marker_parts: list[str] | None = None) -> str:
|
|
262
|
+
if os.environ.get("CLAUDE_SMART_CITATIONS", "on") == "off":
|
|
263
|
+
return ""
|
|
264
|
+
link_style = os.environ.get(_CITATION_LINK_STYLE_ENV, "markdown")
|
|
265
|
+
if link_style == "osc8" and marker_parts:
|
|
266
|
+
marker = f"✨ claude-smart rule applied: {' | '.join(marker_parts)}"
|
|
267
|
+
separator_instruction = (
|
|
268
|
+
" Separate multiple linked memories with the visible ` | ` separator."
|
|
269
|
+
if len(marker_parts) > 1
|
|
270
|
+
else ""
|
|
271
|
+
)
|
|
272
|
+
return _remoteize_citation_instruction(
|
|
273
|
+
f"If used, copy this final marker exactly, preserving its hidden "
|
|
274
|
+
f"OSC 8 terminal link: `{marker}`.{separator_instruction} "
|
|
275
|
+
f"Skip when unrelated."
|
|
276
|
+
)
|
|
277
|
+
if link_style == "osc8":
|
|
278
|
+
return _remoteize_citation_instruction(
|
|
279
|
+
"If used, end with `✨ claude-smart rule applied:` followed by "
|
|
280
|
+
"the same linked memory text; keep the link, but do not show the "
|
|
281
|
+
"URL. Skip when unrelated."
|
|
282
|
+
)
|
|
283
|
+
return _remoteize_citation_instruction(
|
|
284
|
+
"Only if a listed [cs:...] item materially changes your answer, end "
|
|
285
|
+
"with one final marker like `✨ claude-smart rule applied: "
|
|
286
|
+
"[verify process state](http://localhost:3001/rules/s1-123)` using "
|
|
287
|
+
"the shown rule URL; skip the marker when unrelated."
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _citation_instruction(mode: str, link_style: str) -> str:
|
|
292
|
+
return _remoteize_citation_instruction(
|
|
293
|
+
cs_cite.citation_instruction(mode, link_style)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _remoteize_citation_instruction(text: str) -> str:
|
|
298
|
+
playbooks_url = _remote_reflexio_page_url("playbook")
|
|
299
|
+
profiles_url = _remote_reflexio_page_url("profile")
|
|
300
|
+
if not text or not playbooks_url or not profiles_url:
|
|
301
|
+
return text
|
|
302
|
+
return text.replace("http://localhost:3001/rules/s1-123", playbooks_url).replace(
|
|
303
|
+
"http://localhost:3001/rules/p1-pref", profiles_url
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _exact_osc8_marker_instruction(entries: list[dict[str, Any]]) -> str:
|
|
308
|
+
if os.environ.get("CLAUDE_SMART_CITATIONS", "on") == "off":
|
|
309
|
+
return ""
|
|
310
|
+
if os.environ.get(_CITATION_LINK_STYLE_ENV, "markdown") != "osc8":
|
|
311
|
+
return ""
|
|
312
|
+
|
|
313
|
+
marker_parts = []
|
|
314
|
+
for entry in entries:
|
|
315
|
+
rule_url = str(entry.get("rule_url") or "")
|
|
316
|
+
if not rule_url:
|
|
317
|
+
continue
|
|
318
|
+
title = _one_line(str(entry.get("title") or entry["content"]))
|
|
319
|
+
marker_parts.append(
|
|
320
|
+
_osc8_link(rule_url, _strip_trailing_sentence_punctuation(title))
|
|
321
|
+
)
|
|
322
|
+
if not marker_parts:
|
|
323
|
+
return ""
|
|
324
|
+
|
|
325
|
+
marker = f"✨ claude-smart rule applied: {' | '.join(marker_parts)}"
|
|
326
|
+
return (
|
|
327
|
+
"If any listed memory above was used, copy this exact final marker, "
|
|
328
|
+
f"preserving its hidden OSC 8 terminal links: `{marker}`. "
|
|
329
|
+
"Do not rename, summarize, or regroup the linked titles."
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
171
333
|
def _format_combined_playbooks(
|
|
172
334
|
*,
|
|
173
335
|
agent_playbooks: Iterable[Any],
|
|
174
336
|
user_playbooks: Iterable[Any],
|
|
175
337
|
) -> tuple[list[str], list[dict[str, Any]]]:
|
|
176
|
-
"""Render agent
|
|
338
|
+
"""Render agent then user playbooks with one shared rank counter."""
|
|
177
339
|
lines: list[str] = []
|
|
178
340
|
entries: list[dict[str, Any]] = []
|
|
179
341
|
rank = 0
|
|
@@ -205,11 +367,15 @@ def _append_playbook_bullet(
|
|
|
205
367
|
real_id = _field(pb, id_field)
|
|
206
368
|
item_id = cs_cite.rank_id("playbook", rank, real_id)
|
|
207
369
|
title = _title_from_content(content)
|
|
370
|
+
dashboard_url = _dashboard_url("playbook", real_id, source_kind)
|
|
371
|
+
rule_url = _rule_url(item_id, "playbook")
|
|
208
372
|
bullet = f"- [cs:{item_id}] {content}"
|
|
209
373
|
if trigger:
|
|
210
374
|
bullet += f" _(when: {trigger})_"
|
|
211
375
|
if rationale:
|
|
212
376
|
bullet += f" — *why:* {rationale}"
|
|
377
|
+
if rule_url:
|
|
378
|
+
bullet += f" _(open: {rule_url})_"
|
|
213
379
|
lines.append(bullet)
|
|
214
380
|
entries.append(
|
|
215
381
|
{
|
|
@@ -219,6 +385,8 @@ def _append_playbook_bullet(
|
|
|
219
385
|
"content": content,
|
|
220
386
|
"real_id": str(real_id) if real_id is not None else None,
|
|
221
387
|
"source_kind": source_kind,
|
|
388
|
+
"dashboard_url": dashboard_url,
|
|
389
|
+
"rule_url": rule_url,
|
|
222
390
|
}
|
|
223
391
|
)
|
|
224
392
|
return rank
|
|
@@ -238,7 +406,12 @@ def _format_profiles(
|
|
|
238
406
|
real_id = _field(p, "profile_id")
|
|
239
407
|
item_id = cs_cite.rank_id("profile", rank, real_id)
|
|
240
408
|
title = _title_from_content(content)
|
|
241
|
-
|
|
409
|
+
dashboard_url = _dashboard_url("profile", real_id)
|
|
410
|
+
rule_url = _rule_url(item_id, "profile")
|
|
411
|
+
bullet = f"- [cs:{item_id}] {content}"
|
|
412
|
+
if rule_url:
|
|
413
|
+
bullet += f" _(open: {rule_url})_"
|
|
414
|
+
lines.append(bullet)
|
|
242
415
|
entries.append(
|
|
243
416
|
{
|
|
244
417
|
"id": item_id,
|
|
@@ -246,11 +419,64 @@ def _format_profiles(
|
|
|
246
419
|
"title": title,
|
|
247
420
|
"content": content,
|
|
248
421
|
"real_id": str(real_id) if real_id is not None else None,
|
|
422
|
+
"dashboard_url": dashboard_url,
|
|
423
|
+
"rule_url": rule_url,
|
|
249
424
|
}
|
|
250
425
|
)
|
|
251
426
|
return lines, entries
|
|
252
427
|
|
|
253
428
|
|
|
429
|
+
def _dashboard_url(kind: str, real_id: Any, source_kind: str | None = None) -> str:
|
|
430
|
+
remote_url = _remote_reflexio_page_url(kind)
|
|
431
|
+
if remote_url:
|
|
432
|
+
return remote_url
|
|
433
|
+
if real_id is None:
|
|
434
|
+
return ""
|
|
435
|
+
encoded_id = quote(str(real_id), safe="")
|
|
436
|
+
base = os.environ.get(_DASHBOARD_URL_ENV, _DEFAULT_DASHBOARD_URL).rstrip("/")
|
|
437
|
+
if kind == "profile":
|
|
438
|
+
return f"{base}/preferences/project/{encoded_id}"
|
|
439
|
+
if kind == "playbook":
|
|
440
|
+
skill_kind = "shared" if source_kind == "agent_playbook" else "project"
|
|
441
|
+
return f"{base}/skills/{skill_kind}/{encoded_id}"
|
|
442
|
+
return ""
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _rule_url(item_id: str, kind: str) -> str:
|
|
446
|
+
remote_url = _remote_reflexio_page_url(kind)
|
|
447
|
+
if remote_url:
|
|
448
|
+
return remote_url
|
|
449
|
+
if not item_id:
|
|
450
|
+
return ""
|
|
451
|
+
encoded_id = quote(item_id, safe="")
|
|
452
|
+
base = os.environ.get(_DASHBOARD_URL_ENV, _DEFAULT_DASHBOARD_URL).rstrip("/")
|
|
453
|
+
return f"{base}/rules/{encoded_id}"
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _remote_reflexio_page_url(kind: str) -> str:
|
|
457
|
+
origin = _remote_reflexio_origin()
|
|
458
|
+
if not origin:
|
|
459
|
+
return ""
|
|
460
|
+
if kind == "profile":
|
|
461
|
+
return f"{origin}/profiles"
|
|
462
|
+
if kind == "playbook":
|
|
463
|
+
return f"{origin}/playbooks"
|
|
464
|
+
return ""
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _remote_reflexio_origin() -> str:
|
|
468
|
+
env_config.load_reflexio_env()
|
|
469
|
+
raw = os.environ.get(_REFLEXIO_URL_ENV, "").strip()
|
|
470
|
+
if not raw:
|
|
471
|
+
return ""
|
|
472
|
+
parsed = urlparse(raw)
|
|
473
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
474
|
+
return ""
|
|
475
|
+
if (parsed.hostname or "").lower() in _LOCAL_REFLEXIO_HOSTS:
|
|
476
|
+
return ""
|
|
477
|
+
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
|
478
|
+
|
|
479
|
+
|
|
254
480
|
def _title_from_content(content: str, limit: int = 80) -> str:
|
|
255
481
|
"""Derive a compact human-readable title from a bullet's content.
|
|
256
482
|
|
|
@@ -270,6 +496,18 @@ def _title_from_content(content: str, limit: int = 80) -> str:
|
|
|
270
496
|
return text[: limit - 1].rstrip() + "…"
|
|
271
497
|
|
|
272
498
|
|
|
499
|
+
def _one_line(text: str) -> str:
|
|
500
|
+
return " ".join(text.split())
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _osc8_link(url: str, label: str) -> str:
|
|
504
|
+
return f"\x1b]8;;{url}\x1b\\{label}\x1b]8;;\x1b\\"
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _strip_trailing_sentence_punctuation(text: str) -> str:
|
|
508
|
+
return text.rstrip().rstrip(".!?")
|
|
509
|
+
|
|
510
|
+
|
|
273
511
|
def _field(obj: Any, name: str) -> Any:
|
|
274
512
|
"""Read ``name`` from either an attribute or a dict key."""
|
|
275
513
|
if isinstance(obj, dict):
|
|
@@ -17,6 +17,7 @@ turn) — see the two call sites for the small policy differences.
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import json
|
|
20
|
+
import os
|
|
20
21
|
import sys
|
|
21
22
|
import time
|
|
22
23
|
|
|
@@ -59,7 +60,13 @@ def emit_context(
|
|
|
59
60
|
query=query,
|
|
60
61
|
top_k=top_k,
|
|
61
62
|
)
|
|
62
|
-
|
|
63
|
+
renderer = (
|
|
64
|
+
context_format.render_inline_compact_with_registry
|
|
65
|
+
if hook_event_name == "UserPromptSubmit"
|
|
66
|
+
and os.environ.get("CLAUDE_SMART_HOST") == "codex"
|
|
67
|
+
else context_format.render_inline_with_registry
|
|
68
|
+
)
|
|
69
|
+
markdown, registry = renderer(
|
|
63
70
|
project_id=project_id,
|
|
64
71
|
user_playbooks=user_playbooks,
|
|
65
72
|
agent_playbooks=agent_playbooks,
|
|
@@ -7,7 +7,7 @@ real id (``[cs:s1-1a2b]`` for the first skill whose
|
|
|
7
7
|
second preference). The injected instruction asks the assistant to end
|
|
8
8
|
impactful replies with a marker like::
|
|
9
9
|
|
|
10
|
-
✨
|
|
10
|
+
✨ claude-smart rule applied: [git safety](http://localhost:3001/rules/s1-123)
|
|
11
11
|
|
|
12
12
|
The Stop hook later scans the assistant text for those markers and resolves
|
|
13
13
|
the ids against a per-session registry persisted at
|
|
@@ -26,18 +26,24 @@ This module holds:
|
|
|
26
26
|
- ``rank_id``: ``p{n}-{fp}`` / ``s{n}-{fp}`` tag for a given
|
|
27
27
|
(kind, rank, real_id) tuple. Fingerprint is omitted when no real id
|
|
28
28
|
is available. ``p`` is preference, ``s`` is skill.
|
|
29
|
-
- ``
|
|
30
|
-
so the assistant knows when and how to emit the citation marker.
|
|
29
|
+
- ``citation_instruction(mode)``: the trailer text appended to injected
|
|
30
|
+
context so the assistant knows when and how to emit the citation marker.
|
|
31
|
+
``mode`` is read from the ``CLAUDE_SMART_CITATIONS`` env var by the
|
|
32
|
+
caller; ``"off"`` disables the instruction, while all other values enable
|
|
33
|
+
the compact marker instruction for backward compatibility.
|
|
34
|
+
- ``CITATION_INSTRUCTION``: the compact enabled instruction string, kept as
|
|
35
|
+
a module-level constant for backward-compatible imports.
|
|
31
36
|
"""
|
|
32
37
|
|
|
33
38
|
from __future__ import annotations
|
|
34
39
|
|
|
35
40
|
import re
|
|
36
41
|
from typing import Any
|
|
42
|
+
from urllib.parse import unquote, urlparse
|
|
37
43
|
|
|
38
44
|
_FINGERPRINT_LEN = 4
|
|
39
45
|
|
|
40
|
-
_ID_TOKEN = r"(?i:cs:)?(?i:[ps])\d+(?:-(?i:[a-z0-9]){1,4})?"
|
|
46
|
+
_ID_TOKEN = r"(?i:cs:)?(?i:[ps])\d+(?:-(?i:[a-z0-9]){1,4})?" # noqa: S105
|
|
41
47
|
_ID_SEP = r"[,\s]+"
|
|
42
48
|
_CLEAN_ID_RE = re.compile(r"^(?i:cs:)?((?i:[ps])\d+(?:-(?i:[a-z0-9]){1,4})?)$")
|
|
43
49
|
_SPLIT_RE = re.compile(_ID_SEP)
|
|
@@ -45,28 +51,97 @@ _TEXT_CITATION_LINE_RE = re.compile(
|
|
|
45
51
|
r"(?im)^\s*✨\s+\d+\s+claude-smart learning(?:s)? applied\s+"
|
|
46
52
|
r"\[cs:(?P<ids>[^\]]+)\]\s*$"
|
|
47
53
|
)
|
|
54
|
+
_APPLIED_LINK_LINE_RE = re.compile(
|
|
55
|
+
r"(?im)^\s*✨\s+(?:Applied|claude-smart rules? applied):\s+(?P<body>.+?)\s*$"
|
|
56
|
+
)
|
|
57
|
+
_MARKDOWN_LINK_RE = re.compile(r"\[[^\]]+\]\((?P<url>[^)]+)\)")
|
|
58
|
+
_OSC8_URL_RE = re.compile(
|
|
59
|
+
r"\x1b\]8;[^\x07\x1b]*;(?P<url>[^\x07\x1b]+)(?:\x07|\x1b\\)"
|
|
60
|
+
)
|
|
61
|
+
_RAW_DASHBOARD_URL_RE = re.compile(
|
|
62
|
+
r"(?P<url>(?:https?://[^\s),\x1b\\]+)?/"
|
|
63
|
+
r"(?:skills/(?:project|shared)/[^\s),\x1b\\]+|"
|
|
64
|
+
r"preferences/(?:project/)?[^\s),\x1b\\]+|"
|
|
65
|
+
r"rules/[^\s),\x1b\\]+))"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
_COMPACT_INTRO = (
|
|
69
|
+
"_If you use any listed `[cs:…]` item to answer and it materially changes "
|
|
70
|
+
"your answer, you must end with one final marker line. Skip the marker "
|
|
71
|
+
"only when no listed item affected the answer."
|
|
72
|
+
)
|
|
48
73
|
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"Never emit a standalone wrapper like `✨s1-ab12✨` or `✨abc123✨`; "
|
|
62
|
-
"those are not claude-smart citations and cannot be resolved. "
|
|
63
|
-
"Default is to skip. If an item is merely on-topic, confirms what you "
|
|
64
|
-
"already planned, or your reply would read the same without it, do not "
|
|
65
|
-
"cite — end the turn normally with your reply. When unsure, skip. Do "
|
|
66
|
-
"not add any other text, tool calls, or role markers after the final "
|
|
67
|
-
"citation line._"
|
|
74
|
+
_MARKDOWN_MARKER_PARAGRAPH = (
|
|
75
|
+
"Use human-readable linked titles, not raw ids. Format for one item: "
|
|
76
|
+
"`✨ claude-smart rule applied: "
|
|
77
|
+
"[verify process state](http://localhost:3001/rules/s1-123)`. "
|
|
78
|
+
"For multiple items: "
|
|
79
|
+
"`✨ claude-smart rule applied: "
|
|
80
|
+
"[git safety](http://localhost:3001/rules/s1-123) | "
|
|
81
|
+
"[brief answer preference](http://localhost:3001/rules/p1-pref)`. "
|
|
82
|
+
"Separate multiple linked memories with the visible ` | ` separator. "
|
|
83
|
+
"Use the dashboard URL shown beside each cited item; do not invent URLs. "
|
|
84
|
+
"Do not include `[cs:…]` ids in the marker line. Never use the old "
|
|
85
|
+
"`✨ 1 claude-smart learning applied [cs:...]` marker format._"
|
|
68
86
|
)
|
|
69
87
|
|
|
88
|
+
_OSC8_EXAMPLE_ONE = (
|
|
89
|
+
"✨ claude-smart rule applied: "
|
|
90
|
+
"\x1b]8;;http://localhost:3001/rules/s1-123\x1b\\"
|
|
91
|
+
"verify process state"
|
|
92
|
+
"\x1b]8;;\x1b\\"
|
|
93
|
+
)
|
|
94
|
+
_OSC8_EXAMPLE_MULTI = (
|
|
95
|
+
"✨ claude-smart rule applied: "
|
|
96
|
+
"\x1b]8;;http://localhost:3001/rules/s1-123\x1b\\"
|
|
97
|
+
"git safety"
|
|
98
|
+
"\x1b]8;;\x1b\\"
|
|
99
|
+
" | "
|
|
100
|
+
"\x1b]8;;http://localhost:3001/rules/p1-pref\x1b\\"
|
|
101
|
+
"brief answer preference"
|
|
102
|
+
"\x1b]8;;\x1b\\"
|
|
103
|
+
)
|
|
104
|
+
_OSC8_MARKER_PARAGRAPH = (
|
|
105
|
+
"Use human-readable OSC 8 terminal hyperlinks, not raw ids or visible "
|
|
106
|
+
"URLs. Format for one item: "
|
|
107
|
+
f"`{_OSC8_EXAMPLE_ONE}`. For multiple items: "
|
|
108
|
+
f"`{_OSC8_EXAMPLE_MULTI}`. Use the dashboard URL shown beside each cited "
|
|
109
|
+
"item; do not invent URLs. Separate multiple linked memories with the "
|
|
110
|
+
"visible ` | ` separator. If OSC 8 is unavailable, use markdown links. "
|
|
111
|
+
"Do not include `[cs:…]` ids in the marker line. Do not omit the marker "
|
|
112
|
+
"after using listed memory._"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
CITATION_MODES = ("on", "auto", "marker-only", "off")
|
|
116
|
+
LINK_STYLES = ("markdown", "osc8")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def citation_instruction(mode: str, link_style: str = "markdown") -> str:
|
|
120
|
+
"""Return the citation prompt for ``mode``.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
mode: ``"off"`` returns an empty string so no instruction is
|
|
124
|
+
injected. Any other value, including legacy ``"auto"`` and
|
|
125
|
+
``"marker-only"``, returns the compact enabled instruction.
|
|
126
|
+
Unknown values stay enabled — env-var typos must not break
|
|
127
|
+
injection.
|
|
128
|
+
link_style: ``"markdown"`` for ordinary markdown links, or ``"osc8"``
|
|
129
|
+
for terminal-native hyperlinks. Unknown values fall back to
|
|
130
|
+
``"markdown"``.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
str: The instruction text, or ``""`` for ``"off"``.
|
|
134
|
+
"""
|
|
135
|
+
if mode == "off":
|
|
136
|
+
return ""
|
|
137
|
+
marker_paragraph = (
|
|
138
|
+
_OSC8_MARKER_PARAGRAPH if link_style == "osc8" else _MARKDOWN_MARKER_PARAGRAPH
|
|
139
|
+
)
|
|
140
|
+
return f"{_COMPACT_INTRO}\n\n{marker_paragraph}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
CITATION_INSTRUCTION = citation_instruction("on")
|
|
144
|
+
|
|
70
145
|
|
|
71
146
|
def _fingerprint(real_id: Any) -> str:
|
|
72
147
|
"""Return the first ``_FINGERPRINT_LEN`` alphanumeric chars of ``real_id``.
|
|
@@ -128,21 +203,98 @@ def rank_id(kind: str, rank: int, real_id: Any = None) -> str:
|
|
|
128
203
|
def parse_text_citations(text: str) -> list[str]:
|
|
129
204
|
"""Extract Codex text-only citation ids from a final learning marker line.
|
|
130
205
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
206
|
+
Supports the legacy id marker::
|
|
207
|
+
|
|
208
|
+
✨ 1 claude-smart learning applied [cs:s1-ab12]
|
|
209
|
+
|
|
210
|
+
and the newer human-readable dashboard-link marker::
|
|
211
|
+
|
|
212
|
+
✨ claude-smart rule applied: [git safety](http://localhost:3001/rules/s1-123)
|
|
213
|
+
|
|
214
|
+
Ordinary references to injected ``[cs:...]`` ids or dashboard URLs inside
|
|
215
|
+
the answer do not count as citations; they must appear on a final marker
|
|
216
|
+
line. When multiple matching lines exist, the last one wins because the
|
|
135
217
|
instruction requires the citation marker to be final.
|
|
136
218
|
"""
|
|
137
|
-
|
|
219
|
+
old_matches = [
|
|
220
|
+
(m.start(), "ids", m.group("ids"))
|
|
221
|
+
for m in _TEXT_CITATION_LINE_RE.finditer(text or "")
|
|
222
|
+
]
|
|
223
|
+
new_matches = [
|
|
224
|
+
(m.start(), "links", m.group("body"))
|
|
225
|
+
for m in _APPLIED_LINK_LINE_RE.finditer(text or "")
|
|
226
|
+
]
|
|
227
|
+
matches = old_matches + new_matches
|
|
138
228
|
if not matches:
|
|
139
229
|
return []
|
|
140
|
-
|
|
230
|
+
_, kind, value = max(matches, key=lambda item: item[0])
|
|
231
|
+
if kind == "ids":
|
|
232
|
+
return _parse_id_tokens(value)
|
|
233
|
+
return _parse_dashboard_link_tokens(value)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def strip_marker_lines(text: str) -> str:
|
|
237
|
+
"""Remove any ``✨ N claude-smart learning(s) applied [cs:…]`` lines.
|
|
238
|
+
|
|
239
|
+
Used by the Stop hook when ``CLAUDE_SMART_CITATIONS=off`` to scrub
|
|
240
|
+
any marker the assistant emitted from a cached prompt fragment that
|
|
241
|
+
still contained the citation instruction. Returns ``text`` unchanged
|
|
242
|
+
when no marker line is present.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
text: Assistant message text.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
str: ``text`` with marker lines removed, trailing blank lines
|
|
249
|
+
trimmed. ``""`` when ``text`` is falsy.
|
|
250
|
+
"""
|
|
251
|
+
if not text:
|
|
252
|
+
return text or ""
|
|
253
|
+
cleaned = _TEXT_CITATION_LINE_RE.sub("", text)
|
|
254
|
+
cleaned = _APPLIED_LINK_LINE_RE.sub("", cleaned)
|
|
255
|
+
return cleaned.rstrip("\n")
|
|
141
256
|
|
|
142
257
|
|
|
143
258
|
def _parse_id_tokens(raw_ids: str) -> list[str]:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
259
|
+
return [
|
|
260
|
+
clean.group(1).lower()
|
|
261
|
+
for tok in _SPLIT_RE.split(raw_ids.strip())
|
|
262
|
+
if (clean := _CLEAN_ID_RE.match(tok))
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _parse_dashboard_link_tokens(raw: str) -> list[str]:
|
|
267
|
+
tokens: list[str] = []
|
|
268
|
+
seen: set[str] = set()
|
|
269
|
+
matches = [
|
|
270
|
+
(m.start(), m.group("url"))
|
|
271
|
+
for regex in (_OSC8_URL_RE, _MARKDOWN_LINK_RE, _RAW_DASHBOARD_URL_RE)
|
|
272
|
+
for m in regex.finditer(raw)
|
|
273
|
+
]
|
|
274
|
+
for _, url in sorted(matches, key=lambda item: item[0]):
|
|
275
|
+
if (token := dashboard_url_token(url)) and token not in seen:
|
|
276
|
+
seen.add(token)
|
|
277
|
+
tokens.append(token)
|
|
278
|
+
return tokens
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def dashboard_url_token(url: str) -> str:
|
|
282
|
+
"""Return an internal resolver token for a dashboard detail URL.
|
|
283
|
+
|
|
284
|
+
The token is consumed by ``events.stop._resolve_cited_items``. It is not
|
|
285
|
+
shown to users.
|
|
286
|
+
"""
|
|
287
|
+
parsed = urlparse(url)
|
|
288
|
+
path = parsed.path if parsed.scheme else url.split("?", 1)[0].split("#", 1)[0]
|
|
289
|
+
parts = [unquote(part) for part in path.strip("/").split("/") if part]
|
|
290
|
+
if len(parts) == 3 and parts[0] == "skills" and parts[1] in {"project", "shared"}:
|
|
291
|
+
source_kind = "user_playbook" if parts[1] == "project" else "agent_playbook"
|
|
292
|
+
return f"route:playbook:{source_kind}:{parts[2]}"
|
|
293
|
+
if len(parts) == 2 and parts[0] == "preferences":
|
|
294
|
+
return f"route:profile:profile:{parts[1]}"
|
|
295
|
+
if len(parts) == 3 and parts[0] == "preferences" and parts[1] == "project":
|
|
296
|
+
return f"route:profile:profile:{parts[2]}"
|
|
297
|
+
if len(parts) == 2 and parts[0] == "rules":
|
|
298
|
+
clean = _CLEAN_ID_RE.match(parts[1])
|
|
299
|
+
return clean.group(1).lower() if clean else ""
|
|
300
|
+
return ""
|