Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DEVELOPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Tunables read by the plugin at runtime. Most users don't need to touch these —
| `EMBEDDING_PORT` | `8072` | Local embedding daemon port. Shared by Claude Code and Codex on the same machine. |
| `CLAUDE_SMART_ENABLE_OPTIMIZER` | enabled unless set to `0` | Hook-side env var that controls shared skill optimization and rollups during `SessionStart`. Set it in Claude Code settings, not `~/.reflexio/.env`. |
| `CLAUDE_SMART_CITATIONS` | `on` | Controls the final `✨ claude-smart rule applied` marker instruction. `on` (default) injects a compact instruction asking the assistant to cite only memories that materially changed the answer. `off` skips injection of the citation instruction entirely and strips any stray marker line from the assistant text before publishing. Legacy values `auto` and `marker-only` are accepted as enabled aliases. Set in Claude Code settings (hook env), not `~/.reflexio/.env`. |
| `CLAUDE_SMART_CITATION_LINK_STYLE` | `markdown` | Controls the visible links in the final `✨ claude-smart rule applied` marker. `markdown` asks the model to emit `[human title](dashboard URL)` links. `osc8` asks it to emit terminal-native OSC 8 hyperlinks so the human title can be clickable without showing the URL; unsupported terminals should fall back to markdown links. |
| `CLAUDE_SMART_CITATION_LINK_STYLE` | `markdown` | Controls the visible links in the final `✨ claude-smart rule applied` marker. `markdown` asks the model to emit `[human title](dashboard URL)` links and is the Codex default because Codex renders markdown links in assistant messages. `osc8` asks it to emit terminal-native OSC 8 hyperlinks so the human title can be clickable without showing the URL; unsupported terminals should fall back to markdown links. |
| `CLAUDE_SMART_DASHBOARD_URL` | `http://localhost:3001` | Base URL used in injected citation targets and the final `✨ claude-smart rule applied` marker links. Override only if the local dashboard is exposed on another host or port. Model-facing citation links use short `/rules/{citationId}` resolver URLs; canonical direct links remain `/skills/project/{id}`, `/skills/shared/{id}`, and `/preferences/project/{id}`. `/preferences/{id}` remains a compatibility alias. |
| `CLAUDE_SMART_CLI_PATH` | `claude` on Claude Code; Codex compatibility wrapper or `codex` on Codex | Override the executable Reflexio calls for local generation. |
| `CLAUDE_SMART_STATE_DIR` | `~/.claude-smart/sessions/` | Where the per-session JSONL buffer lives. |
Expand Down
6 changes: 6 additions & 0 deletions MANAGED_REFLEXIO.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,12 @@ Open the dashboard the same way as local mode:
- Claude Code: `/claude-smart:dashboard`
- Codex: `bash ~/.reflexio/plugin-root/scripts/dashboard-open.sh`

When managed citations include stored Reflexio IDs, claude-smart links directly
to the managed Reflexio profile or playbook page, for example
`https://www.reflexio.ai/profiles?profile_id=...` or
`https://www.reflexio.ai/playbooks?resource=user_playbook&user_playbook_id=...`.
Items without stored IDs fall back to the managed list page.

## Troubleshooting

If `npx claude-smart setup` prints `unknown command 'setup'`, the machine is
Expand Down
2 changes: 1 addition & 1 deletion plugin/scripts/codex-hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ function runHook(root, event) {
REFLEXIO_URL: readBackendUrl(),
CLAUDE_SMART_HOST: "codex",
CLAUDE_SMART_CLI_PATH: process.env.CLAUDE_SMART_CLI_PATH || codexCompatPath(root),
CLAUDE_SMART_CITATION_LINK_STYLE: process.env.CLAUDE_SMART_CITATION_LINK_STYLE || "osc8",
CLAUDE_SMART_CITATION_LINK_STYLE: process.env.CLAUDE_SMART_CITATION_LINK_STYLE || "markdown",
},
input,
stdio: ["pipe", "pipe", "inherit"],
Expand Down
7 changes: 4 additions & 3 deletions plugin/scripts/hook_entry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ case "$EVENT" in
esac
export CLAUDE_SMART_HOST="$HOST"
if [ "$HOST" = "codex" ] && [ -z "${CLAUDE_SMART_CITATION_LINK_STYLE:-}" ]; then
export CLAUDE_SMART_CITATION_LINK_STYLE="osc8"
export CLAUDE_SMART_CITATION_LINK_STYLE="markdown"
fi

