claude-smart 0.2.31 → 0.2.33

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 (60) hide show
  1. package/README.md +2 -2
  2. package/bin/claude-smart.js +222 -20
  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 +4 -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 +21 -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 +101 -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 +138 -24
  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 +103 -0
  55. package/plugin/src/claude_smart/events/stop.py +32 -6
  56. package/plugin/src/claude_smart/ids.py +32 -0
  57. package/plugin/src/claude_smart/internal_call.py +30 -0
  58. package/plugin/src/claude_smart/publish.py +8 -3
  59. package/plugin/src/claude_smart/reflexio_adapter.py +106 -29
  60. package/plugin/uv.lock +1 -1
@@ -30,13 +30,15 @@ import shutil
30
30
  import subprocess
31
31
  import sys
32
32
  import time
33
+ import uuid
33
34
  from dataclasses import dataclass
34
35
  from pathlib import Path
35
36
 
36
- from claude_smart import context_format, ids, publish, state
37
+ from claude_smart import context_format, env_config, ids, publish, state
37
38
  from claude_smart.reflexio_adapter import Adapter
38
39
 
39
- _REFLEXIO_ENV_PATH = Path.home() / ".reflexio" / ".env"
40
+ _REFLEXIO_ENV_PATH = env_config.REFLEXIO_ENV_PATH
41
+ _MANAGED_REFLEXIO_URL = env_config.MANAGED_REFLEXIO_URL
40
42
  _DEFAULT_MARKETPLACE_SOURCE = "ReflexioAI/claude-smart"
41
43
  _PLUGIN_SPEC = "claude-smart@reflexioai"
42
44
  _CODEX_MARKETPLACE_NAME = "reflexioai"
@@ -70,6 +72,16 @@ _BACKEND_SCRIPT = _SCRIPTS_DIR / "backend-service.sh"
70
72
  _DASHBOARD_SCRIPT = _SCRIPTS_DIR / "dashboard-service.sh"
71
73
  _REFLEXIO_DIR = Path.home() / ".reflexio"
72
74
  _STATE_DIR = Path.home() / ".claude-smart"
75
+ _LOCAL_DATA_NOTICE = (
76
+ "Local data was kept so reinstalling claude-smart can reuse your learned "
77
+ "rules, sessions, logs, and local Reflexio data.\n"
78
+ "Kept folders:\n"
79
+ " ~/.claude-smart\n"
80
+ " ~/.reflexio\n"
81
+ "Delete them only if you want a full reset or need to remove local "
82
+ "claude-smart data from this machine:\n"
83
+ " rm -rf ~/.claude-smart ~/.reflexio\n"
84
+ )
73
85
  _INSTALL_FAILURE_MARKER = _STATE_DIR / "install-failed"
74
86
  _DEFAULT_STORAGE_ROOT = _REFLEXIO_DIR / "data"
75
87
  _REFLEXIO_CONFIG_PATH = _REFLEXIO_DIR / "configs" / "config_self-host-org.json"
@@ -127,9 +139,58 @@ def _seed_reflexio_env() -> list[str]:
127
139
  prefix = "" if not existing or existing.endswith("\n") else "\n"
128
140
  with _REFLEXIO_ENV_PATH.open("a") as fh:
129
141
  fh.write(prefix + "\n".join(f"{f}=1" for f in missing) + "\n")
142
+ _REFLEXIO_ENV_PATH.chmod(0o600)
130
143
  return missing
131
144
 
132
145
 
