claude-smart 0.2.23 → 0.2.25
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.
- package/.agents/plugins/marketplace.json +20 -0
- package/README.md +76 -28
- package/bin/claude-smart.js +355 -11
- package/package.json +11 -1
- package/plugin/.claude-plugin/plugin.json +17 -0
- package/plugin/.codex-plugin/plugin.json +35 -0
- package/plugin/LICENSE +202 -0
- package/plugin/README.md +37 -0
- package/plugin/bin/cs-cite +77 -0
- package/plugin/commands/clear-all.md +8 -0
- package/plugin/commands/dashboard.md +8 -0
- package/plugin/commands/learn.md +12 -0
- package/plugin/commands/restart.md +8 -0
- package/plugin/commands/show.md +8 -0
- package/plugin/dashboard/AGENTS.md +6 -0
- package/plugin/dashboard/app/api/claude-settings/route.ts +19 -0
- package/plugin/dashboard/app/api/config/route.ts +16 -0
- package/plugin/dashboard/app/api/health/route.ts +10 -0
- package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +63 -0
- package/plugin/dashboard/app/api/sessions/[id]/route.ts +28 -0
- package/plugin/dashboard/app/api/sessions/route.ts +14 -0
- package/plugin/dashboard/app/configure/env/page.tsx +318 -0
- package/plugin/dashboard/app/configure/layout.tsx +47 -0
- package/plugin/dashboard/app/configure/page.tsx +5 -0
- package/plugin/dashboard/app/configure/server/page.tsx +258 -0
- package/plugin/dashboard/app/dashboard/page.tsx +227 -0
- package/plugin/dashboard/app/globals.css +129 -0
- package/plugin/dashboard/app/icon.png +0 -0
- package/plugin/dashboard/app/layout.tsx +40 -0
- package/plugin/dashboard/app/page.tsx +5 -0
- package/plugin/dashboard/app/preferences/[id]/page.tsx +531 -0
- package/plugin/dashboard/app/preferences/page.tsx +126 -0
- package/plugin/dashboard/app/providers.tsx +12 -0
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +321 -0
- package/plugin/dashboard/app/sessions/page.tsx +186 -0
- package/plugin/dashboard/app/skills/page.tsx +362 -0
- package/plugin/dashboard/app/skills/project/[id]/page.tsx +597 -0
- package/plugin/dashboard/app/skills/shared/[id]/page.tsx +830 -0
- package/plugin/dashboard/components/common/delete-all-button.tsx +45 -0
- package/plugin/dashboard/components/common/empty-state.tsx +34 -0
- package/plugin/dashboard/components/common/learnings-badge.tsx +34 -0
- package/plugin/dashboard/components/common/page-header.tsx +34 -0
- package/plugin/dashboard/components/common/page-tabs.tsx +115 -0
- package/plugin/dashboard/components/common/stat-card.tsx +38 -0
- package/plugin/dashboard/components/layout/nav-items.ts +22 -0
- package/plugin/dashboard/components/layout/sidebar.tsx +45 -0
- package/plugin/dashboard/components/layout/top-bar.tsx +64 -0
- package/plugin/dashboard/components/stall-banner.tsx +53 -0
- package/plugin/dashboard/components/ui/badge.tsx +52 -0
- package/plugin/dashboard/components/ui/button.tsx +60 -0
- package/plugin/dashboard/components/ui/collapsible.tsx +21 -0
- package/plugin/dashboard/components/ui/input.tsx +20 -0
- package/plugin/dashboard/components/ui/label.tsx +20 -0
- package/plugin/dashboard/components/ui/scroll-area.tsx +55 -0
- package/plugin/dashboard/components/ui/select.tsx +201 -0
- package/plugin/dashboard/components/ui/separator.tsx +25 -0
- package/plugin/dashboard/components/ui/sheet.tsx +135 -0
- package/plugin/dashboard/components/ui/switch.tsx +32 -0
- package/plugin/dashboard/components.json +25 -0
- package/plugin/dashboard/eslint.config.mjs +16 -0
- package/plugin/dashboard/hooks/use-settings.tsx +88 -0
- package/plugin/dashboard/hooks/use-stall-state.ts +59 -0
- package/plugin/dashboard/lib/claude-settings-file.ts +114 -0
- package/plugin/dashboard/lib/config-file.ts +131 -0
- package/plugin/dashboard/lib/format.ts +58 -0
- package/plugin/dashboard/lib/reflexio-client.ts +238 -0
- package/plugin/dashboard/lib/reflexio-url.ts +17 -0
- package/plugin/dashboard/lib/session-reader.ts +245 -0
- package/plugin/dashboard/lib/status.ts +24 -0
- package/plugin/dashboard/lib/types.ts +145 -0
- package/plugin/dashboard/lib/utils.ts +6 -0
- package/plugin/dashboard/next.config.ts +7 -0
- package/plugin/dashboard/package-lock.json +10275 -0
- package/plugin/dashboard/package.json +37 -0
- package/plugin/dashboard/postcss.config.mjs +7 -0
- package/plugin/dashboard/public/claude-smart-icon.png +0 -0
- package/plugin/dashboard/tsconfig.json +34 -0
- package/plugin/hooks/codex-hooks.json +67 -0
- package/plugin/hooks/hooks.json +111 -0
- package/plugin/pyproject.toml +49 -0
- package/plugin/scripts/_codex_env.sh +27 -0
- package/plugin/scripts/_lib.sh +325 -0
- package/plugin/scripts/backend-service.sh +208 -0
- package/plugin/scripts/cli.sh +40 -0
- package/plugin/scripts/dashboard-build.sh +139 -0
- package/plugin/scripts/dashboard-open.sh +107 -0
- package/plugin/scripts/dashboard-service.sh +195 -0
- package/plugin/scripts/ensure-plugin-root.sh +84 -0
- package/plugin/scripts/hook_entry.sh +70 -0
- package/plugin/scripts/smart-install.sh +411 -0
- package/plugin/src/claude_smart/__init__.py +3 -0
- package/plugin/src/claude_smart/cli.py +1342 -0
- package/plugin/src/claude_smart/context_format.py +277 -0
- package/plugin/src/claude_smart/context_inject.py +92 -0
- package/plugin/src/claude_smart/cs_cite.py +236 -0
- package/plugin/src/claude_smart/events/__init__.py +1 -0
- package/plugin/src/claude_smart/events/post_tool.py +148 -0
- package/plugin/src/claude_smart/events/pre_tool.py +52 -0
- package/plugin/src/claude_smart/events/session_end.py +20 -0
- package/plugin/src/claude_smart/events/session_start.py +119 -0
- package/plugin/src/claude_smart/events/stop.py +393 -0
- package/plugin/src/claude_smart/events/user_prompt.py +73 -0
- package/plugin/src/claude_smart/hook.py +114 -0
- package/plugin/src/claude_smart/ids.py +56 -0
- package/plugin/src/claude_smart/internal_call.py +89 -0
- package/plugin/src/claude_smart/optimizer_assistant.py +203 -0
- package/plugin/src/claude_smart/publish.py +71 -0
- package/plugin/src/claude_smart/query_compose.py +51 -0
- package/plugin/src/claude_smart/reflexio_adapter.py +403 -0
- package/plugin/src/claude_smart/runtime.py +52 -0
- package/plugin/src/claude_smart/stall_banner.py +61 -0
- package/plugin/src/claude_smart/state.py +276 -0
- package/plugin/uv.lock +3720 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Detect hook invocations that should not be published to reflexio.
|
|
2
|
+
|
|
3
|
+
Two distinct sources of unwanted hook fires:
|
|
4
|
+
|
|
5
|
+
1. **Reflexio's own LLM provider.** The ``claude-code`` LiteLLM provider
|
|
6
|
+
(see ``reflexio.server.llm.providers.claude_code_provider._run_cli``)
|
|
7
|
+
shells out to the ``claude`` CLI to answer extractor prompts. That
|
|
8
|
+
subprocess is a full Claude Code invocation, so it fires *our* hooks
|
|
9
|
+
too — and without a guard, the Stop hook publishes the extractor's
|
|
10
|
+
own system prompt back into reflexio as a user interaction.
|
|
11
|
+
Reflexio then trains on its own internals.
|
|
12
|
+
|
|
13
|
+
2. **Other tools' headless ``claude -p`` subprocesses.** Third-party
|
|
14
|
+
plugins (e.g. claude-mem) spawn their own ``claude -p`` sessions
|
|
15
|
+
for memory/observation extraction. Those sessions also fire the
|
|
16
|
+
user's globally-installed claude-smart hooks, and the system prompt
|
|
17
|
+
passed to ``-p`` shows up in the transcript as a user message —
|
|
18
|
+
leaking text like ``"You are a Claude-Mem, a specialized observer
|
|
19
|
+
tool..."`` into reflexio as a fake user interaction.
|
|
20
|
+
|
|
21
|
+
Detection signals, OR'd:
|
|
22
|
+
- ``CLAUDE_CODE_ENTRYPOINT`` is anything other than ``"cli"`` —
|
|
23
|
+
the interactive REPL sets ``cli``; headless ``claude -p`` sets
|
|
24
|
+
``sdk-cli`` (and the SDKs may set other values). This catches
|
|
25
|
+
case (2) for any third-party tool, not just claude-mem.
|
|
26
|
+
- Env var ``CLAUDE_SMART_INTERNAL=1``, set by reflexio's provider
|
|
27
|
+
before spawning ``claude``. Belt-and-suspenders for case (1) in
|
|
28
|
+
case the entrypoint check ever misses a future SDK variant.
|
|
29
|
+
- ``payload.cwd`` resolves inside the reflexio submodule. Catches
|
|
30
|
+
direct interactive ``claude`` runs from inside reflexio (manual
|
|
31
|
+
debugging) that would otherwise pollute the corpus.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from claude_smart import runtime
|
|
41
|
+
|
|
42
|
+
_ENTRYPOINT_VAR = "CLAUDE_CODE_ENTRYPOINT"
|
|
43
|
+
_INTERACTIVE_ENTRYPOINT = "cli"
|
|
44
|
+
|
|
45
|
+
# Reflexio submodule lives at <repo>/reflexio when this package runs from
|
|
46
|
+
# a dev checkout (<repo>/plugin/src/claude_smart/internal_call.py); anchor
|
|
47
|
+
# relative to this file so the check follows the real checkout if the
|
|
48
|
+
# repo is relocated. In install mode the submodule is absent — the env
|
|
49
|
+
# marker is the primary signal and this path never matches.
|
|
50
|
+
#
|
|
51
|
+
# The path computation is tightly coupled to the current layout: if this
|
|
52
|
+
# module moves, ``_REFLEXIO_DIR`` silently stops matching and only the
|
|
53
|
+
# env signal remains. ``CLAUDE_SMART_REFLEXIO_DIR`` lets callers (and
|
|
54
|
+
# tests) override the path without touching the module.
|
|
55
|
+
_THIS_DIR = Path(__file__).resolve().parent
|
|
56
|
+
_REFLEXIO_DIR = Path(
|
|
57
|
+
os.environ.get("CLAUDE_SMART_REFLEXIO_DIR") or _THIS_DIR.parents[2] / "reflexio"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_internal_invocation(payload: dict[str, Any]) -> bool:
|
|
62
|
+
"""True if this hook fire originated from reflexio's own LLM provider.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
payload (dict[str, Any]): Parsed Claude Code hook payload. Only
|
|
66
|
+
``cwd`` is inspected.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
bool: True when the env marker is set or ``cwd`` points inside
|
|
70
|
+
the reflexio submodule. False otherwise, including when
|
|
71
|
+
``cwd`` is missing or unresolvable.
|
|
72
|
+
"""
|
|
73
|
+
if runtime.is_internal_invocation_env():
|
|
74
|
+
return True
|
|
75
|
+
entrypoint = os.environ.get(_ENTRYPOINT_VAR)
|
|
76
|
+
if entrypoint and entrypoint != _INTERACTIVE_ENTRYPOINT:
|
|
77
|
+
return True
|
|
78
|
+
cwd = payload.get("cwd")
|
|
79
|
+
if not isinstance(cwd, str) or not cwd:
|
|
80
|
+
return False
|
|
81
|
+
try:
|
|
82
|
+
resolved = Path(cwd).resolve()
|
|
83
|
+
except OSError:
|
|
84
|
+
return False
|
|
85
|
+
try:
|
|
86
|
+
resolved.relative_to(_REFLEXIO_DIR)
|
|
87
|
+
except ValueError:
|
|
88
|
+
return False
|
|
89
|
+
return True
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Local-script assistant backend for Reflexio playbook optimization.
|
|
2
|
+
|
|
3
|
+
Reflexio's ``LocalScriptAssistant`` sends one JSON payload on stdin and expects
|
|
4
|
+
one JSON object on stdout. This module bridges that protocol to a guarded
|
|
5
|
+
``claude -p`` subprocess so candidate playbooks can be evaluated against Claude
|
|
6
|
+
Code without re-entering claude-smart/reflexio hooks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from claude_smart import internal_call, runtime
|
|
19
|
+
|
|
20
|
+
_CLAUDE_TIMEOUT_SECONDS = 300
|
|
21
|
+
_READ_ONLY_TOOLS = "Read,Grep,Glob,LS"
|
|
22
|
+
_MUTATING_TOOLS = "Bash,Edit,Write,MultiEdit,NotebookEdit"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OptimizerAssistantError(Exception):
|
|
26
|
+
"""Raised for any local assistant protocol or Claude CLI failure."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> int:
|
|
30
|
+
"""Console-script entrypoint for ``claude-smart-optimizer-assistant``."""
|
|
31
|
+
try:
|
|
32
|
+
payload = _read_payload()
|
|
33
|
+
messages = _validated_list(payload, "messages")
|
|
34
|
+
playbooks = _validated_list(payload, "playbooks")
|
|
35
|
+
prompt, system_prompt = _build_prompt(messages, playbooks)
|
|
36
|
+
content = _run_claude(prompt=prompt, system_prompt=system_prompt)
|
|
37
|
+
except Exception as exc: # noqa: BLE001 - script errors become LocalScript failures.
|
|
38
|
+
sys.stderr.write(f"{type(exc).__name__}: {exc}\n")
|
|
39
|
+
return 1
|
|
40
|
+
|
|
41
|
+
json.dump({"content": content}, sys.stdout, ensure_ascii=False)
|
|
42
|
+
sys.stdout.write("\n")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_payload() -> dict[str, Any]:
|
|
47
|
+
raw = sys.stdin.read()
|
|
48
|
+
try:
|
|
49
|
+
payload = json.loads(raw)
|
|
50
|
+
except json.JSONDecodeError as exc:
|
|
51
|
+
raise OptimizerAssistantError("stdin must be a JSON object") from exc
|
|
52
|
+
if not isinstance(payload, dict):
|
|
53
|
+
raise OptimizerAssistantError("stdin must be a JSON object")
|
|
54
|
+
return payload
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _validated_list(payload: dict[str, Any], field: str) -> list[Any]:
|
|
58
|
+
value = payload.get(field)
|
|
59
|
+
if not isinstance(value, list):
|
|
60
|
+
raise OptimizerAssistantError(f"payload.{field} must be a list")
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_prompt(messages: list[Any], playbooks: list[Any]) -> tuple[str, str]:
|
|
65
|
+
normalized = [_normalize_message(message) for message in messages]
|
|
66
|
+
normalized = [message for message in normalized if message["content"]]
|
|
67
|
+
if not normalized:
|
|
68
|
+
raise OptimizerAssistantError("payload.messages must contain content")
|
|
69
|
+
|
|
70
|
+
final_message = normalized[-1]
|
|
71
|
+
prior_messages = normalized[:-1]
|
|
72
|
+
|
|
73
|
+
system_sections = [_render_playbooks(playbooks)]
|
|
74
|
+
existing_system = [
|
|
75
|
+
message["content"] for message in normalized if message["role"] == "system"
|
|
76
|
+
]
|
|
77
|
+
if existing_system:
|
|
78
|
+
system_sections.append(
|
|
79
|
+
"## Existing system context\n" + "\n\n".join(existing_system)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
prior_dialogue = [
|
|
83
|
+
message
|
|
84
|
+
for message in prior_messages
|
|
85
|
+
if message["role"] in {"user", "assistant"}
|
|
86
|
+
]
|
|
87
|
+
if prior_dialogue:
|
|
88
|
+
system_sections.append(
|
|
89
|
+
"## Conversation so far\n" + _render_transcript(prior_dialogue)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
prompt = final_message["content"]
|
|
93
|
+
if final_message["role"] != "user":
|
|
94
|
+
prompt = _render_transcript([final_message])
|
|
95
|
+
system_prompt = "\n\n".join(section for section in system_sections if section)
|
|
96
|
+
return prompt, system_prompt
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _normalize_message(message: Any) -> dict[str, str]:
|
|
100
|
+
if not isinstance(message, dict):
|
|
101
|
+
raise OptimizerAssistantError("each message must be an object")
|
|
102
|
+
role = str(message.get("role") or "user").strip().lower()
|
|
103
|
+
if role not in {"user", "assistant", "system"}:
|
|
104
|
+
role = "user"
|
|
105
|
+
content = message.get("content")
|
|
106
|
+
if not isinstance(content, str):
|
|
107
|
+
raise OptimizerAssistantError("each message.content must be a string")
|
|
108
|
+
return {"role": role, "content": content.strip()}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _render_playbooks(playbooks: list[Any]) -> str:
|
|
112
|
+
if not playbooks:
|
|
113
|
+
return ""
|
|
114
|
+
lines = ["## Candidate playbook rules"]
|
|
115
|
+
for index, playbook in enumerate(playbooks, start=1):
|
|
116
|
+
if not isinstance(playbook, dict):
|
|
117
|
+
raise OptimizerAssistantError("each playbook must be an object")
|
|
118
|
+
content = playbook.get("content")
|
|
119
|
+
if not isinstance(content, str) or not content.strip():
|
|
120
|
+
raise OptimizerAssistantError("each playbook.content must be a string")
|
|
121
|
+
trigger = playbook.get("trigger")
|
|
122
|
+
suffix = ""
|
|
123
|
+
if isinstance(trigger, str) and trigger.strip():
|
|
124
|
+
suffix = f" (when: {trigger.strip()})"
|
|
125
|
+
lines.append(f"{index}. {content.strip()}{suffix}")
|
|
126
|
+
return "\n".join(lines)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _render_transcript(messages: list[dict[str, str]]) -> str:
|
|
130
|
+
labels = {"user": "User", "assistant": "Assistant", "system": "System"}
|
|
131
|
+
return "\n\n".join(
|
|
132
|
+
f"{labels.get(message['role'], 'User')}: {message['content']}"
|
|
133
|
+
for message in messages
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _run_claude(*, prompt: str, system_prompt: str) -> str:
|
|
138
|
+
cli_path = shutil.which("claude") or "claude"
|
|
139
|
+
# This is an evaluation rollout, not a real user session: allow local
|
|
140
|
+
# inspection, but prevent filesystem, shell, MCP, and session mutations.
|
|
141
|
+
cmd = [
|
|
142
|
+
cli_path,
|
|
143
|
+
"-p",
|
|
144
|
+
"--output-format",
|
|
145
|
+
"json",
|
|
146
|
+
"--permission-mode",
|
|
147
|
+
"plan",
|
|
148
|
+
"--tools",
|
|
149
|
+
_READ_ONLY_TOOLS,
|
|
150
|
+
"--disallowedTools",
|
|
151
|
+
_MUTATING_TOOLS,
|
|
152
|
+
"--no-session-persistence",
|
|
153
|
+
"--mcp-config",
|
|
154
|
+
'{"mcpServers": {}}',
|
|
155
|
+
"--strict-mcp-config",
|
|
156
|
+
]
|
|
157
|
+
if system_prompt:
|
|
158
|
+
cmd.extend(["--append-system-prompt", system_prompt])
|
|
159
|
+
|
|
160
|
+
env = os.environ.copy()
|
|
161
|
+
env[runtime.INTERNAL_ENV] = "1"
|
|
162
|
+
env[internal_call._ENTRYPOINT_VAR] = "optimizer" # noqa: SLF001
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
proc = subprocess.run( # noqa: S603 - command is fixed plus resolved executable.
|
|
166
|
+
cmd,
|
|
167
|
+
input=prompt,
|
|
168
|
+
capture_output=True,
|
|
169
|
+
text=True,
|
|
170
|
+
timeout=_CLAUDE_TIMEOUT_SECONDS,
|
|
171
|
+
check=False,
|
|
172
|
+
env=env,
|
|
173
|
+
)
|
|
174
|
+
except subprocess.TimeoutExpired as exc:
|
|
175
|
+
raise OptimizerAssistantError(
|
|
176
|
+
f"claude CLI timed out after {_CLAUDE_TIMEOUT_SECONDS}s"
|
|
177
|
+
) from exc
|
|
178
|
+
except FileNotFoundError as exc:
|
|
179
|
+
raise OptimizerAssistantError("claude CLI not found on PATH") from exc
|
|
180
|
+
|
|
181
|
+
if proc.returncode != 0:
|
|
182
|
+
stderr = proc.stderr.strip()
|
|
183
|
+
raise OptimizerAssistantError(
|
|
184
|
+
f"claude CLI exited {proc.returncode}: {stderr[:500]}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
data = json.loads(proc.stdout)
|
|
189
|
+
except json.JSONDecodeError as exc:
|
|
190
|
+
raise OptimizerAssistantError("claude CLI returned non-JSON output") from exc
|
|
191
|
+
if not isinstance(data, dict):
|
|
192
|
+
raise OptimizerAssistantError("claude CLI JSON output must be an object")
|
|
193
|
+
|
|
194
|
+
content = data.get("result")
|
|
195
|
+
if not isinstance(content, str):
|
|
196
|
+
content = data.get("response")
|
|
197
|
+
if not isinstance(content, str):
|
|
198
|
+
raise OptimizerAssistantError("claude CLI JSON output missing result/response")
|
|
199
|
+
return content
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Publish-to-reflexio orchestration used by Stop, SessionEnd, and the sync CLI.
|
|
2
|
+
|
|
3
|
+
One helper — ``publish_unpublished`` — owns the read-buffer → slice → publish →
|
|
4
|
+
stamp-watermark sequence so the three call sites stay in sync. Returns a
|
|
5
|
+
``(status, interaction_count)`` tuple so callers can format appropriate
|
|
6
|
+
messaging without peeking at the adapter.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from claude_smart import state
|
|
14
|
+
from claude_smart.reflexio_adapter import Adapter
|
|
15
|
+
|
|
16
|
+
PublishStatus = Literal["nothing", "ok", "failed"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def publish_unpublished(
|
|
20
|
+
*,
|
|
21
|
+
session_id: str,
|
|
22
|
+
project_id: str,
|
|
23
|
+
force_extraction: bool,
|
|
24
|
+
skip_aggregation: bool,
|
|
25
|
+
adapter: Adapter | None = None,
|
|
26
|
+
) -> tuple[PublishStatus, int]:
|
|
27
|
+
"""Drain the session buffer to reflexio and stamp the high-water mark.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
session_id (str): Claude Code session id, attached to each interaction.
|
|
31
|
+
project_id (str): Stable project name; used as reflexio's
|
|
32
|
+
``user_id`` (preferences) so preferences accumulate at the project
|
|
33
|
+
level across sessions. ``agent_version`` is hardcoded to
|
|
34
|
+
``"claude-code"`` in the adapter so skills roll up
|
|
35
|
+
globally per agent rather than per project.
|
|
36
|
+
force_extraction (bool): Whether to ask reflexio to run extraction
|
|
37
|
+
synchronously instead of queuing for the next sweep.
|
|
38
|
+
skip_aggregation (bool): When True, reflexio extracts preferences and
|
|
39
|
+
raw project-specific skill entries but skips the rollup into
|
|
40
|
+
shared skills. claude-smart passes False on every publish
|
|
41
|
+
path so ``user_playbooks`` roll up into ``agent_playbooks``; aggregation
|
|
42
|
+
additionally requires `aggregation_config` to be set on
|
|
43
|
+
reflexio's `user_playbook_extractor_configs[0]` and
|
|
44
|
+
`optimize_agent_playbooks=true` at the top level — otherwise
|
|
45
|
+
the rollup silently no-ops.
|
|
46
|
+
adapter (Adapter | None): Injection point for tests; a fresh
|
|
47
|
+
``Adapter()`` is constructed when omitted.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
tuple[PublishStatus, int]: ``("nothing", 0)`` if the buffer has no
|
|
51
|
+
unpublished turns, ``("ok", n)`` after a successful publish of
|
|
52
|
+
``n`` interactions, or ``("failed", n)`` if reflexio rejected or
|
|
53
|
+
was unreachable. On ``"failed"`` the watermark is not advanced,
|
|
54
|
+
so the next hook retries the same batch.
|
|
55
|
+
"""
|
|
56
|
+
records = state.read_all(session_id)
|
|
57
|
+
_, interactions = state.unpublished_slice(records)
|
|
58
|
+
if not interactions:
|
|
59
|
+
return ("nothing", 0)
|
|
60
|
+
client = adapter if adapter is not None else Adapter()
|
|
61
|
+
ok = client.publish(
|
|
62
|
+
session_id=session_id,
|
|
63
|
+
project_id=project_id,
|
|
64
|
+
interactions=interactions,
|
|
65
|
+
force_extraction=force_extraction,
|
|
66
|
+
skip_aggregation=skip_aggregation,
|
|
67
|
+
)
|
|
68
|
+
if ok:
|
|
69
|
+
state.append(session_id, {"published_up_to": len(records)})
|
|
70
|
+
return ("ok", len(interactions))
|
|
71
|
+
return ("failed", len(interactions))
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Compose a reflexio search query from a PreToolUse payload.
|
|
2
|
+
|
|
3
|
+
Deterministic — no LLM call — so the PreToolUse hook can stay inside its
|
|
4
|
+
latency budget. The output is fed to ``ReflexioClient.search(query=...)``
|
|
5
|
+
(the unified ``/api/search`` endpoint, which fans out to user playbooks,
|
|
6
|
+
agent playbooks, and preferences server-side), which tokenizes via reflexio's
|
|
7
|
+
FTS5 sanitizer (OR-joined, stemmed) plus a vector-similarity leg. Short,
|
|
8
|
+
meaning-dense strings give the most selective hybrid ranking.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Mapping
|
|
15
|
+
|
|
16
|
+
_MAX_SNIPPET_LEN = 400
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def from_tool_call(tool_name: str, tool_input: Mapping[str, Any]) -> str:
|
|
20
|
+
"""Compose a search query from a Claude Code PreToolUse payload.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
tool_name (str): Claude Code tool name (e.g. ``"Edit"``, ``"Bash"``).
|
|
24
|
+
tool_input (Mapping[str, Any]): The tool's input dict as delivered
|
|
25
|
+
by the hook payload.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
str: A short query suitable for reflexio hybrid search, or ``""``
|
|
29
|
+
when the tool is not one we compose for (caller should then
|
|
30
|
+
skip the search entirely).
|
|
31
|
+
"""
|
|
32
|
+
match tool_name:
|
|
33
|
+
case "Edit" | "Write" | "NotebookEdit":
|
|
34
|
+
return _from_file_edit(tool_input)
|
|
35
|
+
case "Bash":
|
|
36
|
+
return _from_bash(tool_input)
|
|
37
|
+
case _:
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _from_file_edit(tool_input: Mapping[str, Any]) -> str:
|
|
42
|
+
path = tool_input.get("file_path") or ""
|
|
43
|
+
snippet = tool_input.get("new_string") or tool_input.get("content") or ""
|
|
44
|
+
basename = Path(path).name if path else ""
|
|
45
|
+
return f"{basename} {snippet[:_MAX_SNIPPET_LEN]}".strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _from_bash(tool_input: Mapping[str, Any]) -> str:
|
|
49
|
+
command = tool_input.get("command") or ""
|
|
50
|
+
first_line = command.splitlines()[0] if command else ""
|
|
51
|
+
return first_line[:_MAX_SNIPPET_LEN].strip()
|