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.
Files changed (64) hide show
  1. package/README.md +2 -2
  2. package/bin/claude-smart.js +172 -18
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/dashboard/app/api/config/route.ts +11 -2
  7. package/plugin/dashboard/app/api/health/route.ts +45 -6
  8. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +15 -7
  9. package/plugin/dashboard/app/api/rules/applied/route.ts +27 -0
  10. package/plugin/dashboard/app/configure/env/page.tsx +36 -31
  11. package/plugin/dashboard/app/configure/layout.tsx +1 -1
  12. package/plugin/dashboard/app/configure/server/page.tsx +8 -14
  13. package/plugin/dashboard/app/dashboard/page.tsx +311 -115
  14. package/plugin/dashboard/app/globals.css +80 -66
  15. package/plugin/dashboard/app/layout.tsx +13 -10
  16. package/plugin/dashboard/app/preferences/[id]/page.tsx +21 -32
  17. package/plugin/dashboard/app/preferences/page.tsx +154 -54
  18. package/plugin/dashboard/app/preferences/project/[id]/page.tsx +1 -0
  19. package/plugin/dashboard/app/rules/[id]/page.tsx +51 -0
  20. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +4 -4
  21. package/plugin/dashboard/app/sessions/page.tsx +14 -10
  22. package/plugin/dashboard/app/skills/page.tsx +175 -56
  23. package/plugin/dashboard/app/skills/project/[id]/page.tsx +22 -38
  24. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +20 -37
  25. package/plugin/dashboard/components/common/delete-learning-danger-zone.tsx +166 -0
  26. package/plugin/dashboard/components/common/empty-state.tsx +4 -2
  27. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  28. package/plugin/dashboard/components/common/page-header.tsx +5 -3
  29. package/plugin/dashboard/components/common/page-tabs.tsx +4 -4
  30. package/plugin/dashboard/components/common/stat-card.tsx +9 -5
  31. package/plugin/dashboard/components/layout/sidebar.tsx +24 -9
  32. package/plugin/dashboard/components/layout/top-bar.tsx +37 -25
  33. package/plugin/dashboard/components/ui/input.tsx +1 -0
  34. package/plugin/dashboard/hooks/use-settings.tsx +30 -61
  35. package/plugin/dashboard/hooks/use-stall-state.ts +5 -9
  36. package/plugin/dashboard/lib/config-file.ts +2 -0
  37. package/plugin/dashboard/lib/reflexio-client.ts +23 -48
  38. package/plugin/dashboard/lib/session-reader.ts +222 -6
  39. package/plugin/dashboard/lib/types.ts +20 -1
  40. package/plugin/dashboard/package-lock.json +70 -95
  41. package/plugin/dashboard/package.json +5 -2
  42. package/plugin/hooks/hooks.json +1 -1
  43. package/plugin/pyproject.toml +1 -1
  44. package/plugin/scripts/_lib.sh +126 -0
  45. package/plugin/scripts/backend-service.sh +32 -7
  46. package/plugin/scripts/cli.sh +4 -2
  47. package/plugin/scripts/codex-hook.js +100 -3
  48. package/plugin/scripts/dashboard-service.sh +98 -19
  49. package/plugin/scripts/hook_entry.sh +32 -11
  50. package/plugin/scripts/smart-install.sh +27 -44
  51. package/plugin/src/claude_smart/cli.py +204 -20
  52. package/plugin/src/claude_smart/context_format.py +244 -6
  53. package/plugin/src/claude_smart/context_inject.py +8 -1
  54. package/plugin/src/claude_smart/cs_cite.py +186 -34
  55. package/plugin/src/claude_smart/env_config.py +102 -0
  56. package/plugin/src/claude_smart/events/session_end.py +171 -6
  57. package/plugin/src/claude_smart/events/session_start.py +26 -2
  58. package/plugin/src/claude_smart/events/stop.py +48 -9
  59. package/plugin/src/claude_smart/hook.py +62 -4
  60. package/plugin/src/claude_smart/hook_log.py +301 -0
  61. package/plugin/src/claude_smart/internal_call.py +30 -0
  62. package/plugin/src/claude_smart/publish.py +5 -0
  63. package/plugin/src/claude_smart/reflexio_adapter.py +102 -26
  64. package/plugin/uv.lock +1 -1
@@ -2,9 +2,18 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, Iterable
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
- sections.append(cs_cite.CITATION_INSTRUCTION)
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
- sections.append(cs_cite.CITATION_INSTRUCTION)
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 playbooks first, then user playbooks, with one shared rank counter."""
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
- lines.append(f"- [cs:{item_id}] {content}")
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
- markdown, registry = context_format.render_inline_with_registry(
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
- 1 claude-smart learning applied [cs:s1-1a2b]
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
- - ``CITATION_INSTRUCTION``: the trailer text appended to injected context
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
- CITATION_INSTRUCTION = (
50
- "_First, fully answer the user citation does not change what or how "
51
- "you reply. Then, as a final step, consider whether to cite: if — and "
52
- "only if — an injected `[cs:…]` item materially changed your reply "
53
- "(different wording, action, or conclusion than you would have produced "
54
- "without it), append exactly one final citation line after your answer. "
55
- "Do not call a shell command or any other tool for citations. Ids come verbatim "
56
- "from the `[cs:…]` tags — keep the leading `p` (preference) or `s` "
57
- "(skill) and the `-<fp>` suffix. Use this exact format for one id: "
58
- "`✨ 1 claude-smart learning applied [cs:s1-ab12]`. Use this exact format "
59
- "for multiple ids: `✨ 2 claude-smart learnings applied [cs:s1-ab12,p2-cd34]`, "
60
- "where the number is the count of ids in the brackets. "
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
- The parser intentionally only accepts lines containing the visual
132
- ``claude-smart learning(s) applied`` marker, so ordinary references to
133
- injected ``[cs:...]`` ids inside an answer do not count as citations.
134
- When multiple matching lines exist, the last one wins because the
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
- matches = list(_TEXT_CITATION_LINE_RE.finditer(text or ""))
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
- return _parse_id_tokens(matches[-1].group("ids"))
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
- ids: list[str] = []
145
- for tok in _SPLIT_RE.split(raw_ids.strip()):
146
- if clean := _CLEAN_ID_RE.match(tok):
147
- ids.append(clean.group(1).lower())
148
- return ids
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 ""