146
+ def _seed_managed_reflexio_env(*, api_key: str, reflexio_url: str) -> list[str]:
147
+ """Write managed Reflexio connection settings to ``~/.reflexio/.env``."""
148
+ user_id = _read_reflexio_env_value(env_config.REFLEXIO_USER_ID_ENV) or str(
149
+ uuid.uuid4()
150
+ )
151
+ return env_config.set_env_vars(
152
+ _REFLEXIO_ENV_PATH,
153
+ {
154
+ env_config.REFLEXIO_URL_ENV: reflexio_url,
155
+ env_config.REFLEXIO_API_KEY_ENV: api_key,
156
+ env_config.REFLEXIO_USER_ID_ENV: user_id,
157
+ },
158
+ )
159
+
160
+
161
+ def _read_reflexio_env_value(key: str) -> str:
162
+ try:
163
+ text = _REFLEXIO_ENV_PATH.read_text()
164
+ except OSError:
165
+ return ""
166
+ for line in text.splitlines():
167
+ parsed = env_config.parse_env_line(line)
168
+ if parsed is None:
169
+ continue
170
+ parsed_key, value = parsed
171
+ if parsed_key == key:
172
+ return value
173
+ return ""
174
+
175
+
176
+ def _semver_tuple(path: Path) -> tuple[int, int, int] | None:
177
+ parts = path.name.split(".", 2)
178
+ if len(parts) != 3:
179
+ return None
180
+ try:
181
+ patch = parts[2].split("-", 1)[0].split("+", 1)[0]
182
+ return (int(parts[0]), int(parts[1]), int(patch))
183
+ except ValueError:
184
+ return None
185
+
186
+
187
+ def _installed_plugin_sort_key(path: Path) -> tuple[int, int, int, int, float]:
188
+ version = _semver_tuple(path)
189
+ if version is not None:
190
+ return (1, *version, path.stat().st_mtime)
191
+ return (0, 0, 0, 0, path.stat().st_mtime)
192
+
193
+
133
194
  def _find_claude_code_plugin_root() -> Path | None:
134
195
  """Locate the installed Claude Code plugin root after native install."""
135
196
  cache_root = (
@@ -149,7 +210,7 @@ def _find_claude_code_plugin_root() -> Path | None:
149
210
  and (child / "scripts" / "smart-install.sh").is_file()
150
211
  ):
151
212
  candidates.append(child)
152
- candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True)
213
+ candidates.sort(key=_installed_plugin_sort_key, reverse=True)
153
214
  candidates.extend(
154
215
  [
155
216
  Path.home()
@@ -162,10 +223,9 @@ def _find_claude_code_plugin_root() -> Path | None:
162
223
  ]
163
224
  )
164
225
  for candidate in candidates:
165
- if (
166
- (candidate / "pyproject.toml").is_file()
167
- and (candidate / "scripts" / "smart-install.sh").is_file()
168
- ):
226
+ if (candidate / "pyproject.toml").is_file() and (
227
+ candidate / "scripts" / "smart-install.sh"
228
+ ).is_file():
169
229
  return candidate
170
230
  return None
171
231
 
@@ -735,7 +795,26 @@ def _register_codex_marketplace(root: Path) -> tuple[bool, str]:
735
795
  return False, output or "Codex CLI does not expose plugin marketplace commands"
736
796
 
737
797
 
