diff --git a/DEVELOPER.md b/DEVELOPER.md index a222e46..2647957 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -31,7 +31,7 @@ Tunables read by the plugin at runtime. Most users don't need to touch these — | `REFLEXIO_EMBEDDING_SERVICE_URL` | `http://127.0.0.1:$EMBEDDING_PORT` | OpenAI-compatible embedding endpoint used by `local_service` and `internal_service`. | | `EMBEDDING_PORT` | `8072` | Local embedding daemon port. Shared by Claude Code and Codex on the same machine. | | `REFLEXIO_RERANK_DEVICE` | `cpu` | Torch device for the in-process cross-encoder reranker used by search. Defaults to CPU: auto-selected MPS on Apple Silicon accumulates gigabytes of GPU memory in the backend process for no latency win at this model size. Set to `cuda` only for GPU deployments. | -| `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_ENABLE_OPTIMIZER` | auto: local Reflexio only | Hook-side env var that controls shared skill optimization and rollups during `SessionStart`. Set to `1` to force optimizer defaults for hosted Reflexio, or `0` to disable everywhere. 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 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. | diff --git a/plugin/dashboard/app/configure/env/page.tsx b/plugin/dashboard/app/configure/env/page.tsx index 3bdeaf1..c08f419 100644 --- a/plugin/dashboard/app/configure/env/page.tsx +++ b/plugin/dashboard/app/configure/env/page.tsx @@ -6,10 +6,21 @@ import { PageHeader } from "@/components/common/page-header"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Separator } from "@/components/ui/separator"; import { SETTINGS_CHANGED_EVENT } from "@/hooks/use-settings"; -import type { ClaudeCodeHookConfig, ClaudeSmartConfig } from "@/lib/types"; +import type { + ClaudeCodeHookConfig, + ClaudeSmartConfig, + OptimizerMode, +} from "@/lib/types"; export default function ConfigureEnvPage() { const [config, setConfig] = useState(null); @@ -44,14 +55,14 @@ export default function ConfigureEnvPage() { setSaved(false); }; - const updateOptimizer = (enabled: boolean) => { + const updateOptimizer = (mode: OptimizerMode) => { setHookConfig((prev) => prev ? { ...prev, - CLAUDE_SMART_ENABLE_OPTIMIZER: enabled, - effectiveValue: enabled, - localValue: enabled, + CLAUDE_SMART_ENABLE_OPTIMIZER: mode, + effectiveValue: mode, + localValue: mode === "auto" ? null : mode, } : prev, ); @@ -280,15 +291,29 @@ export default function ConfigureEnvPage() { CLAUDE_SMART_ENABLE_OPTIMIZER

- Enable shared skill optimization and rollups during - SessionStart. + Control shared skill optimization and rollups during + SessionStart. Auto applies only to local Reflexio.

