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,276 @@
|
|
|
1
|
+
"""Per-session JSONL buffer for interactions awaiting publish to reflexio.
|
|
2
|
+
|
|
3
|
+
Each Claude Code session gets one file at
|
|
4
|
+
``~/.claude-smart/sessions/{session_id}.jsonl``. Lines are one of:
|
|
5
|
+
|
|
6
|
+
- ``{"role": "User", ...}`` — a user turn (see InteractionData fields)
|
|
7
|
+
- ``{"role": "Assistant", ...}`` — a finalized assistant turn
|
|
8
|
+
- ``{"role": "Assistant_tool", ...}`` — a single tool invocation, attached
|
|
9
|
+
to the next assistant turn at ``Stop`` time
|
|
10
|
+
- ``{"published_up_to": N}`` — high-water mark so Stop / SessionEnd don't
|
|
11
|
+
re-publish rows already sent to reflexio
|
|
12
|
+
|
|
13
|
+
The buffer exists for offline resilience: when reflexio is unreachable,
|
|
14
|
+
Stop appends without publishing and the next successful hook drains.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Iterable
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import fcntl # POSIX only — Windows hooks fall back to append-without-lock.
|
|
27
|
+
except ImportError: # pragma: no cover — non-POSIX platforms
|
|
28
|
+
fcntl = None # type: ignore[assignment]
|
|
29
|
+
|
|
30
|
+
_LOGGER = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_ENV_STATE_DIR = "CLAUDE_SMART_STATE_DIR"
|
|
33
|
+
_DEFAULT_STATE_DIR = Path.home() / ".claude-smart" / "sessions"
|
|
34
|
+
|
|
35
|
+
_TOOL_DATA_FIELD_MAX_LEN = 256
|
|
36
|
+
|
|
37
|
+
_VALID_CITATION_KINDS = frozenset({"playbook", "profile"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _truncate_tool_data_field(value: Any) -> Any:
|
|
41
|
+
"""Truncate a single tool_data field value to ``_TOOL_DATA_FIELD_MAX_LEN``.
|
|
42
|
+
|
|
43
|
+
Only *top-level string* values are shortened. Nested containers
|
|
44
|
+
(dicts, lists) and non-string scalars pass through unchanged, even if
|
|
45
|
+
the container holds overlong strings — extractor prompts built from
|
|
46
|
+
this payload are bounded upstream by reflexio, and truncating a mid-
|
|
47
|
+
structure string risks producing invalid JSON when the caller later
|
|
48
|
+
serializes. The cap keeps long fields (``Edit.old_string`` /
|
|
49
|
+
``new_string`` diffs, multi-line ``Bash`` scripts) from inflating the
|
|
50
|
+
extractor's input; short fields like file paths, URLs, and typical
|
|
51
|
+
commands stay intact. The value is tuned for extractor-prompt budget
|
|
52
|
+
predictability, not for preserving every character of a real
|
|
53
|
+
command — fields over the cap are treated as diff-style content
|
|
54
|
+
whose exact tail rarely changes what extraction learns.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
value (Any): A field value from the redacted tool_input dict.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Any: The value truncated to ``_TOOL_DATA_FIELD_MAX_LEN`` chars if it
|
|
61
|
+
was an overlong string, otherwise the original value.
|
|
62
|
+
"""
|
|
63
|
+
if isinstance(value, str) and len(value) > _TOOL_DATA_FIELD_MAX_LEN:
|
|
64
|
+
return value[:_TOOL_DATA_FIELD_MAX_LEN]
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def state_dir() -> Path:
|
|
69
|
+
"""Root directory for session JSONL files. Honours ``CLAUDE_SMART_STATE_DIR``."""
|
|
70
|
+
override = os.environ.get(_ENV_STATE_DIR)
|
|
71
|
+
return Path(override) if override else _DEFAULT_STATE_DIR
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def session_path(session_id: str) -> Path:
|
|
75
|
+
"""Return the JSONL path for a given session id."""
|
|
76
|
+
return state_dir() / f"{session_id}.jsonl"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def injected_path(session_id: str) -> Path:
|
|
80
|
+
"""Return the JSONL path for the per-session cs-cite registry."""
|
|
81
|
+
return state_dir() / f"{session_id}.injected.jsonl"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def append_injected(session_id: str, entries: Iterable[dict[str, Any]]) -> None:
|
|
85
|
+
"""Append citation-registry entries to the per-session injected-items file.
|
|
86
|
+
|
|
87
|
+
Each entry maps a short ``id`` (4-hex-char) back to the skill or
|
|
88
|
+
preference it came from so the Stop hook can resolve ids cited by
|
|
89
|
+
Claude via ``cs-cite`` into human-readable titles for the dashboard.
|
|
90
|
+
Silently no-ops when ``entries`` is empty.
|
|
91
|
+
"""
|
|
92
|
+
records = list(entries)
|
|
93
|
+
if not records:
|
|
94
|
+
return
|
|
95
|
+
path = injected_path(session_id)
|
|
96
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
98
|
+
if fcntl is not None:
|
|
99
|
+
try:
|
|
100
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
|
101
|
+
except OSError as exc:
|
|
102
|
+
_LOGGER.debug("flock failed on %s: %s", path, exc)
|
|
103
|
+
for rec in records:
|
|
104
|
+
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_injected(session_id: str) -> dict[str, dict[str, Any]]:
|
|
108
|
+
"""Return the per-session citation registry keyed by id.
|
|
109
|
+
|
|
110
|
+
Later entries win when the same id was injected multiple times
|
|
111
|
+
(identical content produces the same hash-derived id, so the extra
|
|
112
|
+
record only refreshes metadata).
|
|
113
|
+
"""
|
|
114
|
+
path = injected_path(session_id)
|
|
115
|
+
if not path.exists():
|
|
116
|
+
return {}
|
|
117
|
+
registry: dict[str, dict[str, Any]] = {}
|
|
118
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
119
|
+
for line in fh:
|
|
120
|
+
line = line.strip()
|
|
121
|
+
if not line:
|
|
122
|
+
continue
|
|
123
|
+
try:
|
|
124
|
+
entry = json.loads(line)
|
|
125
|
+
except json.JSONDecodeError as exc:
|
|
126
|
+
_LOGGER.warning("Skipping malformed injected line in %s: %s", path, exc)
|
|
127
|
+
continue
|
|
128
|
+
item_id = entry.get("id")
|
|
129
|
+
if isinstance(item_id, str) and item_id:
|
|
130
|
+
registry[item_id] = entry
|
|
131
|
+
return registry
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def append(session_id: str, record: dict[str, Any]) -> None:
|
|
135
|
+
"""Append one JSON record to the session buffer. Creates the dir if needed.
|
|
136
|
+
|
|
137
|
+
Holds an exclusive ``flock`` on the buffer file across the write so
|
|
138
|
+
concurrent hooks (e.g. parallel ``PostToolUse`` fires) cannot interleave
|
|
139
|
+
JSON lines when a payload exceeds the buffered-writer's flush size.
|
|
140
|
+
"""
|
|
141
|
+
path = session_path(session_id)
|
|
142
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
line = json.dumps(record, ensure_ascii=False) + "\n"
|
|
144
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
145
|
+
if fcntl is not None:
|
|
146
|
+
try:
|
|
147
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
|
148
|
+
except OSError as exc:
|
|
149
|
+
_LOGGER.debug("flock failed on %s: %s", path, exc)
|
|
150
|
+
fh.write(line)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def read_all(session_id: str) -> list[dict[str, Any]]:
|
|
154
|
+
"""Return every record in the buffer as a list of dicts. Missing file → []."""
|
|
155
|
+
path = session_path(session_id)
|
|
156
|
+
if not path.exists():
|
|
157
|
+
return []
|
|
158
|
+
records: list[dict[str, Any]] = []
|
|
159
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
160
|
+
for line in fh:
|
|
161
|
+
line = line.strip()
|
|
162
|
+
if not line:
|
|
163
|
+
continue
|
|
164
|
+
try:
|
|
165
|
+
records.append(json.loads(line))
|
|
166
|
+
except json.JSONDecodeError as exc:
|
|
167
|
+
_LOGGER.warning("Skipping malformed buffer line in %s: %s", path, exc)
|
|
168
|
+
return records
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _to_wire_citations(cited_items: Any) -> list[dict[str, str]]:
|
|
172
|
+
"""Map local ``cited_items`` to the wire ``Citation`` shape.
|
|
173
|
+
|
|
174
|
+
Local entries (from ``events.stop._resolve_cited_items``) carry
|
|
175
|
+
``{id, kind, title, real_id}``; reflexio's ``InteractionData.citations``
|
|
176
|
+
wants ``{kind, real_id, tag, title}`` where ``tag`` is the rank id
|
|
177
|
+
(``s1-301``-style) we already keep under ``id``. Entries without a
|
|
178
|
+
``real_id`` (unresolved injections) are dropped — the server can't
|
|
179
|
+
join them back to a stored row.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
cited_items (Any): The list-of-dicts blob attached to an Assistant
|
|
183
|
+
turn record, or ``None`` when the turn cited nothing.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
list[dict[str, str]]: Citation dicts ready to be folded into an
|
|
187
|
+
``InteractionData`` payload. Empty when ``cited_items`` is
|
|
188
|
+
missing, malformed, or contains nothing resolvable.
|
|
189
|
+
"""
|
|
190
|
+
if not isinstance(cited_items, list):
|
|
191
|
+
return []
|
|
192
|
+
out: list[dict[str, str]] = []
|
|
193
|
+
for item in cited_items:
|
|
194
|
+
if not isinstance(item, dict):
|
|
195
|
+
continue
|
|
196
|
+
real_id = item.get("real_id")
|
|
197
|
+
kind = item.get("kind")
|
|
198
|
+
if not isinstance(real_id, str) or not real_id:
|
|
199
|
+
continue
|
|
200
|
+
if kind not in _VALID_CITATION_KINDS:
|
|
201
|
+
continue
|
|
202
|
+
tag = item.get("id")
|
|
203
|
+
title = item.get("title")
|
|
204
|
+
out.append(
|
|
205
|
+
{
|
|
206
|
+
"kind": kind,
|
|
207
|
+
"real_id": real_id,
|
|
208
|
+
"tag": tag if isinstance(tag, str) else "",
|
|
209
|
+
"title": title if isinstance(title, str) else "",
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
return out
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def unpublished_slice(
|
|
216
|
+
records: Iterable[dict[str, Any]],
|
|
217
|
+
) -> tuple[int, list[dict[str, Any]]]:
|
|
218
|
+
"""Split records into (last-published index, unpublished turn records).
|
|
219
|
+
|
|
220
|
+
Walks the records in order, tracking the most recent ``published_up_to``
|
|
221
|
+
marker and collecting turn records (anything with a ``role``) that come
|
|
222
|
+
after it. Tool records are folded into the closest following Assistant
|
|
223
|
+
turn's ``tools_used``.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
tuple[int, list[dict]]: ``(published_up_to, interactions)``. The
|
|
227
|
+
integer is the watermark after which all turns are unpublished;
|
|
228
|
+
the list is formatted for ``InteractionData`` construction.
|
|
229
|
+
"""
|
|
230
|
+
published = 0
|
|
231
|
+
pending_tools: list[dict[str, Any]] = []
|
|
232
|
+
turns: list[dict[str, Any]] = []
|
|
233
|
+
for idx, rec in enumerate(records):
|
|
234
|
+
if "published_up_to" in rec:
|
|
235
|
+
published = rec["published_up_to"]
|
|
236
|
+
pending_tools = []
|
|
237
|
+
turns = []
|
|
238
|
+
continue
|
|
239
|
+
if idx < published:
|
|
240
|
+
continue
|
|
241
|
+
role = rec.get("role")
|
|
242
|
+
if role == "Assistant_tool":
|
|
243
|
+
tool_input = rec.get("tool_input") or {}
|
|
244
|
+
tool_output = rec.get("tool_output") or ""
|
|
245
|
+
tool_entry: dict[str, Any] = {
|
|
246
|
+
"tool_name": rec.get("tool_name", ""),
|
|
247
|
+
"status": rec.get("status", "success"),
|
|
248
|
+
}
|
|
249
|
+
tool_data: dict[str, Any] = {}
|
|
250
|
+
if tool_input:
|
|
251
|
+
tool_data["input"] = {
|
|
252
|
+
k: _truncate_tool_data_field(v) for k, v in tool_input.items()
|
|
253
|
+
}
|
|
254
|
+
if tool_output:
|
|
255
|
+
tool_data["output"] = _truncate_tool_data_field(tool_output)
|
|
256
|
+
if tool_data:
|
|
257
|
+
tool_entry["tool_data"] = tool_data
|
|
258
|
+
pending_tools.append(tool_entry)
|
|
259
|
+
continue
|
|
260
|
+
if role in {"User", "Assistant"}:
|
|
261
|
+
# ``cited_items`` is local-only metadata (dashboard "used" badge);
|
|
262
|
+
# map it onto the wire's ``citations`` field — reflexio uses those
|
|
263
|
+
# to drive skill/preference reflection in the publish flow.
|
|
264
|
+
turn = {
|
|
265
|
+
k: v for k, v in rec.items() if k not in {"role", "ts", "cited_items"}
|
|
266
|
+
}
|
|
267
|
+
turn["role"] = role
|
|
268
|
+
if role == "Assistant":
|
|
269
|
+
citations = _to_wire_citations(rec.get("cited_items"))
|
|
270
|
+
if citations:
|
|
271
|
+
turn["citations"] = citations
|
|
272
|
+
if pending_tools:
|
|
273
|
+
turn["tools_used"] = pending_tools
|
|
274
|
+
pending_tools = []
|
|
275
|
+
turns.append(turn)
|
|
276
|
+
return published, turns
|