738
- def cmd_install_codex(_args: argparse.Namespace) -> int:
798
+ def _configure_reflexio_setup(args: argparse.Namespace) -> None:
799
+ api_key = (getattr(args, "api_key", "") or "").strip()
800
+ if api_key:
801
+ reflexio_url = (
802
+ getattr(args, "reflexio_url", "") or _MANAGED_REFLEXIO_URL
803
+ ).strip()
804
+ added = _seed_managed_reflexio_env(api_key=api_key, reflexio_url=reflexio_url)
805
+ changed = ", ".join(added) if added else "managed Reflexio settings"
806
+ sys.stdout.write(
807
+ f"Configured {_REFLEXIO_ENV_PATH} for managed Reflexio "
808
+ f"({changed}; API key {env_config.mask_secret(api_key)}).\n"
809
+ )
810
+ return
811
+
812
+ added = _seed_reflexio_env()
813
+ if added:
814
+ sys.stdout.write(f"Seeded {_REFLEXIO_ENV_PATH} with {', '.join(added)}.\n")
815
+
816
+
817
+ def cmd_install_codex(args: argparse.Namespace) -> int:
739
818
  """Install the claude-smart plugin marketplace for Codex.
740
819
 
741
820
  Only supported from a source checkout — the wheel ships the Python
@@ -756,6 +835,7 @@ def cmd_install_codex(_args: argparse.Namespace) -> int:
756
835
  "error: 'uv' not found on PATH. Install uv or restart your shell.\n"
757
836
  )
758
837
  return 1
838
+ _configure_reflexio_setup(args)
759
839
 
760
840
  missing = _missing_codex_marketplace_files(_REPO_ROOT)
761
841
  if missing:
@@ -767,10 +847,6 @@ def cmd_install_codex(_args: argparse.Namespace) -> int:
767
847
  return 1
768
848
  marketplace_root = _prepare_codex_local_marketplace()
769
849
 
770
- added = _seed_reflexio_env()
771
- if added:
772
- sys.stdout.write(f"Seeded {_REFLEXIO_ENV_PATH} with {', '.join(added)}.\n")
773
-
774
850
  hooks_ok, hooks_msg = _enable_codex_plugin_hooks()
775
851
  if hooks_ok:
776
852
  sys.stdout.write(f"Enabled Codex plugin hooks ({hooks_msg}).\n")
@@ -860,6 +936,7 @@ def cmd_install(args: argparse.Namespace) -> int:
860
936
  "Install Claude Code first: https://claude.com/claude-code\n"
861
937
  )
862
938
  return 1
939
+ _configure_reflexio_setup(args)
863
940
 
864
941
  for cmd in (
865
942
  ["claude", "plugin", "marketplace", "add", args.source],
@@ -871,10 +948,6 @@ def cmd_install(args: argparse.Namespace) -> int:
871
948
  sys.stderr.write(f"error: {' '.join(cmd)} failed (exit {exc.returncode})\n")
872
949
  return exc.returncode or 1
873
950
 
874
- added = _seed_reflexio_env()
875
- if added:
876
- sys.stdout.write(f"Seeded {_REFLEXIO_ENV_PATH} with {', '.join(added)}.\n")
877
-
878
951
  bootstrapped, message = _bootstrap_claude_code_install()
879
952
  if not bootstrapped:
880
953
  sys.stderr.write(
@@ -893,7 +966,7 @@ def cmd_install(args: argparse.Namespace) -> int:
893
966
  return 0
894
967
 
895
968
 
896
- def cmd_update(_args: argparse.Namespace) -> int:
969
+ def cmd_update(args: argparse.Namespace) -> int:
897
970
  """Update claude-smart to the latest version via the native plugin CLI.
898
971
 
899
972
  Runs ``claude plugin update claude-smart@reflexioai``.
@@ -919,6 +992,8 @@ def cmd_update(_args: argparse.Namespace) -> int:
919
992
  return exc.returncode or 1
920
993
 
921
994
  sys.stdout.write("\nclaude-smart updated. Restart Claude Code to apply.\n")
995
+ if getattr(args, "api_key", ""):
996
+ _configure_reflexio_setup(args)
922
997
  return 0
923
998
 
924
999
 
@@ -953,8 +1028,7 @@ def cmd_uninstall(_args: argparse.Namespace) -> int:
953
1028
 
954
1029
  sys.stdout.write(
955
1030
  "\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"
1031
+ f"{_LOCAL_DATA_NOTICE}"
958
1032
  )
959
1033
  return 0
960
1034
 
@@ -991,8 +1065,8 @@ def cmd_uninstall_codex(_args: argparse.Namespace) -> int:
991
1065
  sys.stderr.write(f"warning: could not update {_CODEX_CONFIG_PATH}\n")
992
1066
  sys.stdout.write(
993
1067
  "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"
1068
+ "Codex's global hook feature flags were left in place.\n"
1069
+ f"{_LOCAL_DATA_NOTICE}"
996
1070
  )
997
1071
  return 0
998
1072
 
@@ -1026,6 +1100,13 @@ def cmd_show(args: argparse.Namespace) -> int:
1026
1100
  agent_playbooks=agent_playbooks,
1027
1101
  profiles=profiles,
1028
1102
  )
1103
+ if not md and adapter.read_errors:
1104
+ sys.stdout.write(
1105
+ "Could not read skills or preferences from Reflexio for "
1106
+ f"project `{project_id}` at `{adapter.url}`.\n"
1107
+ f"First error: {adapter.read_errors[0]}\n"
1108
+ )
1109
+ return 1
1029
1110
  sys.stdout.write(
1030
1111
  md or f"_No skills or preferences yet for project `{project_id}`._\n"
1031
1112
  )
@@ -1035,8 +1116,10 @@ def cmd_show(args: argparse.Namespace) -> int:
1035
1116
  def cmd_learn(args: argparse.Namespace) -> int:
