claude-smart 0.2.31 → 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 (59) 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/pyproject.toml +1 -1
  43. package/plugin/scripts/_lib.sh +61 -0
  44. package/plugin/scripts/backend-service.sh +13 -2
  45. package/plugin/scripts/cli.sh +1 -0
  46. package/plugin/scripts/codex-hook.js +100 -3
  47. package/plugin/scripts/dashboard-service.sh +95 -19
  48. package/plugin/scripts/hook_entry.sh +13 -7
  49. package/plugin/scripts/smart-install.sh +18 -13
  50. package/plugin/src/claude_smart/cli.py +86 -20
  51. package/plugin/src/claude_smart/context_format.py +244 -6
  52. package/plugin/src/claude_smart/context_inject.py +8 -1
  53. package/plugin/src/claude_smart/cs_cite.py +186 -34
  54. package/plugin/src/claude_smart/env_config.py +102 -0
  55. package/plugin/src/claude_smart/events/stop.py +32 -6
  56. package/plugin/src/claude_smart/internal_call.py +30 -0
  57. package/plugin/src/claude_smart/publish.py +5 -0
  58. package/plugin/src/claude_smart/reflexio_adapter.py +102 -26
  59. package/plugin/uv.lock +1 -1
@@ -33,10 +33,11 @@ import time
33
33
  from dataclasses import dataclass
34
34
  from pathlib import Path
35
35
 
36
- from claude_smart import context_format, ids, publish, state
36
+ from claude_smart import context_format, env_config, ids, publish, state
37
37
  from claude_smart.reflexio_adapter import Adapter
38
38
 
39
- _REFLEXIO_ENV_PATH = Path.home() / ".reflexio" / ".env"
39
+ _REFLEXIO_ENV_PATH = env_config.REFLEXIO_ENV_PATH
40
+ _MANAGED_REFLEXIO_URL = env_config.MANAGED_REFLEXIO_URL
40
41
  _DEFAULT_MARKETPLACE_SOURCE = "ReflexioAI/claude-smart"
41
42
  _PLUGIN_SPEC = "claude-smart@reflexioai"
42
43
  _CODEX_MARKETPLACE_NAME = "reflexioai"
@@ -70,6 +71,16 @@ _BACKEND_SCRIPT = _SCRIPTS_DIR / "backend-service.sh"
70
71
  _DASHBOARD_SCRIPT = _SCRIPTS_DIR / "dashboard-service.sh"
71
72
  _REFLEXIO_DIR = Path.home() / ".reflexio"
72
73
  _STATE_DIR = Path.home() / ".claude-smart"
74
+ _LOCAL_DATA_NOTICE = (
75
+ "Local data was kept so reinstalling claude-smart can reuse your learned "
76
+ "rules, sessions, logs, and local Reflexio data.\n"
77
+ "Kept folders:\n"
78
+ " ~/.claude-smart\n"
79
+ " ~/.reflexio\n"
80
+ "Delete them only if you want a full reset or need to remove local "
81
+ "claude-smart data from this machine:\n"
82
+ " rm -rf ~/.claude-smart ~/.reflexio\n"
83
+ )
73
84
  _INSTALL_FAILURE_MARKER = _STATE_DIR / "install-failed"
74
85
  _DEFAULT_STORAGE_ROOT = _REFLEXIO_DIR / "data"
75
86
  _REFLEXIO_CONFIG_PATH = _REFLEXIO_DIR / "configs" / "config_self-host-org.json"
@@ -127,9 +138,21 @@ def _seed_reflexio_env() -> list[str]:
127
138
  prefix = "" if not existing or existing.endswith("\n") else "\n"
128
139
  with _REFLEXIO_ENV_PATH.open("a") as fh:
129
140
  fh.write(prefix + "\n".join(f"{f}=1" for f in missing) + "\n")
141
+ _REFLEXIO_ENV_PATH.chmod(0o600)
130
142
  return missing
131
143
 
132
144
 
145
+ def _seed_managed_reflexio_env(*, api_key: str, reflexio_url: str) -> list[str]:
146
+ """Write managed Reflexio connection settings to ``~/.reflexio/.env``."""
147
+ return env_config.set_env_vars(
148
+ _REFLEXIO_ENV_PATH,
149
+ {
150
+ env_config.REFLEXIO_URL_ENV: reflexio_url,
151
+ env_config.REFLEXIO_API_KEY_ENV: api_key,
152
+ },
153
+ )
154
+
155
+
133
156
  def _find_claude_code_plugin_root() -> Path | None:
134
157
  """Locate the installed Claude Code plugin root after native install."""