HERE="$(cd "$(dirname "$0")" && pwd)"
Expand Down Expand Up @@ -59,8 +59,9 @@ if [ -f "$FAILURE_MARKER" ]; then
if [ -z "$stored_fp" ] || [ "$stored_fp" != "$current_fp" ]; then
rm -f "$FAILURE_MARKER"
else
if [ "$EVENT" = "session-start" ] && command -v python3 >/dev/null 2>&1; then
python3 - "$FAILURE_MARKER" <<'PY'
failure_py="$(claude_smart_resolve_python 2>/dev/null || true)"
if [ "$EVENT" = "session-start" ] && [ -n "$failure_py" ]; then
"$failure_py" - "$FAILURE_MARKER" <<'PY'
import json, pathlib, sys
first = pathlib.Path(sys.argv[1]).read_text().splitlines()
msg = (first[0].strip() if first else "") or "unknown error"
Expand Down
55 changes: 50 additions & 5 deletions plugin/src/claude_smart/context_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ def render_inline_compact_with_registry(
item = f"{content} (title: {title}"
if rule_url:
item += f"; open: {rule_url}"
marker_parts.append(
_markdown_link(
rule_url, _strip_trailing_sentence_punctuation(title)
)
)
item += ")"
if entry.get("kind") == "profile":
preference_parts.append(item)
Expand Down Expand Up @@ -280,6 +285,17 @@ def _compact_citation_instruction(marker_parts: list[str] | None = None) -> str:
"the same linked memory text; keep the link, but do not show the "
"URL. Skip when unrelated."
)
if marker_parts:
marker = f"✨ claude-smart rule applied: {' | '.join(marker_parts)}"
separator_instruction = (
" Separate multiple linked memories with the visible ` | ` separator."
if len(marker_parts) > 1
else ""
)
return _remoteize_citation_instruction(
f"If used, copy this final marker exactly with markdown links: "
f"`{marker}`.{separator_instruction} Skip when unrelated."
)
return _remoteize_citation_instruction(
"Only if a listed [cs:...] item materially changes your answer, end "
"with one final marker like `✨ claude-smart rule applied: "
Expand Down Expand Up @@ -368,7 +384,7 @@ def _append_playbook_bullet(
item_id = cs_cite.rank_id("playbook", rank, real_id)
title = _title_from_content(content)
dashboard_url = _dashboard_url("playbook", real_id, source_kind)
rule_url = _rule_url(item_id, "playbook")
rule_url = _rule_url(item_id, "playbook", real_id, source_kind)
bullet = f"- [cs:{item_id}] {content}"
if trigger:
bullet += f" _(when: {trigger})_"
Expand Down Expand Up @@ -407,7 +423,7 @@ def _format_profiles(
item_id = cs_cite.rank_id("profile", rank, real_id)
title = _title_from_content(content)
dashboard_url = _dashboard_url("profile", real_id)
rule_url = _rule_url(item_id, "profile")
rule_url = _rule_url(item_id, "profile", real_id)
bullet = f"- [cs:{item_id}] {content}"
if rule_url:
bullet += f" _(open: {rule_url})_"
Expand All @@ -427,7 +443,7 @@ def _format_profiles(


def _dashboard_url(kind: str, real_id: Any, source_kind: str | None = None) -> str:
remote_url = _remote_reflexio_page_url(kind)
remote_url = _remote_reflexio_item_url(kind, real_id, source_kind)
if remote_url:
return remote_url
if real_id is None:
Expand All @@ -442,8 +458,10 @@ def _dashboard_url(kind: str, real_id: Any, source_kind: str | None = None) -> s
return ""


def _rule_url(item_id: str, kind: str) -> str:
remote_url = _remote_reflexio_page_url(kind)
def _rule_url(
item_id: str, kind: str, real_id: Any = None, source_kind: str | None = None
) -> str:
remote_url = _remote_reflexio_item_url(kind, real_id, source_kind)
if remote_url:
return remote_url
if not item_id:
Expand All @@ -453,6 +471,27 @@ def _rule_url(item_id: str, kind: str) -> str:
return f"{base}/rules/{encoded_id}"


def _remote_reflexio_item_url(
kind: str, real_id: Any, source_kind: str | None = None
) -> str:
origin = _remote_reflexio_origin()
if not origin:
return ""
if real_id is None:
return _remote_reflexio_page_url(kind)
encoded_id = quote(str(real_id), safe="")
if kind == "profile":
return f"{origin}/profiles?profile_id={encoded_id}"
if kind == "playbook":
if source_kind == "user_playbook":
return (
f"{origin}/playbooks?resource=user_playbook&"
f"user_playbook_id={encoded_id}"
)
return f"{origin}/playbooks?agent_playbook_id={encoded_id}"
return ""


def _remote_reflexio_page_url(kind: str) -> str:
origin = _remote_reflexio_origin()
if not origin:
Expand Down Expand Up @@ -504,6 +543,12 @@ def _osc8_link(url: str, label: str) -> str:
return f"\x1b]8;;{url}\x1b\\{label}\x1b]8;;\x1b\\"


def _markdown_link(url: str, label: str) -> str:
safe_label = label.replace("[", "\\[").replace("]", "\\]")
safe_url = url.replace(")", "%29")
return f"[{safe_label}]({safe_url})"


def _strip_trailing_sentence_punctuation(text: str) -> str:
return text.rstrip().rstrip(".!?")

Expand Down
13 changes: 12 additions & 1 deletion plugin/src/claude_smart/cs_cite.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@

import re
from typing import Any
from urllib.parse import unquote, urlparse
from urllib.parse import parse_qs, unquote, urlparse

_FINGERPRINT_LEN = 4

Expand Down Expand Up @@ -287,6 +287,17 @@ def dashboard_url_token(url: str) -> str:
parsed = urlparse(url)
path = parsed.path if parsed.scheme else url.split("?", 1)[0].split("#", 1)[0]
parts = [unquote(part) for part in path.strip("/").split("/") if part]
query = parse_qs(parsed.query)
if len(parts) == 1 and parts[0] == "profiles":
profile_ids = query.get("profile_id") or []
return f"route:profile:profile:{profile_ids[0]}" if profile_ids else ""
if len(parts) == 1 and parts[0] == "playbooks":
user_playbook_ids = query.get("user_playbook_id") or []
if query.get("resource") == ["user_playbook"] and user_playbook_ids:
return f"route:playbook:user_playbook:{user_playbook_ids[0]}"
agent_playbook_ids = query.get("agent_playbook_id") or []
if agent_playbook_ids:
return f"route:playbook:agent_playbook:{agent_playbook_ids[0]}"
if len(parts) == 3 and parts[0] == "skills" and parts[1] in {"project", "shared"}:
source_kind = "user_playbook" if parts[1] == "project" else "agent_playbook"
return f"route:playbook:{source_kind}:{parts[2]}"
Expand Down
133 changes: 110 additions & 23 deletions tests/test_context_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ def test_render_inline_with_registry_can_inject_osc8_instruction(monkeypatch) ->
def test_render_inline_compact_with_registry_is_one_logical_line(
monkeypatch,
) -> None:
monkeypatch.setenv("CLAUDE_SMART_CITATION_LINK_STYLE", "osc8")
monkeypatch.delenv("CLAUDE_SMART_CITATION_LINK_STYLE", raising=False)
md, registry = context_format.render_inline_compact_with_registry(
project_id="demo",
user_playbooks=[
Expand All @@ -270,26 +270,47 @@ def test_render_inline_compact_with_registry_is_one_logical_line(
assert "[cs:" not in md
assert "claude-smart: using relevant memory. Skill:" in md
assert "Preference:" in md
assert "\x1b]8;;http://localhost:3001/rules/s1-17\x1b\\" in md
assert "Run uv sync after pyproject edits" in md
assert "Then run an import smoke test before committing" in md
assert "Run uv sync after pyproject edits: Run uv sync" not in md
assert "title:" not in md
assert "\x1b]8;;http://localhost:3001/rules/p1-pref\x1b\\" in md
assert "title: Run uv sync after pyproject edits" in md
assert "open: http://localhost:3001/rules/s1-17" in md
assert "prefers concise answers" in md
assert "✨ claude-smart rule applied:" in md
assert md.count("✨ claude-smart rule applied:") == 1
assert "preserving its hidden OSC 8 terminal link" in md
assert "copy this final marker exactly with markdown links" in md
assert (
"✨ claude-smart rule applied: "
"\x1b]8;;http://localhost:3001/rules/s1-17\x1b\\"
"Run uv sync after pyproject edits"
"\x1b]8;;\x1b\\ | "
"\x1b]8;;http://localhost:3001/rules/p1-pref\x1b\\"
"prefers concise answers"
"\x1b]8;;\x1b\\"
"[Run uv sync after pyproject edits](http://localhost:3001/rules/s1-17) | "
"[prefers concise answers](http://localhost:3001/rules/p1-pref)"
) in md
assert "visible ` | ` separator" in md
assert "\x1b]8;;" not in md
assert {entry["id"] for entry in registry} == {"s1-17", "p1-pref"}


def test_render_inline_compact_with_registry_can_emit_osc8_when_requested(
monkeypatch,
) -> None:
monkeypatch.setenv("CLAUDE_SMART_CITATION_LINK_STYLE", "osc8")
md, registry = context_format.render_inline_compact_with_registry(
project_id="demo",
user_playbooks=[
{
"content": (
"Run uv sync after pyproject edits. "
"Then run an import smoke test before committing."
),
"user_playbook_id": 17,
}
],
agent_playbooks=[],
profiles=[{"content": "prefers concise answers", "profile_id": "pref"}],
)

assert "\x1b]8;;http://localhost:3001/rules/s1-17\x1b\\" in md
assert "\x1b]8;;http://localhost:3001/rules/p1-pref\x1b\\" in md
assert "preserving its hidden OSC 8 terminal link" in md
assert "open: http://localhost:3001/rules/s1-17" not in md
assert {entry["id"] for entry in registry} == {"s1-17", "p1-pref"}

Expand Down Expand Up @@ -333,7 +354,7 @@ def test_render_inline_with_registry_includes_dashboard_urls(monkeypatch) -> Non
assert by_id["p1-pref"]["rule_url"] == "http://127.0.0.1:3333/rules/p1-pref"


def test_render_inline_with_registry_uses_remote_reflexio_list_pages(
def test_render_inline_with_registry_uses_remote_reflexio_item_pages(
monkeypatch,
) -> None:
monkeypatch.setenv("REFLEXIO_URL", "https://www.reflexio.ai/")
Expand All @@ -344,19 +365,64 @@ def test_render_inline_with_registry_uses_remote_reflexio_list_pages(
profiles=[{"content": "prefers concise answers", "profile_id": "pref/one"}],
)

assert "open: https://www.reflexio.ai/playbooks" in md
assert "open: https://www.reflexio.ai/profiles" in md
assert "open: https://www.reflexio.ai/playbooks?agent_playbook_id=42" in md
assert (
"open: https://www.reflexio.ai/playbooks?"
"resource=user_playbook&user_playbook_id=17"
) in md
assert "open: https://www.reflexio.ai/profiles?profile_id=pref%2Fone" in md
assert "/rules/" not in md
by_id = {e["id"]: e for e in registry}
assert by_id["s1-42"]["dashboard_url"] == "https://www.reflexio.ai/playbooks"
assert by_id["s1-42"]["rule_url"] == "https://www.reflexio.ai/playbooks"
assert by_id["s2-17"]["dashboard_url"] == "https://www.reflexio.ai/playbooks"
assert by_id["s2-17"]["rule_url"] == "https://www.reflexio.ai/playbooks"
assert by_id["p1-pref"]["dashboard_url"] == "https://www.reflexio.ai/profiles"
assert by_id["p1-pref"]["rule_url"] == "https://www.reflexio.ai/profiles"
assert (
by_id["s1-42"]["dashboard_url"]
== "https://www.reflexio.ai/playbooks?agent_playbook_id=42"
)
assert (
by_id["s1-42"]["rule_url"]
== "https://www.reflexio.ai/playbooks?agent_playbook_id=42"
)
assert (
by_id["s2-17"]["dashboard_url"]
== "https://www.reflexio.ai/playbooks?resource=user_playbook&user_playbook_id=17"
)
assert (
by_id["s2-17"]["rule_url"]
== "https://www.reflexio.ai/playbooks?resource=user_playbook&user_playbook_id=17"
)
assert (
by_id["p1-pref"]["dashboard_url"]
== "https://www.reflexio.ai/profiles?profile_id=pref%2Fone"
)
assert (
by_id["p1-pref"]["rule_url"]
== "https://www.reflexio.ai/profiles?profile_id=pref%2Fone"
)


def test_render_inline_osc8_uses_remote_reflexio_list_pages(monkeypatch) -> None:
def test_render_inline_compact_uses_remote_reflexio_item_pages(monkeypatch) -> None:
monkeypatch.setenv("REFLEXIO_URL", "https://www.reflexio.ai/")

md, _ = context_format.render_inline_compact_with_registry(
project_id="demo",
user_playbooks=[{"content": "use safe git flow", "user_playbook_id": 17}],
agent_playbooks=[{"content": "use shared flow", "agent_playbook_id": 42}],
profiles=[{"content": "prefers concise answers", "profile_id": "pref/one"}],
)

assert (
"[use shared flow](https://www.reflexio.ai/playbooks?agent_playbook_id=42)"
) in md
assert (
"[use safe git flow](https://www.reflexio.ai/playbooks?"
"resource=user_playbook&user_playbook_id=17)"
) in md
assert (
"[prefers concise answers](https://www.reflexio.ai/profiles?profile_id=pref%2Fone)"
) in md
assert "http://localhost:3001/rules/" not in md


def test_render_inline_osc8_uses_remote_reflexio_item_pages(monkeypatch) -> None:
monkeypatch.setenv("REFLEXIO_URL", "https://www.reflexio.ai/")
monkeypatch.setenv("CLAUDE_SMART_CITATION_LINK_STYLE", "osc8")

Expand All @@ -367,12 +433,33 @@ def test_render_inline_osc8_uses_remote_reflexio_list_pages(monkeypatch) -> None
profiles=[{"content": "prefers concise answers", "profile_id": "pref/one"}],
)

assert "\x1b]8;;https://www.reflexio.ai/playbooks\x1b\\" in md
assert "\x1b]8;;https://www.reflexio.ai/profiles\x1b\\" in md
assert (
"\x1b]8;;https://www.reflexio.ai/playbooks?"
"resource=user_playbook&user_playbook_id=17\x1b\\"
) in md
assert "\x1b]8;;https://www.reflexio.ai/profiles?profile_id=pref%2Fone\x1b\\" in md
assert "http://localhost:3001/rules/s1-17" not in md
assert "http://localhost:3001/rules/p1-pref" not in md


def test_render_inline_with_registry_remote_items_without_real_ids_use_list_pages(
monkeypatch,
) -> None:
monkeypatch.setenv("REFLEXIO_URL", "https://www.reflexio.ai/")
md, registry = context_format.render_inline_with_registry(
project_id="demo",
user_playbooks=[{"content": "use safe git flow"}],
agent_playbooks=[],
profiles=[{"content": "prefers concise answers"}],
)

assert "open: https://www.reflexio.ai/playbooks" in md
assert "open: https://www.reflexio.ai/profiles" in md
by_id = {e["id"]: e for e in registry}
assert by_id["s1"]["rule_url"] == "https://www.reflexio.ai/playbooks"
assert by_id["p1"]["rule_url"] == "https://www.reflexio.ai/profiles"


def test_render_inline_with_registry_off_omits_instruction(monkeypatch) -> None:
monkeypatch.setenv("CLAUDE_SMART_CITATIONS", "off")
md, _ = context_format.render_inline_with_registry(
Expand Down
16 changes: 16 additions & 0 deletions tests/test_cs_cite.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@ def test_parse_text_citations_accepts_direct_dashboard_links() -> None:
]


def test_parse_text_citations_accepts_managed_reflexio_item_links() -> None:
text = (
"Done.\n\n"
"✨ claude-smart rules applied: "
"[shared skill](https://www.reflexio.ai/playbooks?agent_playbook_id=42), "
"[project skill](https://www.reflexio.ai/playbooks?"
"resource=user_playbook&user_playbook_id=7536), "
"[brief answers](https://www.reflexio.ai/profiles?profile_id=pref%2Fone)"
)
assert cs_cite.parse_text_citations(text) == [
"route:playbook:agent_playbook:42",
"route:playbook:user_playbook:7536",
"route:profile:profile:pref/one",
]


def test_parse_text_citations_keeps_old_applied_marker_compatible() -> None:
text = "Done.\n\n✨ Applied: [git safety](http://localhost:3001/skills/project/7536)"
assert cs_cite.parse_text_citations(text) == ["route:playbook:user_playbook:7536"]
Expand Down
Loading
Loading