claude-smart 0.2.22 → 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,148 @@
1
+ """PostToolUse hook — record the tool invocation and its outcome."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+ from typing import Any
8
+
9
+ from claude_smart import state
10
+
11
+ # Tool inputs are persisted locally and later published to reflexio, so we
12
+ # apply a conservative redaction pass at ingestion time. Chosen to avoid
13
+ # false positives over maximal coverage — the dashboard shows these
14
+ # verbatim, and users noticing a masked command is far less surprising
15
+ # than a masked `LOG_LEVEL=INFO`.
16
+ _MAX_STR_LEN = 4096
17
+ _SECRET_ASSIGNMENT = re.compile(
18
+ r"(?P<key>[A-Z][A-Z0-9_]{2,})=(?P<quote>['\"]?)"
19
+ r"(?P<value>[A-Za-z0-9+/=_\-]{20,})(?P=quote)"
20
+ )
21
+
22
+
23
+ def _looks_like_secret(value: str) -> bool:
24
+ """Heuristic: mixed-case letters plus digits suggest a high-entropy token."""
25
+ has_lower = any(c.islower() for c in value)
26
+ has_upper = any(c.isupper() for c in value)
27
+ has_digit = any(c.isdigit() for c in value)
28
+ return has_lower and has_upper and has_digit
29
+
30
+
31
+ def _mask_secrets(text: str) -> str:
32
+ def sub(match: "re.Match[str]") -> str:
33
+ value = match.group("value")
34
+ if not _looks_like_secret(value):
35
+ return match.group(0)
36
+ key = match.group("key")
37
+ quote = match.group("quote")
38
+ return f"{key}={quote}<redacted:{len(value)}>{quote}"
39
+
40
+ return _SECRET_ASSIGNMENT.sub(sub, text)
41
+
42
+
43
+ def _redact_string(value: str) -> str:
44
+ masked = _mask_secrets(value)
45
+ if len(masked) > _MAX_STR_LEN:
46
+ return masked[:_MAX_STR_LEN] + "…(truncated)"
47
+ return masked
48
+
49
+
50
+ def _redact(tool_input: dict[str, Any]) -> dict[str, Any]:
51
+ """Redact obvious secrets and truncate oversized string values.
52
+
53
+ Only top-level string values are inspected — nested dicts/lists and
54
+ non-string scalars pass through unchanged. Claude Code tool payloads
55
+ are flat in practice (Bash.command, Edit.file_path, etc.), so deeper
56
+ recursion isn't worth the false-positive surface.
57
+
58
+ Args:
59
+ tool_input (dict[str, Any]): Raw ``tool_input`` from the PostToolUse
60
+ payload.
61
+
62
+ Returns:
63
+ dict[str, Any]: New dict with redaction applied.
64
+ """
65
+ return {
66
+ k: _redact_string(v) if isinstance(v, str) else v for k, v in tool_input.items()
67
+ }
68
+
69
+
70
+ def _derive_status(tool_response: Any) -> str:
71
+ """Classify the tool outcome as 'success' or 'error'.
72
+
73
+ Claude Code's PostToolUse payload puts the tool response under
74
+ ``tool_response``, which may be a dict (with an ``is_error`` / ``error``
75
+ field) or a bare string. Unknown shapes default to success.
76
+ """
77
+ if isinstance(tool_response, dict):
78
+ if tool_response.get("is_error") or tool_response.get("error"):
79
+ return "error"
80
+ return "success"
81
+
82
+
83
+ _OUTPUT_TEXT_KEYS = ("stdout", "stderr", "output", "content", "text", "error")
84
+
85
+
86
+ def _flatten_tool_response_text(tool_response: Any) -> str:
87
+ """Flatten ``tool_response`` into a single string for buffering.
88
+
89
+ Claude Code delivers tool responses in heterogeneous shapes — Bash sends
90
+ a dict with ``stdout``/``stderr``, Edit/Read send a string or a dict
91
+ with ``content``/``output``, and failures populate ``error``. Joining
92
+ the well-known string-valued keys preserves the parts most useful for
93
+ downstream learning (failure messages, command output) without
94
+ serializing entire structured payloads.
95
+
96
+ Note: structured ``content`` lists (e.g. ``[{type: 'text', text: ...}]``)
97
+ are not flattened — only top-level string values are joined. If a tool
98
+ starts emitting block-form content we want to capture, add a dedicated
99
+ branch here.
100
+
101
+ Args:
102
+ tool_response (Any): The raw ``tool_response`` from the PostToolUse
103
+ payload.
104
+
105
+ Returns:
106
+ str: A flattened string. Empty when no textual content is present.
107
+ """
108
+ if tool_response is None:
109
+ return ""
110
+ if isinstance(tool_response, str):
111
+ return tool_response
112
+ if isinstance(tool_response, dict):
113
+ parts = [
114
+ tool_response[key]
115
+ for key in _OUTPUT_TEXT_KEYS
116
+ if isinstance(tool_response.get(key), str) and tool_response[key]
117
+ ]
118
+ if parts:
119
+ return "\n".join(parts)
120
+ for key in ("text", "content"):
121
+ value = tool_response.get(key)
122
+ if isinstance(value, str):
123
+ return value
124
+ return ""
125
+ for attr in ("text", "content"):
126
+ value = getattr(tool_response, attr, None)
127
+ if isinstance(value, str):
128
+ return value
129
+ return ""
130
+
131
+
132
+ def handle(payload: dict[str, Any]) -> None:
133
+ session_id = payload.get("session_id")
134
+ tool_name = payload.get("tool_name") or ""
135
+ if not session_id or not tool_name:
136
+ return
137
+
138
+ tool_response = payload.get("tool_response")
139
+ output_text = _flatten_tool_response_text(tool_response)
140
+ record = {
141
+ "ts": int(time.time()),
142
+ "role": "Assistant_tool",
143
+ "tool_name": tool_name,
144
+ "tool_input": _redact(payload.get("tool_input") or {}),
145
+ "tool_output": _redact_string(output_text) if output_text else "",
146
+ "status": _derive_status(tool_response),
147
+ }
148
+ state.append(session_id, record)
@@ -0,0 +1,52 @@
1
+ """PreToolUse hook — just-in-time skill + preference inject before a mutating tool.
2
+
3
+ Fires only for tools listed in ``hooks.json``'s PreToolUse matcher
4
+ (Edit/Write/NotebookEdit/Bash). Composes a query from the tool call and
5
+ delegates to ``context_inject.emit_context`` for the shared
6
+ search-render-emit pipeline, falling back to ``hook.emit_continue`` when
7
+ there is nothing to inject or the search raises.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from typing import Any
14
+
15
+ from claude_smart import context_inject, hook, ids, query_compose, runtime
16
+
17
+ _LOGGER = logging.getLogger(__name__)
18
+ _TOP_K = 3
19
+
20
+
21
+ def handle(payload: dict[str, Any]) -> None:
22
+ """PreToolUse dispatcher — never raises; degrades to ``emit_continue``."""
23
+ if runtime.is_codex():
24
+ hook.emit_continue()
25
+ return
26
+
27
+ session_id = payload.get("session_id")
28
+ tool_name = payload.get("tool_name")
29
+ tool_input = payload.get("tool_input") or {}
30
+ if not session_id or not tool_name:
31
+ hook.emit_continue()
32
+ return
33
+
34
+ query = query_compose.from_tool_call(tool_name, tool_input)
35
+ if not query:
36
+ hook.emit_continue()
37
+ return
38
+
39
+ project_id = ids.resolve_project_id(payload.get("cwd"))
40
+ try:
41
+ emitted = context_inject.emit_context(
42
+ session_id=session_id,
43
+ project_id=project_id,
44
+ query=query,
45
+ hook_event_name="PreToolUse",
46
+ top_k=_TOP_K,
47
+ )
48
+ except Exception as exc: # noqa: BLE001 — never block a tool call.
49
+ _LOGGER.debug("pre_tool context inject failed: %s", exc)
50
+ emitted = False
51
+ if not emitted:
52
+ hook.emit_continue()
@@ -0,0 +1,20 @@
1
+ """SessionEnd hook — flush any remaining interactions; extraction runs async."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from claude_smart import ids, publish
8
+
9
+
10
+ def handle(payload: dict[str, Any]) -> None:
11
+ session_id = payload.get("session_id")
12
+ if not session_id:
13
+ return
14
+ project_id = ids.resolve_project_id(payload.get("cwd"))
15
+ publish.publish_unpublished(
16
+ session_id=session_id,
17
+ project_id=project_id,
18
+ force_extraction=False,
19
+ skip_aggregation=False,
20
+ )
@@ -0,0 +1,119 @@
1
+ """SessionStart hook — apply startup defaults without broad memory retrieval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from claude_smart import hook
12
+ from claude_smart.reflexio_adapter import Adapter
13
+ from claude_smart.stall_banner import render_banner
14
+
15
+ # Claude-smart's preferred extraction cadence — more frequent, smaller windows
16
+ # than reflexio's out-of-box 10/5. Applied idempotently to the reflexio server
17
+ # on every SessionStart via Adapter.apply_extraction_defaults.
18
+ _CLAUDE_SMART_WINDOW_SIZE = 5
19
+ _CLAUDE_SMART_STRIDE_SIZE = 3
20
+ # Optimizer is on by default. Set this env var to "0" to skip pushing the
21
+ # claude-smart optimizer defaults on SessionStart (kill switch).
22
+ _DISABLE_OPTIMIZER_ENV = "CLAUDE_SMART_ENABLE_OPTIMIZER"
23
+ _OPTIMIZER_TIMEOUT_SECONDS = 300
24
+
25
+
26
+ def _adapter() -> Adapter:
27
+ """Construct the reflexio adapter for this hook invocation.
28
+
29
+ Indirected through a factory so tests can monkeypatch the adapter
30
+ construction without touching the ``Adapter`` class itself.
31
+
32
+ Returns:
33
+ Adapter: A fresh adapter bound to the current process env.
34
+ """
35
+ return Adapter()
36
+
37
+
38
+ def _stall_banner(adapter: Any) -> str:
39
+ """Return the prepend-able stall banner, or "" if no banner should fire.
40
+
41
+ Reads ``adapter.fetch_stall_state()``; if it reports an active, not-yet-
42
+ notified stall, renders a one-line banner via ``stall_banner.render_banner``.
43
+ All exceptions are absorbed: this is defense-in-depth — even though the
44
+ hook dispatcher already wraps ``handle`` in try/except, a stall-path bug
45
+ must never block the existing playbook/profile rendering.
46
+
47
+ Args:
48
+ adapter (Any): The adapter to query. Duck-typed so tests can stub.
49
+
50
+ Returns:
51
+ str: The banner text, or ``""`` when there is nothing to show.
52
+ """
53
+ try:
54
+ state_obj = adapter.fetch_stall_state()
55
+ except Exception: # noqa: BLE001 — stall path must never crash the hook.
56
+ return ""
57
+ if state_obj is None:
58
+ return ""
59
+ if not getattr(state_obj, "stalled", False):
60
+ return ""
61
+ if getattr(state_obj, "notified_in_cc", False):
62
+ return ""
63
+ try:
64
+ return render_banner(
65
+ reason=getattr(state_obj, "reason", None),
66
+ reset_estimate=getattr(state_obj, "reset_estimate", None),
67
+ )
68
+ except Exception: # noqa: BLE001 — render_banner bug must not block playbook injection.
69
+ return ""
70
+
71
+
72
+ def handle(payload: dict[str, Any]) -> None:
73
+ session_id = payload.get("session_id")
74
+ if not session_id:
75
+ hook.emit_continue()
76
+ return
77
+
78
+ adapter = _adapter()
79
+
80
+ # Stall banner — prepended to additionalContext, fires at most once per
81
+ # stall event (controlled server-side via mark_stall_notified).
82
+ banner = _stall_banner(adapter)
83
+
84
+ adapter.apply_extraction_defaults(
85
+ window_size=_CLAUDE_SMART_WINDOW_SIZE,
86
+ stride_size=_CLAUDE_SMART_STRIDE_SIZE,
87
+ )
88
+ if os.environ.get(_DISABLE_OPTIMIZER_ENV) != "0":
89
+ adapter.apply_optimizer_defaults(
90
+ script_path=_optimizer_assistant_path(),
91
+ timeout_seconds=_OPTIMIZER_TIMEOUT_SECONDS,
92
+ )
93
+
94
+ if not banner:
95
+ hook.emit_continue()
96
+ return
97
+
98
+ sys.stdout.write(
99
+ json.dumps(
100
+ {
101
+ "hookSpecificOutput": {
102
+ "hookEventName": "SessionStart",
103
+ "additionalContext": banner,
104
+ }
105
+ }
106
+ )
107
+ )
108
+ sys.stdout.write("\n")
109
+
110
+ try:
111
+ adapter.mark_stall_notified()
112
+ except Exception: # noqa: BLE001 — telemetry must not break session.
113
+ pass
114
+
115
+
116
+ def _optimizer_assistant_path() -> str:
117
+ executable = Path(sys.executable)
118
+ suffix = ".exe" if os.name == "nt" else ""
119
+ return str(executable.with_name(f"claude-smart-optimizer-assistant{suffix}"))