135
158
  cache_root = (
@@ -162,10 +185,9 @@ def _find_claude_code_plugin_root() -> Path | None:
162
185
  ]
163
186
  )
164
187
  for candidate in candidates:
165
- if (
166
- (candidate / "pyproject.toml").is_file()
167
- and (candidate / "scripts" / "smart-install.sh").is_file()
168
- ):
188
+ if (candidate / "pyproject.toml").is_file() and (
189
+ candidate / "scripts" / "smart-install.sh"
190
+ ).is_file():
169
191
  return candidate
170
192
  return None
171
193
 
@@ -735,7 +757,26 @@ def _register_codex_marketplace(root: Path) -> tuple[bool, str]:
735
757
  return False, output or "Codex CLI does not expose plugin marketplace commands"
736
758
 
737
759
 
738
- def cmd_install_codex(_args: argparse.Namespace) -> int:
760
+ def _configure_reflexio_setup(args: argparse.Namespace) -> None:
761
+ api_key = (getattr(args, "api_key", "") or "").strip()
762
+ if api_key:
763
+ reflexio_url = (
764
+ getattr(args, "reflexio_url", "") or _MANAGED_REFLEXIO_URL
765
+ ).strip()
766
+ added = _seed_managed_reflexio_env(api_key=api_key, reflexio_url=reflexio_url)
767
+ changed = ", ".join(added) if added else "managed Reflexio settings"
768
+ sys.stdout.write(
769
+ f"Configured {_REFLEXIO_ENV_PATH} for managed Reflexio "
770
+ f"({changed}; API key {env_config.mask_secret(api_key)}).\n"
771
+ )
772
+ return
773
+
774
+ added = _seed_reflexio_env()
775
+ if added:
776
+ sys.stdout.write(f"Seeded {_REFLEXIO_ENV_PATH} with {', '.join(added)}.\n")
777
+
778
+
779
+ def cmd_install_codex(args: argparse.Namespace) -> int:
739
780
  """Install the claude-smart plugin marketplace for Codex.
740
781
 
741
782
  Only supported from a source checkout — the wheel ships the Python
@@ -767,9 +808,7 @@ def cmd_install_codex(_args: argparse.Namespace) -> int:
767
808
  return 1
768
809
  marketplace_root = _prepare_codex_local_marketplace()
769
810
 
770
- added = _seed_reflexio_env()
771
- if added:
772
- sys.stdout.write(f"Seeded {_REFLEXIO_ENV_PATH} with {', '.join(added)}.\n")
811
+ _configure_reflexio_setup(args)
773
812
 
774
813
  hooks_ok, hooks_msg = _enable_codex_plugin_hooks()
775
814
  if hooks_ok:
@@ -871,9 +910,7 @@ def cmd_install(args: argparse.Namespace) -> int:
871
910
  sys.stderr.write(f"error: {' '.join(cmd)} failed (exit {exc.returncode})\n")
872
911
  return exc.returncode or 1
873
912
 
874
- added = _seed_reflexio_env()
875
- if added:
876
- sys.stdout.write(f"Seeded {_REFLEXIO_ENV_PATH} with {', '.join(added)}.\n")
913
+ _configure_reflexio_setup(args)
877
914
 
878
915
  bootstrapped, message = _bootstrap_claude_code_install()
879
916
  if not bootstrapped:
@@ -953,8 +990,7 @@ def cmd_uninstall(_args: argparse.Namespace) -> int:
953
990
 
954
991
  sys.stdout.write(
955
992
  "\nclaude-smart uninstalled. Restart Claude Code to apply.\n"
956
- "Local data in ~/.reflexio/ and ~/.claude-smart/ was left in place — "
957
- "remove manually if desired.\n"
993
+ f"{_LOCAL_DATA_NOTICE}"
958
994
  )
959
995
  return 0
960
996
 
@@ -991,8 +1027,8 @@ def cmd_uninstall_codex(_args: argparse.Namespace) -> int:
991
1027
  sys.stderr.write(f"warning: could not update {_CODEX_CONFIG_PATH}\n")
992
1028
  sys.stdout.write(
993
1029
  "claude-smart Codex plugin and marketplace state removed. Restart Codex to apply. "
994
- "Codex's global hook feature flags and local data under ~/.reflexio "
995
- "and ~/.claude-smart were left in place.\n"
1030
+ "Codex's global hook feature flags were left in place.\n"
1031
+ f"{_LOCAL_DATA_NOTICE}"
996
1032
  )
997
1033
  return 0
998
1034
 
@@ -1026,6 +1062,13 @@ def cmd_show(args: argparse.Namespace) -> int:
1026
1062
  agent_playbooks=agent_playbooks,
1027
1063
  profiles=profiles,
1028
1064
  )
1065
+ if not md and adapter.read_errors:
1066
+ sys.stdout.write(
1067
+ "Could not read skills or preferences from Reflexio for "
1068
+ f"project `{project_id}` at `{adapter.url}`.\n"
1069
+ f"First error: {adapter.read_errors[0]}\n"
1070
+ )
1071
+ return 1
1029
1072
  sys.stdout.write(
1030
1073
  md or f"_No skills or preferences yet for project `{project_id}`._\n"
1031
1074
  )
@@ -1035,8 +1078,10 @@ def cmd_show(args: argparse.Namespace) -> int:
1035
1078
  def cmd_learn(args: argparse.Namespace) -> int:
1036
1079
  """Force reflexio extraction over the active session's interactions.
