claude-smart 0.2.23 → 0.2.24

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 (113) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +69 -27
  3. package/bin/claude-smart.js +296 -11
  4. package/package.json +11 -1
  5. package/plugin/.claude-plugin/plugin.json +17 -0
  6. package/plugin/.codex-plugin/plugin.json +35 -0
  7. package/plugin/LICENSE +202 -0
  8. package/plugin/README.md +37 -0
  9. package/plugin/bin/cs-cite +77 -0
  10. package/plugin/commands/clear-all.md +8 -0
  11. package/plugin/commands/dashboard.md +8 -0
  12. package/plugin/commands/learn.md +12 -0
  13. package/plugin/commands/restart.md +8 -0
  14. package/plugin/commands/show.md +8 -0
  15. package/plugin/dashboard/AGENTS.md +6 -0
  16. package/plugin/dashboard/app/api/claude-settings/route.ts +19 -0
  17. package/plugin/dashboard/app/api/config/route.ts +16 -0
  18. package/plugin/dashboard/app/api/health/route.ts +10 -0
  19. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +63 -0
  20. package/plugin/dashboard/app/api/sessions/[id]/route.ts +28 -0
  21. package/plugin/dashboard/app/api/sessions/route.ts +14 -0
  22. package/plugin/dashboard/app/configure/env/page.tsx +318 -0
  23. package/plugin/dashboard/app/configure/layout.tsx +47 -0
  24. package/plugin/dashboard/app/configure/page.tsx +5 -0
  25. package/plugin/dashboard/app/configure/server/page.tsx +258 -0
  26. package/plugin/dashboard/app/dashboard/page.tsx +227 -0
  27. package/plugin/dashboard/app/globals.css +129 -0
  28. package/plugin/dashboard/app/icon.png +0 -0
  29. package/plugin/dashboard/app/layout.tsx +40 -0
  30. package/plugin/dashboard/app/page.tsx +5 -0
  31. package/plugin/dashboard/app/preferences/[id]/page.tsx +531 -0
  32. package/plugin/dashboard/app/preferences/page.tsx +126 -0
  33. package/plugin/dashboard/app/providers.tsx +12 -0
  34. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +321 -0
  35. package/plugin/dashboard/app/sessions/page.tsx +186 -0
  36. package/plugin/dashboard/app/skills/page.tsx +362 -0
  37. package/plugin/dashboard/app/skills/project/[id]/page.tsx +597 -0
  38. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +830 -0
  39. package/plugin/dashboard/components/common/delete-all-button.tsx +45 -0
  40. package/plugin/dashboard/components/common/empty-state.tsx +34 -0
  41. package/plugin/dashboard/components/common/learnings-badge.tsx +34 -0
  42. package/plugin/dashboard/components/common/page-header.tsx +34 -0
  43. package/plugin/dashboard/components/common/page-tabs.tsx +115 -0
  44. package/plugin/dashboard/components/common/stat-card.tsx +38 -0
  45. package/plugin/dashboard/components/layout/nav-items.ts +22 -0
  46. package/plugin/dashboard/components/layout/sidebar.tsx +45 -0
  47. package/plugin/dashboard/components/layout/top-bar.tsx +64 -0
  48. package/plugin/dashboard/components/stall-banner.tsx +53 -0
  49. package/plugin/dashboard/components/ui/badge.tsx +52 -0
  50. package/plugin/dashboard/components/ui/button.tsx +60 -0
  51. package/plugin/dashboard/components/ui/collapsible.tsx +21 -0
  52. package/plugin/dashboard/components/ui/input.tsx +20 -0
  53. package/plugin/dashboard/components/ui/label.tsx +20 -0
  54. package/plugin/dashboard/components/ui/scroll-area.tsx +55 -0
  55. package/plugin/dashboard/components/ui/select.tsx +201 -0
  56. package/plugin/dashboard/components/ui/separator.tsx +25 -0
  57. package/plugin/dashboard/components/ui/sheet.tsx +135 -0
  58. package/plugin/dashboard/components/ui/switch.tsx +32 -0
  59. package/plugin/dashboard/components.json +25 -0
  60. package/plugin/dashboard/eslint.config.mjs +16 -0
  61. package/plugin/dashboard/hooks/use-settings.tsx +88 -0
  62. package/plugin/dashboard/hooks/use-stall-state.ts +59 -0
  63. package/plugin/dashboard/lib/claude-settings-file.ts +114 -0
  64. package/plugin/dashboard/lib/config-file.ts +131 -0
  65. package/plugin/dashboard/lib/format.ts +58 -0
  66. package/plugin/dashboard/lib/reflexio-client.ts +238 -0
  67. package/plugin/dashboard/lib/reflexio-url.ts +17 -0
  68. package/plugin/dashboard/lib/session-reader.ts +245 -0
  69. package/plugin/dashboard/lib/status.ts +24 -0
  70. package/plugin/dashboard/lib/types.ts +145 -0
  71. package/plugin/dashboard/lib/utils.ts +6 -0
  72. package/plugin/dashboard/next.config.ts +7 -0
  73. package/plugin/dashboard/package-lock.json +10275 -0
  74. package/plugin/dashboard/package.json +37 -0
  75. package/plugin/dashboard/postcss.config.mjs +7 -0
  76. package/plugin/dashboard/public/claude-smart-icon.png +0 -0
  77. package/plugin/dashboard/tsconfig.json +34 -0
  78. package/plugin/hooks/codex-hooks.json +67 -0
  79. package/plugin/hooks/hooks.json +111 -0
  80. package/plugin/pyproject.toml +49 -0
  81. package/plugin/scripts/_codex_env.sh +27 -0
  82. package/plugin/scripts/_lib.sh +325 -0
  83. package/plugin/scripts/backend-service.sh +208 -0
  84. package/plugin/scripts/cli.sh +40 -0
  85. package/plugin/scripts/dashboard-build.sh +139 -0
  86. package/plugin/scripts/dashboard-open.sh +107 -0
  87. package/plugin/scripts/dashboard-service.sh +195 -0
  88. package/plugin/scripts/ensure-plugin-root.sh +84 -0
  89. package/plugin/scripts/hook_entry.sh +70 -0
  90. package/plugin/scripts/smart-install.sh +411 -0
  91. package/plugin/src/claude_smart/__init__.py +3 -0
  92. package/plugin/src/claude_smart/cli.py +1273 -0
  93. package/plugin/src/claude_smart/context_format.py +277 -0
  94. package/plugin/src/claude_smart/context_inject.py +92 -0
  95. package/plugin/src/claude_smart/cs_cite.py +236 -0
  96. package/plugin/src/claude_smart/events/__init__.py +1 -0
  97. package/plugin/src/claude_smart/events/post_tool.py +148 -0
  98. package/plugin/src/claude_smart/events/pre_tool.py +52 -0
  99. package/plugin/src/claude_smart/events/session_end.py +20 -0
  100. package/plugin/src/claude_smart/events/session_start.py +119 -0
  101. package/plugin/src/claude_smart/events/stop.py +393 -0
  102. package/plugin/src/claude_smart/events/user_prompt.py +73 -0
  103. package/plugin/src/claude_smart/hook.py +114 -0
  104. package/plugin/src/claude_smart/ids.py +56 -0
  105. package/plugin/src/claude_smart/internal_call.py +89 -0
  106. package/plugin/src/claude_smart/optimizer_assistant.py +203 -0
  107. package/plugin/src/claude_smart/publish.py +71 -0
  108. package/plugin/src/claude_smart/query_compose.py +51 -0
  109. package/plugin/src/claude_smart/reflexio_adapter.py +403 -0
  110. package/plugin/src/claude_smart/runtime.py +52 -0
  111. package/plugin/src/claude_smart/stall_banner.py +61 -0
  112. package/plugin/src/claude_smart/state.py +276 -0
  113. 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()