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,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