1037
1080
 
1038
- Publishes unpublished interactions with ``force_extraction=True`` so
1039
- extraction runs immediately rather than at the next batch interval.
1081
+ Publishes unpublished interactions with ``force_extraction=True`` and
1082
+ ``override_learning_stall=True`` as an explicit retry request rather than
1083
+ waiting for the next batch interval. The publish call itself remains
1084
+ non-blocking: ``Adapter.publish`` sends ``wait_for_response=False``.
1040
1085
  When ``args.note`` is provided, the note is appended to the session
1041
1086
  buffer as a neutral User turn before publishing — letting the user
1042
1087
  capture an explicit insight, preference, or workflow note alongside
@@ -1074,6 +1119,7 @@ def cmd_learn(args: argparse.Namespace) -> int:
1074
1119
  session_id=session_id,
1075
1120
  project_id=project_id,
1076
1121
  force_extraction=True,
1122
+ override_learning_stall=True,
1077
1123
  skip_aggregation=False,
1078
1124
  )
1079
1125
  if status == "ok":
@@ -1573,9 +1619,15 @@ def _resolve_reflexio_url() -> str:
1573
1619
  str: The ``REFLEXIO_URL`` env var if set, otherwise the default
1574
1620
  local backend endpoint.
1575
1621
  """
1622
+ env_config.load_reflexio_env()
1576
1623
  return os.environ.get("REFLEXIO_URL", "http://localhost:8071/")
1577
1624
 
1578
1625
 
1626
+ def _resolve_reflexio_api_key() -> str:
1627
+ env_config.load_reflexio_env()
1628
+ return os.environ.get("REFLEXIO_API_KEY", "")
1629
+
1630
+
1579
1631
  def _format_deleted_counts(deleted_counts: object) -> str:
1580
1632
  """Render ``deleted_counts`` from a ``clear_user_data`` response as a summary.
1581
1633
 
@@ -1644,7 +1696,10 @@ def cmd_clear_user(args: argparse.Namespace) -> int:
1644
1696
  return 1
1645
1697
 
1646
1698
  try:
1647
- client = ReflexioClient(url_endpoint=_resolve_reflexio_url())
1699
+ client = ReflexioClient(
1700
+ url_endpoint=_resolve_reflexio_url(),
1701
+ api_key=_resolve_reflexio_api_key(),
1702
+ )
1648
1703
  # The companion reflexio PR adds ``clear_user_data``; keep the
1649
1704
  # CLI buildable until both sides ship together.
1650
1705
  response = client.clear_user_data(user_id) # pyright: ignore[reportAttributeAccessIssue]
@@ -1676,6 +1731,16 @@ def _build_parser() -> argparse.ArgumentParser:
1676
1731
  default=_DEFAULT_MARKETPLACE_SOURCE,
1677
1732
  help="Marketplace ref — GitHub owner/repo, or a local directory path",
1678
1733
  )
1734
+ inst.add_argument(
1735
+ "--api-key",
1736
+ default="",
1737
+ help="Configure managed Reflexio with this API key instead of local mode",
1738
+ )
1739
+ inst.add_argument(
1740
+ "--reflexio-url",
1741
+ default=_MANAGED_REFLEXIO_URL,
1742
+ help="Managed Reflexio URL used only when --api-key is provided",
1743
+ )
1679
1744
  inst.set_defaults(func=cmd_install)
1680
1745
 
1681
1746
  upd = sub.add_parser("update", help="Update claude-smart to the latest version")
@@ -1767,6 +1832,7 @@ def _build_parser() -> argparse.ArgumentParser:
1767
1832
 
1768
1833
 
1769
1834
  def main(argv: list[str] | None = None) -> int:
1835
+ env_config.load_reflexio_env()
1770
1836
  parser = _build_parser()
1771
1837
  args = parser.parse_args(argv)
1772
1838
  return args.func(args)
@@ -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,