1036
1117
  """Force reflexio extraction over the active session's interactions.
1037
1118
 
1038
- Publishes unpublished interactions with ``force_extraction=True`` so
1039
- extraction runs immediately rather than at the next batch interval.
1119
+ Publishes unpublished interactions with ``force_extraction=True`` and
1120
+ ``override_learning_stall=True`` as an explicit retry request rather than
1121
+ waiting for the next batch interval. The publish call itself remains
1122
+ non-blocking: ``Adapter.publish`` sends ``wait_for_response=False``.
1040
1123
  When ``args.note`` is provided, the note is appended to the session
1041
1124
  buffer as a neutral User turn before publishing — letting the user
1042
1125
  capture an explicit insight, preference, or workflow note alongside
@@ -1074,6 +1157,7 @@ def cmd_learn(args: argparse.Namespace) -> int:
1074
1157
  session_id=session_id,
1075
1158
  project_id=project_id,
1076
1159
  force_extraction=True,
1160
+ override_learning_stall=True,
1077
1161
  skip_aggregation=False,
1078
1162
  )
1079
1163
  if status == "ok":
@@ -1573,9 +1657,15 @@ def _resolve_reflexio_url() -> str:
1573
1657
  str: The ``REFLEXIO_URL`` env var if set, otherwise the default
1574
1658
  local backend endpoint.
1575
1659
  """
1660
+ env_config.load_reflexio_env()
1576
1661
  return os.environ.get("REFLEXIO_URL", "http://localhost:8071/")
1577
1662
 
1578
1663
 
1664
+ def _resolve_reflexio_api_key() -> str:
1665
+ env_config.load_reflexio_env()
1666
+ return os.environ.get("REFLEXIO_API_KEY", "")
1667
+
1668
+
1579
1669
  def _format_deleted_counts(deleted_counts: object) -> str:
1580
1670
  """Render ``deleted_counts`` from a ``clear_user_data`` response as a summary.
1581
1671
 
@@ -1644,7 +1734,10 @@ def cmd_clear_user(args: argparse.Namespace) -> int:
1644
1734
  return 1
1645
1735
 
1646
1736
  try:
1647
- client = ReflexioClient(url_endpoint=_resolve_reflexio_url())
1737
+ client = ReflexioClient(
1738
+ url_endpoint=_resolve_reflexio_url(),
1739
+ api_key=_resolve_reflexio_api_key(),
1740
+ )
1648
1741
  # The companion reflexio PR adds ``clear_user_data``; keep the
1649
1742
  # CLI buildable until both sides ship together.
1650
1743
  response = client.clear_user_data(user_id) # pyright: ignore[reportAttributeAccessIssue]
@@ -1676,9 +1769,29 @@ def _build_parser() -> argparse.ArgumentParser:
1676
1769
  default=_DEFAULT_MARKETPLACE_SOURCE,
1677
1770
  help="Marketplace ref — GitHub owner/repo, or a local directory path",
1678
1771
  )
1772
+ inst.add_argument(
1773
+ "--api-key",
1774
+ default="",
1775
+ help="Configure managed Reflexio with this API key instead of local mode",
1776
+ )
1777
+ inst.add_argument(
1778
+ "--reflexio-url",
1779
+ default=_MANAGED_REFLEXIO_URL,
1780
+ help="Managed Reflexio URL used only when --api-key is provided",
1781
+ )
1679
1782
  inst.set_defaults(func=cmd_install)
1680
1783
 
1681
1784
  upd = sub.add_parser("update", help="Update claude-smart to the latest version")
1785
+ upd.add_argument(
1786
+ "--api-key",
1787
+ default="",
1788
+ help="Configure managed Reflexio with this API key after updating",
1789
+ )
1790
+ upd.add_argument(
1791
+ "--reflexio-url",
1792
+ default=_MANAGED_REFLEXIO_URL,
1793
+ help="Managed Reflexio URL used only when --api-key is provided",
1794
+ )
1682
1795
  upd.set_defaults(func=cmd_update)
1683
1796
 
1684
1797
  uni = sub.add_parser("uninstall", help="Remove claude-smart")
@@ -1767,6 +1880,7 @@ def _build_parser() -> argparse.ArgumentParser:
1767
1880
 
1768
1881
 
1769
1882
  def main(argv: list[str] | None = None) -> int:
1883
+ env_config.load_reflexio_env()
1770
1884
  parser = _build_parser()
1771
1885
  args = parser.parse_args(argv)
1772
1886
  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,