- +
@@ -332,7 +357,8 @@ export default function ConfigureEnvPage() { ); } -function formatSettingValue(value: boolean | null): string { +function formatSettingValue(value: OptimizerMode | null): string { if (value === null) return "Not set"; - return value ? "Enabled" : "Disabled"; + if (value === "auto") return "Auto (local only)"; + return value === "enabled" ? "Enabled" : "Disabled"; } diff --git a/plugin/dashboard/lib/claude-settings-file.ts b/plugin/dashboard/lib/claude-settings-file.ts index 67917cd..4fc394c 100644 --- a/plugin/dashboard/lib/claude-settings-file.ts +++ b/plugin/dashboard/lib/claude-settings-file.ts @@ -10,7 +10,7 @@ import fs from "node:fs/promises"; import { existsSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { ClaudeCodeHookConfig } from "./types"; +import type { ClaudeCodeHookConfig, OptimizerMode } from "./types"; const ENV_KEY = "CLAUDE_SMART_ENABLE_OPTIMIZER"; @@ -52,17 +52,22 @@ function asRecord(value: unknown): Record { return value as Record; } -function parseEnabled(value: unknown): boolean { - if (value === undefined || value === null || value === "") return true; - if (value === false || value === 0) return false; - if (typeof value === "string" && value.trim() === "0") return false; - return true; +function parseOptimizerMode(value: unknown): OptimizerMode { + if ( + value === false || + value === 0 || + (typeof value === "string" && + ["0", "false", "no", "off"].includes(value.trim().toLowerCase())) + ) { + return "disabled"; + } + return "enabled"; } -function optimizerValue(settings: Record): boolean | null { +function optimizerValue(settings: Record): OptimizerMode | null { const env = asRecord(settings.env); if (!(ENV_KEY in env)) return null; - return parseEnabled(env[ENV_KEY]); + return parseOptimizerMode(env[ENV_KEY]); } async function readSettingsFile(file: string): Promise> { @@ -84,7 +89,7 @@ export async function readClaudeCodeHookConfig(): Promise ]); const localValue = optimizerValue(localSettings); const userValue = optimizerValue(userSettings); - const effectiveValue = localValue ?? userValue ?? true; + const effectiveValue = localValue ?? userValue ?? "auto"; return { CLAUDE_SMART_ENABLE_OPTIMIZER: effectiveValue, effectiveValue, @@ -103,7 +108,12 @@ export async function writeClaudeCodeHookConfig( const env = asRecord(settings.env); if (!("CLAUDE_SMART_ENABLE_OPTIMIZER" in update)) return; - env[ENV_KEY] = update.CLAUDE_SMART_ENABLE_OPTIMIZER ? "1" : "0"; + if (update.CLAUDE_SMART_ENABLE_OPTIMIZER === "auto") { + delete env[ENV_KEY]; + } else { + env[ENV_KEY] = + update.CLAUDE_SMART_ENABLE_OPTIMIZER === "enabled" ? "1" : "0"; + } settings.env = env; await fs.mkdir(path.dirname(file), { recursive: true }); diff --git a/plugin/dashboard/lib/types.ts b/plugin/dashboard/lib/types.ts index 28a8e5d..f0b3fa3 100644 --- a/plugin/dashboard/lib/types.ts +++ b/plugin/dashboard/lib/types.ts @@ -124,11 +124,13 @@ export interface ClaudeSmartConfig { [extra: string]: string | boolean | undefined; } +export type OptimizerMode = "auto" | "enabled" | "disabled"; + export interface ClaudeCodeHookConfig { - CLAUDE_SMART_ENABLE_OPTIMIZER: boolean; - effectiveValue: boolean; - localValue: boolean | null; - userValue: boolean | null; + CLAUDE_SMART_ENABLE_OPTIMIZER: OptimizerMode; + effectiveValue: OptimizerMode; + localValue: OptimizerMode | null; + userValue: OptimizerMode | null; settingsPath: string; userSettingsPath: string; } diff --git a/plugin/src/claude_smart/events/session_start.py b/plugin/src/claude_smart/events/session_start.py index 29e618e..14ebcfa 100644 --- a/plugin/src/claude_smart/events/session_start.py +++ b/plugin/src/claude_smart/events/session_start.py @@ -5,8 +5,10 @@ import json import os import sys +from contextlib import suppress from pathlib import Path from typing import Any +from urllib.parse import urlparse from claude_smart import hook from claude_smart.reflexio_adapter import Adapter @@ -41,10 +43,12 @@ def _int_env(name: str, default: int) -> int: _CLAUDE_SMART_WINDOW_SIZE = _int_env("CLAUDE_SMART_WINDOW_SIZE", 5) _CLAUDE_SMART_STRIDE_SIZE = _int_env("CLAUDE_SMART_STRIDE_SIZE", 3) -# Optimizer is on by default. Set this env var to "0" to skip pushing the -# claude-smart optimizer defaults on SessionStart (kill switch). -_DISABLE_OPTIMIZER_ENV = "CLAUDE_SMART_ENABLE_OPTIMIZER" +# Optimizer defaults are local-only by default because they store an absolute +# path to the claude-smart helper process. Set this env var to "1" to force +# enabling against a non-local Reflexio URL, or "0" to disable everywhere. +_OPTIMIZER_ENV = "CLAUDE_SMART_ENABLE_OPTIMIZER" _OPTIMIZER_TIMEOUT_SECONDS = 300 +_LOCAL_REFLEXIO_HOSTS = {"", "localhost", "127.0.0.1", "::1"} def _adapter() -> Adapter: @@ -109,7 +113,7 @@ def handle(payload: dict[str, Any]) -> None: window_size=_CLAUDE_SMART_WINDOW_SIZE, stride_size=_CLAUDE_SMART_STRIDE_SIZE, ) - if os.environ.get(_DISABLE_OPTIMIZER_ENV) != "0": + if _should_apply_optimizer_defaults(adapter): adapter.apply_optimizer_defaults( script_path=_optimizer_assistant_path(), timeout_seconds=_OPTIMIZER_TIMEOUT_SECONDS, @@ -131,13 +135,28 @@ def handle(payload: dict[str, Any]) -> None: ) sys.stdout.write("\n") - try: + with suppress(Exception): adapter.mark_stall_notified() - except Exception: # noqa: BLE001 — telemetry must not break session. - pass def _optimizer_assistant_path() -> str: executable = Path(sys.executable) suffix = ".exe" if os.name == "nt" else "" return str(executable.with_name(f"claude-smart-optimizer-assistant{suffix}")) + + +def _should_apply_optimizer_defaults(adapter: Any) -> bool: + flag = os.environ.get(_OPTIMIZER_ENV, "").strip().lower() + if flag in {"0", "false", "no", "off"}: + return False + if flag in {"1", "true", "yes", "on"}: + return True + return _is_local_reflexio_url(getattr(adapter, "url", "")) + + +def _is_local_reflexio_url(url: str) -> bool: + if not url: + return True + parsed = urlparse(url) + host = parsed.hostname if parsed.scheme else url.split(":", 1)[0] + return (host or "").lower() in _LOCAL_REFLEXIO_HOSTS diff --git a/plugin/src/claude_smart/reflexio_adapter.py b/plugin/src/claude_smart/reflexio_adapter.py index ba4ffb8..58bc74d 100644 --- a/plugin/src/claude_smart/reflexio_adapter.py +++ b/plugin/src/claude_smart/reflexio_adapter.py @@ -6,16 +6,15 @@ from __future__ import annotations +import inspect import logging import os -import inspect from collections.abc import Sequence from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from typing import Any -from claude_smart import env_config -from claude_smart import runtime +from claude_smart import env_config, runtime _LOGGER = logging.getLogger(__name__) @@ -174,8 +173,9 @@ def apply_optimizer_defaults( Idempotent compare-then-write: reads ``Config``, only issues a ``set_config`` when the server-side values differ from the desired - dict below. Called unconditionally from SessionStart; the caller's - only escape hatch is ``CLAUDE_SMART_ENABLE_OPTIMIZER=0``. + dict below. SessionStart calls this only for local Reflexio URLs by + default; ``CLAUDE_SMART_ENABLE_OPTIMIZER=1`` forces hosted URLs, and + ``CLAUDE_SMART_ENABLE_OPTIMIZER=0`` disables it everywhere. """ client = self._get_client() if client is None: diff --git a/tests/test_events.py b/tests/test_events.py index a4a093c..e529024 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1462,6 +1462,8 @@ def test_session_start_ignores_available_memory_and_continues( session_dir, monkeypatch ) -> None: class Stub: + url = "http://localhost:8071/" + def apply_extraction_defaults(self, **_kw): return True @@ -1580,6 +1582,8 @@ def test_session_start_skips_optimizer_defaults_when_opted_out( optimizer_calls: list[dict[str, Any]] = [] class Stub: + url = "http://localhost:8071/" + def apply_extraction_defaults(self, **_kw): return True @@ -1608,6 +1612,72 @@ def test_session_start_applies_optimizer_defaults_by_default( optimizer_calls: list[dict[str, Any]] = [] class Stub: + url = "http://localhost:8071/" + + def apply_extraction_defaults(self, **_kw): + return True + + def apply_optimizer_defaults(self, **kwargs): + optimizer_calls.append(kwargs) + return True + + def fetch_all(self, **_kw): + return ([], [], []) + + monkeypatch.setattr(session_start.sys, "executable", "/tmp/venv/bin/python") + monkeypatch.setattr( + "claude_smart.events.session_start.Adapter", lambda *a, **kw: Stub() + ) + buf = io.StringIO() + monkeypatch.setattr(sys, "stdout", buf) + + session_start.handle({"session_id": "s1", "source": "startup"}) + + assert optimizer_calls == [ + { + "script_path": "/tmp/venv/bin/claude-smart-optimizer-assistant", + "timeout_seconds": 300, + } + ] + + +def test_session_start_skips_optimizer_defaults_for_hosted_reflexio_by_default( + session_dir, monkeypatch +) -> None: + optimizer_calls: list[dict[str, Any]] = [] + + class Stub: + url = "https://www.reflexio.ai/" + + def apply_extraction_defaults(self, **_kw): + return True + + def apply_optimizer_defaults(self, **kwargs): + optimizer_calls.append(kwargs) + return True + + def fetch_all(self, **_kw): + return ([], [], []) + + monkeypatch.setattr( + "claude_smart.events.session_start.Adapter", lambda *a, **kw: Stub() + ) + buf = io.StringIO() + monkeypatch.setattr(sys, "stdout", buf) + + session_start.handle({"session_id": "s1", "source": "startup"}) + + assert optimizer_calls == [] + + +def test_session_start_can_force_optimizer_defaults_for_hosted_reflexio( + session_dir, monkeypatch +) -> None: + optimizer_calls: list[dict[str, Any]] = [] + + class Stub: + url = "https://www.reflexio.ai/" + def apply_extraction_defaults(self, **_kw): return True @@ -1618,6 +1688,7 @@ def apply_optimizer_defaults(self, **kwargs): def fetch_all(self, **_kw): return ([], [], []) + monkeypatch.setenv("CLAUDE_SMART_ENABLE_OPTIMIZER", "1") monkeypatch.setattr(session_start.sys, "executable", "/tmp/venv/bin/python") monkeypatch.setattr( "claude_smart.events.session_start.Adapter", lambda *a, **kw: Stub()