claude-smart 0.2.27 → 0.2.29
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/README.md +22 -1
- package/bin/claude-smart.js +333 -73
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/README.md +4 -0
- package/plugin/hooks/codex-hooks.json +5 -0
- package/plugin/hooks/hooks.json +10 -0
- package/plugin/pyproject.toml +1 -1
- package/plugin/scripts/_lib.sh +38 -0
- package/plugin/scripts/backend-log-runner.sh +33 -0
- package/plugin/scripts/backend-service.sh +51 -9
- package/plugin/scripts/cli.sh +27 -3
- package/plugin/scripts/codex-claude-compat +9 -0
- package/plugin/scripts/codex-claude-compat.cmd +4 -0
- package/plugin/scripts/codex-claude-compat.js +162 -0
- package/plugin/scripts/codex-hook.js +30 -2
- package/plugin/scripts/smart-install.sh +136 -50
- package/plugin/src/claude_smart/cli.py +101 -2
- package/plugin/src/claude_smart/context_inject.py +2 -4
- package/plugin/src/claude_smart/cs_cite.py +2 -90
- package/plugin/src/claude_smart/events/stop.py +16 -42
- package/plugin/src/claude_smart/internal_call.py +23 -0
- package/plugin/src/claude_smart/state.py +3 -3
- package/plugin/uv.lock +73 -76
- package/plugin/bin/cs-cite +0 -77
- package/plugin/scripts/codex-claude-compat.py +0 -144
|
@@ -20,18 +20,121 @@ REPO_ROOT="$(cd "$HERE/../.." && pwd)"
|
|
|
20
20
|
|
|
21
21
|
MARKER_DIR="$HOME/.claude-smart"
|
|
22
22
|
FAILURE_MARKER="$MARKER_DIR/install-failed"
|
|
23
|
+
SUCCESS_MARKER="$MARKER_DIR/install-complete"
|
|
24
|
+
INSTALL_LOCK="$MARKER_DIR/install.lock"
|
|
23
25
|
mkdir -p "$MARKER_DIR"
|
|
26
|
+
|
|
27
|
+
# Serialize concurrent installer runs (SessionStart hook + slash-command
|
|
28
|
+
# self-heal can both invoke this script). Wait for the active installer
|
|
29
|
+
# rather than returning early, otherwise callers can re-check uv before
|
|
30
|
+
# the first install has finished and report a false missing-dependency error.
|
|
31
|
+
if command -v flock >/dev/null 2>&1; then
|
|
32
|
+
exec 9>"$INSTALL_LOCK"
|
|
33
|
+
if ! flock 9; then
|
|
34
|
+
echo "[claude-smart] install lock failed; continuing without serialization" >&2
|
|
35
|
+
echo '{"continue":true,"suppressOutput":true}'
|
|
36
|
+
exit 0
|
|
37
|
+
fi
|
|
38
|
+
fi
|
|
39
|
+
|
|
24
40
|
rm -f "$FAILURE_MARKER"
|
|
25
41
|
|
|
26
42
|
write_failure() {
|
|
27
43
|
local reason
|
|
28
44
|
reason="$1"
|
|
29
45
|
printf '%s\n' "$reason" > "$FAILURE_MARKER"
|
|
46
|
+
rm -f "$SUCCESS_MARKER"
|
|
30
47
|
echo "[claude-smart] install failed: $reason" >&2
|
|
31
48
|
echo '{"continue":true,"suppressOutput":true}'
|
|
32
49
|
exit 0
|
|
33
50
|
}
|
|
34
51
|
|
|
52
|
+
fingerprint_file() {
|
|
53
|
+
local path
|
|
54
|
+
path="$1"
|
|
55
|
+
if [ -f "$path" ]; then
|
|
56
|
+
cksum "$path" 2>/dev/null | awk '{print $1 ":" $2}'
|
|
57
|
+
else
|
|
58
|
+
printf 'missing\n'
|
|
59
|
+
fi
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
install_fingerprint() {
|
|
63
|
+
printf 'plugin_root=%s\n' "$PLUGIN_ROOT"
|
|
64
|
+
printf 'smart_install=%s\n' "$(fingerprint_file "$HERE/smart-install.sh")"
|
|
65
|
+
printf 'pyproject=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/pyproject.toml")"
|
|
66
|
+
printf 'uv_lock=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/uv.lock")"
|
|
67
|
+
# Resolved python interpreter — catches a system upgrade (3.12.4 → 3.12.5)
|
|
68
|
+
# that would otherwise let install_complete return true against a venv
|
|
69
|
+
# built against a now-deleted interpreter.
|
|
70
|
+
if command -v uv >/dev/null 2>&1; then
|
|
71
|
+
printf 'python=%s\n' "$(uv python find 3.12 2>/dev/null || echo missing)"
|
|
72
|
+
else
|
|
73
|
+
printf 'python=no-uv\n'
|
|
74
|
+
fi
|
|
75
|
+
if [ -d "$PLUGIN_ROOT/dashboard" ]; then
|
|
76
|
+
printf 'dashboard_pkg=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/dashboard/package.json")"
|
|
77
|
+
printf 'dashboard_lock=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/dashboard/package-lock.json")"
|
|
78
|
+
else
|
|
79
|
+
printf 'dashboard_pkg=none\n'
|
|
80
|
+
printf 'dashboard_lock=none\n'
|
|
81
|
+
fi
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
install_complete() {
|
|
85
|
+
[ -f "$SUCCESS_MARKER" ] || return 1
|
|
86
|
+
[ "$(cat "$SUCCESS_MARKER" 2>/dev/null || true)" = "$(install_fingerprint)" ] || return 1
|
|
87
|
+
command -v uv >/dev/null 2>&1 || return 1
|
|
88
|
+
[ -d "$PLUGIN_ROOT/.venv" ] || return 1
|
|
89
|
+
[ -f "$HOME/.reflexio/.env" ] || return 1
|
|
90
|
+
grep -q '^CLAUDE_SMART_USE_LOCAL_CLI=' "$HOME/.reflexio/.env" || return 1
|
|
91
|
+
grep -q '^CLAUDE_SMART_USE_LOCAL_EMBEDDING=' "$HOME/.reflexio/.env" || return 1
|
|
92
|
+
if [ -d "$PLUGIN_ROOT/dashboard" ]; then
|
|
93
|
+
[ -d "$PLUGIN_ROOT/dashboard/.next" ] || [ -f "$MARKER_DIR/dashboard-build.pid" ] || [ -f "$(claude_smart_dashboard_unavailable_marker)" ] || return 1
|
|
94
|
+
fi
|
|
95
|
+
return 0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
write_success_marker() {
|
|
99
|
+
install_fingerprint > "$SUCCESS_MARKER"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
preflight_supported_runtime_platform() {
|
|
103
|
+
local os_name machine darwin_major
|
|
104
|
+
os_name="$(uname -s 2>/dev/null || echo unknown)"
|
|
105
|
+
machine="$(uname -m 2>/dev/null || echo unknown)"
|
|
106
|
+
case "$os_name" in
|
|
107
|
+
Darwin*)
|
|
108
|
+
if [ "$machine" != "arm64" ]; then
|
|
109
|
+
write_failure "claude-smart currently supports Apple Silicon macOS 14+ only; Intel Mac is not supported because native ML wheels are unavailable."
|
|
110
|
+
fi
|
|
111
|
+
darwin_major="$(uname -r 2>/dev/null | awk -F. '{print $1}')"
|
|
112
|
+
case "$darwin_major" in
|
|
113
|
+
''|*[!0-9]*)
|
|
114
|
+
write_failure "claude-smart could not determine the macOS version; Apple Silicon macOS 14+ is required."
|
|
115
|
+
;;
|
|
116
|
+
esac
|
|
117
|
+
if [ "$darwin_major" -lt 23 ]; then
|
|
118
|
+
write_failure "claude-smart currently supports macOS 14+ on Apple Silicon; macOS 13 and older are not supported because native ML wheels are unavailable."
|
|
119
|
+
fi
|
|
120
|
+
;;
|
|
121
|
+
MINGW*|MSYS*|CYGWIN*)
|
|
122
|
+
case "$machine" in
|
|
123
|
+
x86_64|amd64) : ;;
|
|
124
|
+
*)
|
|
125
|
+
write_failure "claude-smart currently supports Windows x64 only; Windows ARM is not supported because native ML wheels are unavailable."
|
|
126
|
+
;;
|
|
127
|
+
esac
|
|
128
|
+
;;
|
|
129
|
+
Linux*)
|
|
130
|
+
: # Existing Linux installs remain supported when package wheels are available.
|
|
131
|
+
;;
|
|
132
|
+
*)
|
|
133
|
+
write_failure "claude-smart currently supports Apple Silicon macOS 14+, Windows x64, and Linux for vanilla installs."
|
|
134
|
+
;;
|
|
135
|
+
esac
|
|
136
|
+
}
|
|
137
|
+
|
|
35
138
|
install_private_node() {
|
|
36
139
|
local NODE_MIN_MAJOR NODE_MIN_MINOR NODE_LTS_MAJOR
|
|
37
140
|
local node_os archive_ext reason node_arch node_platform base_url node_root
|
|
@@ -236,6 +339,13 @@ if [ "${CLAUDE_SMART_INSTALL_PRIVATE_NODE_ONLY:-}" = "1" ]; then
|
|
|
236
339
|
exit $?
|
|
237
340
|
fi
|
|
238
341
|
|
|
342
|
+
preflight_supported_runtime_platform
|
|
343
|
+
|
|
344
|
+
if install_complete; then
|
|
345
|
+
echo '{"continue":true,"suppressOutput":true}'
|
|
346
|
+
exit 0
|
|
347
|
+
fi
|
|
348
|
+
|
|
239
349
|
# Dev-mode only: when running from a git checkout, pull the reflexio
|
|
240
350
|
# submodule so tests/benchmarks can use its sources. In install mode the
|
|
241
351
|
# plugin lives under ~/.claude/plugins/cache and reflexio-ai resolves
|
|
@@ -327,57 +437,32 @@ if ! command -v claude >/dev/null 2>&1; then
|
|
|
327
437
|
echo "[claude-smart] WARNING: 'claude' CLI not on PATH — reflexio extractors will have no LLM until it's installed" >&2
|
|
328
438
|
fi
|
|
329
439
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
440
|
+
LEGACY_CS_CITE="$HOME/.claude-smart/bin/cs-cite"
|
|
441
|
+
if [ -e "$LEGACY_CS_CITE" ]; then
|
|
442
|
+
rm -f "$LEGACY_CS_CITE"
|
|
443
|
+
echo "[claude-smart] removed legacy cs-cite helper at $LEGACY_CS_CITE" >&2
|
|
444
|
+
fi
|
|
445
|
+
|
|
336
446
|
CLAUDE_SETTINGS="$HOME/.claude/settings.json"
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
)
|
|
357
|
-
sys.exit(2)
|
|
358
|
-
def _warn_and_exit(reason: str) -> None:
|
|
359
|
-
print(
|
|
360
|
-
f"[claude-smart] WARNING: {path} {reason}; skipping cs-cite allowlist",
|
|
361
|
-
file=sys.stderr,
|
|
362
|
-
)
|
|
363
|
-
sys.exit(2)
|
|
364
|
-
|
|
365
|
-
if not isinstance(data, dict):
|
|
366
|
-
_warn_and_exit("top-level is not a JSON object")
|
|
367
|
-
permissions = data.setdefault("permissions", {})
|
|
368
|
-
if not isinstance(permissions, dict):
|
|
369
|
-
_warn_and_exit("'permissions' is not a JSON object")
|
|
370
|
-
allow = permissions.setdefault("allow", [])
|
|
371
|
-
if not isinstance(allow, list):
|
|
372
|
-
_warn_and_exit("'permissions.allow' is not a JSON array")
|
|
373
|
-
if entry in allow:
|
|
374
|
-
sys.exit(1) # already present — convey via exit code so shell can skip the log
|
|
375
|
-
allow.append(entry)
|
|
376
|
-
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
377
|
-
sys.exit(0)
|
|
378
|
-
PY
|
|
379
|
-
then
|
|
380
|
-
echo "[claude-smart] added Bash(cs-cite:*) to $CLAUDE_SETTINGS permissions.allow" >&2
|
|
447
|
+
if [ -f "$CLAUDE_SETTINGS" ] && command -v node >/dev/null 2>&1; then
|
|
448
|
+
node - "$CLAUDE_SETTINGS" <<'JS' >&2 || true
|
|
449
|
+
const fs = require("fs");
|
|
450
|
+
const path = process.argv[2];
|
|
451
|
+
const entry = "Bash(cs-cite:*)";
|
|
452
|
+
let data;
|
|
453
|
+
try {
|
|
454
|
+
data = JSON.parse(fs.readFileSync(path, "utf8") || "{}");
|
|
455
|
+
} catch {
|
|
456
|
+
process.exit(0);
|
|
457
|
+
}
|
|
458
|
+
const allow = data?.permissions?.allow;
|
|
459
|
+
if (!Array.isArray(allow)) process.exit(0);
|
|
460
|
+
const next = allow.filter((item) => item !== entry);
|
|
461
|
+
if (next.length === allow.length) process.exit(0);
|
|
462
|
+
data.permissions.allow = next;
|
|
463
|
+
fs.writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
|
|
464
|
+
console.error(`[claude-smart] removed legacy ${entry} permission from ${path}`);
|
|
465
|
+
JS
|
|
381
466
|
fi
|
|
382
467
|
|
|
383
468
|
# Spawn the dashboard build detached so install returns immediately and
|
|
@@ -406,5 +491,6 @@ if ! bash "$HERE/ensure-plugin-root.sh" "$PLUGIN_ROOT"; then
|
|
|
406
491
|
echo "[claude-smart] WARNING: failed to set ~/.reflexio/plugin-root symlink — slash commands may not resolve" >&2
|
|
407
492
|
fi
|
|
408
493
|
|
|
494
|
+
write_success_marker
|
|
409
495
|
echo "[claude-smart] install complete. Backend and dashboard auto-start on session start." >&2
|
|
410
496
|
echo '{"continue":true,"suppressOutput":true}'
|
|
@@ -69,6 +69,8 @@ _DASHBOARD_DIR = _PLUGIN_ROOT / "dashboard"
|
|
|
69
69
|
_BACKEND_SCRIPT = _SCRIPTS_DIR / "backend-service.sh"
|
|
70
70
|
_DASHBOARD_SCRIPT = _SCRIPTS_DIR / "dashboard-service.sh"
|
|
71
71
|
_REFLEXIO_DIR = Path.home() / ".reflexio"
|
|
72
|
+
_STATE_DIR = Path.home() / ".claude-smart"
|
|
73
|
+
_INSTALL_FAILURE_MARKER = _STATE_DIR / "install-failed"
|
|
72
74
|
_DEFAULT_STORAGE_ROOT = _REFLEXIO_DIR / "data"
|
|
73
75
|
_REFLEXIO_CONFIG_PATH = _REFLEXIO_DIR / "configs" / "config_self-host-org.json"
|
|
74
76
|
_LOCAL_STORAGE_ENV = "LOCAL_STORAGE_PATH"
|
|
@@ -76,8 +78,11 @@ _CODEX_REQUIRED_FILES = (
|
|
|
76
78
|
Path(".agents/plugins/marketplace.json"),
|
|
77
79
|
Path("plugin/.codex-plugin/plugin.json"),
|
|
78
80
|
Path("plugin/hooks/codex-hooks.json"),
|
|
79
|
-
Path("plugin/scripts/codex-claude-compat
|
|
81
|
+
Path("plugin/scripts/codex-claude-compat"),
|
|
82
|
+
Path("plugin/scripts/codex-claude-compat.cmd"),
|
|
83
|
+
Path("plugin/scripts/codex-claude-compat.js"),
|
|
80
84
|
Path("plugin/scripts/codex-hook.js"),
|
|
85
|
+
Path("plugin/scripts/backend-log-runner.sh"),
|
|
81
86
|
Path("plugin/scripts/_codex_env.sh"),
|
|
82
87
|
)
|
|
83
88
|
_COPYTREE_IGNORE = shutil.ignore_patterns(
|
|
@@ -125,6 +130,86 @@ def _seed_reflexio_env() -> list[str]:
|
|
|
125
130
|
return missing
|
|
126
131
|
|
|
127
132
|
|
|
133
|
+
def _find_claude_code_plugin_root() -> Path | None:
|
|
134
|
+
"""Locate the installed Claude Code plugin root after native install."""
|
|
135
|
+
cache_root = (
|
|
136
|
+
Path.home()
|
|
137
|
+
/ ".claude"
|
|
138
|
+
/ "plugins"
|
|
139
|
+
/ "cache"
|
|
140
|
+
/ _CODEX_MARKETPLACE_NAME
|
|
141
|
+
/ "claude-smart"
|
|
142
|
+
)
|
|
143
|
+
candidates: list[Path] = []
|
|
144
|
+
if cache_root.is_dir():
|
|
145
|
+
for child in cache_root.iterdir():
|
|
146
|
+
if (
|
|
147
|
+
child.is_dir()
|
|
148
|
+
and (child / "pyproject.toml").is_file()
|
|
149
|
+
and (child / "scripts" / "smart-install.sh").is_file()
|
|
150
|
+
):
|
|
151
|
+
candidates.append(child)
|
|
152
|
+
candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True)
|
|
153
|
+
candidates.extend(
|
|
154
|
+
[
|
|
155
|
+
Path.home()
|
|
156
|
+
/ ".claude"
|
|
157
|
+
/ "plugins"
|
|
158
|
+
/ "marketplaces"
|
|
159
|
+
/ _CODEX_MARKETPLACE_NAME
|
|
160
|
+
/ "plugin",
|
|
161
|
+
_PLUGIN_ROOT,
|
|
162
|
+
]
|
|
163
|
+
)
|
|
164
|
+
for candidate in candidates:
|
|
165
|
+
if (
|
|
166
|
+
(candidate / "pyproject.toml").is_file()
|
|
167
|
+
and (candidate / "scripts" / "smart-install.sh").is_file()
|
|
168
|
+
):
|
|
169
|
+
return candidate
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _force_plugin_root(plugin_root: Path) -> None:
|
|
174
|
+
"""Point ~/.reflexio/plugin-root at the installed plugin root."""
|
|
175
|
+
_REFLEXIO_DIR.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
link = _REFLEXIO_DIR / "plugin-root"
|
|
177
|
+
if link.is_symlink() or link.is_file():
|
|
178
|
+
link.unlink()
|
|
179
|
+
elif link.exists():
|
|
180
|
+
raise OSError(f"refusing to replace non-symlink plugin-root at {link}")
|
|
181
|
+
try:
|
|
182
|
+
link.symlink_to(plugin_root, target_is_directory=True)
|
|
183
|
+
except OSError:
|
|
184
|
+
if link.exists():
|
|
185
|
+
raise
|
|
186
|
+
(_REFLEXIO_DIR / "plugin-root.txt").write_text(f"{plugin_root}\n")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _bootstrap_claude_code_install() -> tuple[bool, str]:
|
|
190
|
+
"""Run smart-install immediately for the installed Claude Code plugin."""
|
|
191
|
+
plugin_root = _find_claude_code_plugin_root()
|
|
192
|
+
if plugin_root is None:
|
|
193
|
+
return False, "could not locate installed Claude Code plugin root after install"
|
|
194
|
+
try:
|
|
195
|
+
_force_plugin_root(plugin_root)
|
|
196
|
+
except OSError as exc:
|
|
197
|
+
return False, str(exc)
|
|
198
|
+
bash = shutil.which("bash")
|
|
199
|
+
if not bash:
|
|
200
|
+
return False, "bash is required to bootstrap claude-smart dependencies"
|
|
201
|
+
result = subprocess.run(
|
|
202
|
+
[bash, str(plugin_root / "scripts" / "smart-install.sh")],
|
|
203
|
+
cwd=plugin_root,
|
|
204
|
+
)
|
|
205
|
+
if result.returncode != 0:
|
|
206
|
+
return False, f"smart-install.sh failed in {plugin_root}"
|
|
207
|
+
if _INSTALL_FAILURE_MARKER.is_file():
|
|
208
|
+
reason = _INSTALL_FAILURE_MARKER.read_text().strip() or "unknown error"
|
|
209
|
+
return False, reason
|
|
210
|
+
return True, str(plugin_root)
|
|
211
|
+
|
|
212
|
+
|
|
128
213
|
def _missing_codex_marketplace_files(root: Path) -> list[Path]:
|
|
129
214
|
return [entry for entry in _CODEX_REQUIRED_FILES if not (root / entry).is_file()]
|
|
130
215
|
|
|
@@ -789,7 +874,21 @@ def cmd_install(args: argparse.Namespace) -> int:
|
|
|
789
874
|
if added:
|
|
790
875
|
sys.stdout.write(f"Seeded {_REFLEXIO_ENV_PATH} with {', '.join(added)}.\n")
|
|
791
876
|
|
|
792
|
-
|
|
877
|
+
bootstrapped, message = _bootstrap_claude_code_install()
|
|
878
|
+
if not bootstrapped:
|
|
879
|
+
sys.stderr.write(
|
|
880
|
+
f"error: claude-smart installed, but dependency bootstrap failed: {message}\n"
|
|
881
|
+
)
|
|
882
|
+
sys.stderr.write(
|
|
883
|
+
"Fix the issue above, then run /claude-smart:restart or restart Claude Code to retry.\n"
|
|
884
|
+
)
|
|
885
|
+
return 1
|
|
886
|
+
sys.stdout.write(f"Prepared claude-smart runtime at {message}.\n")
|
|
887
|
+
|
|
888
|
+
sys.stdout.write(
|
|
889
|
+
"\nclaude-smart installed and dependencies are prepared. "
|
|
890
|
+
"Restart Claude Code in your project.\n"
|
|
891
|
+
)
|
|
793
892
|
return 0
|
|
794
893
|
|
|
795
894
|
|
|
@@ -6,8 +6,7 @@ search, (b) render the hits with ``context_format.render_inline_with_registry``,
|
|
|
6
6
|
(d) emit a Claude Code ``hookSpecificOutput.additionalContext`` envelope
|
|
7
7
|
on stdout. This module owns that shared pipeline so the two hook
|
|
8
8
|
handlers keep exactly one source of truth for the injection contract —
|
|
9
|
-
the envelope shape, the registry schema, and the
|
|
10
|
-
``ensure_installed`` / ``append_injected``.
|
|
9
|
+
the envelope shape, the registry schema, and the injected context append.
|
|
11
10
|
|
|
12
11
|
The caller remains responsible for handler-specific framing (PreToolUse
|
|
13
12
|
needs ``hook.emit_continue()`` on the empty path; UserPromptSubmit wraps
|
|
@@ -21,7 +20,7 @@ import json
|
|
|
21
20
|
import sys
|
|
22
21
|
import time
|
|
23
22
|
|
|
24
|
-
from claude_smart import context_format,
|
|
23
|
+
from claude_smart import context_format, state
|
|
25
24
|
from claude_smart.reflexio_adapter import Adapter
|
|
26
25
|
|
|
27
26
|
|
|
@@ -69,7 +68,6 @@ def emit_context(
|
|
|
69
68
|
if not markdown:
|
|
70
69
|
return False
|
|
71
70
|
|
|
72
|
-
cs_cite.ensure_installed()
|
|
73
71
|
state.append_injected(
|
|
74
72
|
session_id,
|
|
75
73
|
(dict(entry, ts=int(time.time())) for entry in registry),
|
|
@@ -11,8 +11,7 @@ impactful replies with a marker like::
|
|
|
11
11
|
|
|
12
12
|
The Stop hook later scans the assistant text for those markers and resolves
|
|
13
13
|
the ids against a per-session registry persisted at
|
|
14
|
-
``~/.claude-smart/sessions/<session_id>.injected.jsonl``.
|
|
15
|
-
Bash tool calls are still accepted as a fallback for older instructions.
|
|
14
|
+
``~/.claude-smart/sessions/<session_id>.injected.jsonl``.
|
|
16
15
|
|
|
17
16
|
Why rank + fingerprint: rank alone resets at every injection, so a
|
|
18
17
|
later injection's ``s1`` would silently overwrite an earlier entry in
|
|
@@ -27,48 +26,19 @@ This module holds:
|
|
|
27
26
|
- ``rank_id``: ``p{n}-{fp}`` / ``s{n}-{fp}`` tag for a given
|
|
28
27
|
(kind, rank, real_id) tuple. Fingerprint is omitted when no real id
|
|
29
28
|
is available. ``p`` is preference, ``s`` is skill.
|
|
30
|
-
- ``CITATION_CMD_RE``: regex matching a valid legacy ``cs-cite`` command line.
|
|
31
|
-
- ``ensure_installed``: idempotent copy of ``plugin/bin/cs-cite`` to
|
|
32
|
-
``~/.claude-smart/bin/cs-cite`` with the executable bit set.
|
|
33
29
|
- ``CITATION_INSTRUCTION``: the trailer text appended to injected context
|
|
34
30
|
so the assistant knows when and how to emit the citation marker.
|
|
35
31
|
"""
|
|
36
32
|
|
|
37
33
|
from __future__ import annotations
|
|
38
34
|
|
|
39
|
-
import logging
|
|
40
35
|
import re
|
|
41
|
-
import shutil
|
|
42
|
-
import stat as stat_
|
|
43
|
-
from pathlib import Path
|
|
44
36
|
from typing import Any
|
|
45
37
|
|
|
46
|
-
_LOGGER = logging.getLogger(__name__)
|
|
47
|
-
|
|
48
|
-
_THIS_DIR = Path(__file__).resolve().parent
|
|
49
|
-
_PLUGIN_ROOT = _THIS_DIR.parents[1] # plugin/src/claude_smart/ -> plugin/
|
|
50
|
-
_SOURCE_SCRIPT = _PLUGIN_ROOT / "bin" / "cs-cite"
|
|
51
|
-
_INSTALL_DIR = Path.home() / ".claude-smart" / "bin"
|
|
52
|
-
INSTALL_PATH = _INSTALL_DIR / "cs-cite"
|
|
53
|
-
|
|
54
38
|
_FINGERPRINT_LEN = 4
|
|
55
39
|
|
|
56
|
-
# Match a bare `cs-cite <ids>` invocation. Ids are rank tokens of the
|
|
57
|
-
# form `p<N>` (preference) or `s<N>` (skill) with an optional
|
|
58
|
-
# `-<fp>` fingerprint (1-4 alphanumeric chars), optionally
|
|
59
|
-
# `cs:`-prefixed (since bullets render as `[cs:p1-ab12]` and the model
|
|
60
|
-
# often copies the tag verbatim). The `(?i:...)` inline flags make the
|
|
61
|
-
# prefix, kind letter, and fingerprint case-insensitive so `CS:P1-AB12`
|
|
62
|
-
# is accepted — matching the `re.IGNORECASE` used by the standalone
|
|
63
|
-
# `cs-cite` script. Tokens may be comma- and/or whitespace-separated.
|
|
64
|
-
# Chained commands (&&, |, ;) and extra trailing tokens remain rejected
|
|
65
|
-
# by the anchored `\s*$` terminator so accidental mentions don't
|
|
66
|
-
# register as citations.
|
|
67
40
|
_ID_TOKEN = r"(?i:cs:)?(?i:[ps])\d+(?:-(?i:[a-z0-9]){1,4})?"
|
|
68
41
|
_ID_SEP = r"[,\s]+"
|
|
69
|
-
CITATION_CMD_RE = re.compile(
|
|
70
|
-
rf"^\s*(?:[^\s]*/)?cs-cite\s+({_ID_TOKEN}(?:{_ID_SEP}{_ID_TOKEN})*)\s*$"
|
|
71
|
-
)
|
|
72
42
|
_CLEAN_ID_RE = re.compile(r"^(?i:cs:)?((?i:[ps])\d+(?:-(?i:[a-z0-9]){1,4})?)$")
|
|
73
43
|
_SPLIT_RE = re.compile(_ID_SEP)
|
|
74
44
|
_TEXT_CITATION_LINE_RE = re.compile(
|
|
@@ -82,7 +52,7 @@ CITATION_INSTRUCTION = (
|
|
|
82
52
|
"only if — an injected `[cs:…]` item materially changed your reply "
|
|
83
53
|
"(different wording, action, or conclusion than you would have produced "
|
|
84
54
|
"without it), append exactly one final citation line after your answer. "
|
|
85
|
-
"Do not call
|
|
55
|
+
"Do not call a shell command or any other tool for citations. Ids come verbatim "
|
|
86
56
|
"from the `[cs:…]` tags — keep the leading `p` (preference) or `s` "
|
|
87
57
|
"(skill) and the `-<fp>` suffix. Use this exact format for one id: "
|
|
88
58
|
"`✨ 1 claude-smart learning applied [cs:s1-ab12]`. Use this exact format "
|
|
@@ -155,29 +125,6 @@ def rank_id(kind: str, rank: int, real_id: Any = None) -> str:
|
|
|
155
125
|
return f"{prefix}{rank}-{fp}" if fp else f"{prefix}{rank}"
|
|
156
126
|
|
|
157
127
|
|
|
158
|
-
def parse_citation_command(command: str) -> list[str]:
|
|
159
|
-
"""Extract citation ids from a ``cs-cite`` Bash command string.
|
|
160
|
-
|
|
161
|
-
Returns an empty list when the command does not match the expected
|
|
162
|
-
shape (chained commands, extra arguments, or anything other than a
|
|
163
|
-
bare ``cs-cite <ids>`` invocation are rejected to avoid false
|
|
164
|
-
positives from accidental mentions).
|
|
165
|
-
|
|
166
|
-
Args:
|
|
167
|
-
command: The raw ``input.command`` value from a Bash tool_use
|
|
168
|
-
block.
|
|
169
|
-
|
|
170
|
-
Returns:
|
|
171
|
-
list[str]: Lowercase rank ids (e.g. ``"p1"``, ``"s3"``), in the
|
|
172
|
-
order Claude cited them. Empty when the command does not
|
|
173
|
-
match.
|
|
174
|
-
"""
|
|
175
|
-
match = CITATION_CMD_RE.match(command or "")
|
|
176
|
-
if not match:
|
|
177
|
-
return []
|
|
178
|
-
return _parse_id_tokens(match.group(1))
|
|
179
|
-
|
|
180
|
-
|
|
181
128
|
def parse_text_citations(text: str) -> list[str]:
|
|
182
129
|
"""Extract Codex text-only citation ids from a final learning marker line.
|
|
183
130
|
|
|
@@ -199,38 +146,3 @@ def _parse_id_tokens(raw_ids: str) -> list[str]:
|
|
|
199
146
|
if clean := _CLEAN_ID_RE.match(tok):
|
|
200
147
|
ids.append(clean.group(1).lower())
|
|
201
148
|
return ids
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def ensure_installed() -> Path:
|
|
205
|
-
"""Idempotently install ``cs-cite`` into ``~/.claude-smart/bin/``.
|
|
206
|
-
|
|
207
|
-
Called from every PreToolUse / UserPromptSubmit inject, so we
|
|
208
|
-
short-circuit when the target file already exists with
|
|
209
|
-
the executable bit set — the steady-state path is one ``stat`` syscall
|
|
210
|
-
instead of mkdir + copy + stat + chmod. Keying on filesystem state
|
|
211
|
-
(rather than a module-level boolean) keeps test isolation working when
|
|
212
|
-
tests monkeypatch ``INSTALL_PATH`` to a fresh tmpdir.
|
|
213
|
-
|
|
214
|
-
Never raises — filesystem errors are logged at DEBUG and the caller
|
|
215
|
-
proceeds with injection regardless (the citation feature degrades to
|
|
216
|
-
silent if the script is unreachable).
|
|
217
|
-
|
|
218
|
-
Returns:
|
|
219
|
-
Path: Target path, whether or not install succeeded.
|
|
220
|
-
"""
|
|
221
|
-
try:
|
|
222
|
-
if (
|
|
223
|
-
INSTALL_PATH.is_file()
|
|
224
|
-
and INSTALL_PATH.stat().st_mode & stat_.S_IXUSR
|
|
225
|
-
and _SOURCE_SCRIPT.is_file()
|
|
226
|
-
and INSTALL_PATH.read_bytes() == _SOURCE_SCRIPT.read_bytes()
|
|
227
|
-
):
|
|
228
|
-
return INSTALL_PATH
|
|
229
|
-
_INSTALL_DIR.mkdir(parents=True, exist_ok=True)
|
|
230
|
-
if _SOURCE_SCRIPT.is_file():
|
|
231
|
-
shutil.copy2(_SOURCE_SCRIPT, INSTALL_PATH)
|
|
232
|
-
mode = INSTALL_PATH.stat().st_mode
|
|
233
|
-
INSTALL_PATH.chmod(mode | stat_.S_IXUSR | stat_.S_IXGRP | stat_.S_IXOTH)
|
|
234
|
-
except OSError as exc:
|
|
235
|
-
_LOGGER.debug("cs-cite install failed: %s", exc)
|
|
236
|
-
return INSTALL_PATH
|
|
@@ -34,7 +34,7 @@ _EXIT_PLAN_MODE_TOOL = "ExitPlanMode"
|
|
|
34
34
|
def _read_transcript_entries(path: Path) -> list[dict[str, Any]]:
|
|
35
35
|
"""Parse the transcript JSONL once into a list of entries.
|
|
36
36
|
|
|
37
|
-
Stop's
|
|
37
|
+
Stop's scanners (assistant text and plan decisions) both
|
|
38
38
|
need the same parsed view; reading once and passing the list around keeps
|
|
39
39
|
the hook's wall-clock cost to a single ``read_text`` per fire even on
|
|
40
40
|
multi-megabyte transcripts.
|
|
@@ -267,43 +267,10 @@ def _parse_plan_decision(text: str) -> str | None:
|
|
|
267
267
|
return None
|
|
268
268
|
|
|
269
269
|
|
|
270
|
-
def
|
|
271
|
-
"""
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
the current turn. Multiple calls are merged; order follows Claude's
|
|
275
|
-
emission order (earliest first).
|
|
276
|
-
|
|
277
|
-
Args:
|
|
278
|
-
entries (list[dict[str, Any]]): Pre-parsed transcript entries.
|
|
279
|
-
|
|
280
|
-
Returns:
|
|
281
|
-
list[str]: Rank ids (e.g. ``"s1-ab12"``, ``"p2-cd34"``) in
|
|
282
|
-
emission order. Empty when no ``cs-cite`` call is found.
|
|
283
|
-
"""
|
|
284
|
-
out: list[str] = []
|
|
285
|
-
for entry in _current_turn_assistant_entries(entries):
|
|
286
|
-
message = entry.get("message") or {}
|
|
287
|
-
out.extend(_extract_cs_cite_ids(message.get("content")))
|
|
288
|
-
return out
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
def _extract_cs_cite_ids(content: Any) -> list[str]:
|
|
292
|
-
"""Return citation ids from all Bash ``cs-cite`` tool_use blocks in ``content``."""
|
|
293
|
-
if not isinstance(content, list):
|
|
294
|
-
return []
|
|
295
|
-
out: list[str] = []
|
|
296
|
-
for block in content:
|
|
297
|
-
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
298
|
-
continue
|
|
299
|
-
if block.get("name") != "Bash":
|
|
300
|
-
continue
|
|
301
|
-
tool_input = block.get("input") or {}
|
|
302
|
-
command = tool_input.get("command")
|
|
303
|
-
if not isinstance(command, str):
|
|
304
|
-
continue
|
|
305
|
-
out.extend(cs_cite.parse_citation_command(command))
|
|
306
|
-
return out
|
|
270
|
+
def _has_unpublished_user_turn(session_id: str) -> bool:
|
|
271
|
+
"""True when the session buffer already has a user turn awaiting publish."""
|
|
272
|
+
_, interactions = state.unpublished_slice(state.read_all(session_id))
|
|
273
|
+
return any(item.get("role") == "User" for item in interactions)
|
|
307
274
|
|
|
308
275
|
|
|
309
276
|
def _resolve_cited_items(session_id: str, cited_ids: list[str]) -> list[dict[str, Any]]:
|
|
@@ -360,8 +327,10 @@ def handle(payload: dict[str, Any]) -> None:
|
|
|
360
327
|
if path.is_file():
|
|
361
328
|
entries = _load_transcript_with_retry(path)
|
|
362
329
|
|
|
330
|
+
prompt = payload.get("prompt") or (
|
|
331
|
+
_scan_transcript_for_user_text(entries) if runtime.is_codex() else ""
|
|
332
|
+
)
|
|
363
333
|
if runtime.is_codex():
|
|
364
|
-
prompt = payload.get("prompt") or _scan_transcript_for_user_text(entries)
|
|
365
334
|
if internal_call.is_codex_internal_prompt(prompt):
|
|
366
335
|
return
|
|
367
336
|
|
|
@@ -373,10 +342,15 @@ def handle(payload: dict[str, Any]) -> None:
|
|
|
373
342
|
and last_assistant_message
|
|
374
343
|
else _scan_transcript_for_assistant_text(entries)
|
|
375
344
|
)
|
|
376
|
-
|
|
345
|
+
if (
|
|
346
|
+
runtime.is_codex()
|
|
347
|
+
and internal_call.is_codex_title_response(assistant_text)
|
|
348
|
+
and not prompt
|
|
349
|
+
and not _has_unpublished_user_turn(session_id)
|
|
350
|
+
):
|
|
351
|
+
return
|
|
377
352
|
text_cited_ids = cs_cite.parse_text_citations(assistant_text)
|
|
378
|
-
|
|
379
|
-
cited_items = _resolve_cited_items(session_id, cited_ids)
|
|
353
|
+
cited_items = _resolve_cited_items(session_id, text_cited_ids)
|
|
380
354
|
plan_decisions = _scan_transcript_for_plan_decisions(entries)
|
|
381
355
|
|
|
382
356
|
now = int(time.time())
|
|
@@ -36,6 +36,7 @@ Detection signals, OR'd:
|
|
|
36
36
|
|
|
37
37
|
from __future__ import annotations
|
|
38
38
|
|
|
39
|
+
import json
|
|
39
40
|
import os
|
|
40
41
|
from pathlib import Path
|
|
41
42
|
from typing import Any
|
|
@@ -117,3 +118,25 @@ def is_codex_internal_prompt(prompt: Any) -> bool:
|
|
|
117
118
|
and _CODEX_SUGGESTIONS_PROMPT_MARKER in text
|
|
118
119
|
and _CODEX_SUGGESTIONS_APPS_MARKER in text
|
|
119
120
|
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def is_codex_title_response(content: Any) -> bool:
|
|
124
|
+
"""True for Codex's title-generator response body.
|
|
125
|
+
|
|
126
|
+
Codex can run a separate title-generation task whose Stop payload contains
|
|
127
|
+
only the assistant response, e.g. ``{"title":"Fix tests"}``, with no
|
|
128
|
+
corresponding user turn. That metadata is useful to Codex's UI, but it is
|
|
129
|
+
not a user interaction and should not be published to reflexio.
|
|
130
|
+
"""
|
|
131
|
+
if not isinstance(content, str):
|
|
132
|
+
return False
|
|
133
|
+
try:
|
|
134
|
+
parsed = json.loads(content)
|
|
135
|
+
except json.JSONDecodeError:
|
|
136
|
+
return False
|
|
137
|
+
return (
|
|
138
|
+
isinstance(parsed, dict)
|
|
139
|
+
and set(parsed) == {"title"}
|
|
140
|
+
and isinstance(parsed.get("title"), str)
|
|
141
|
+
and bool(parsed["title"].strip())
|
|
142
|
+
)
|
|
@@ -77,7 +77,7 @@ def session_path(session_id: str) -> Path:
|
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
def injected_path(session_id: str) -> Path:
|
|
80
|
-
"""Return the JSONL path for the per-session
|
|
80
|
+
"""Return the JSONL path for the per-session citation registry."""
|
|
81
81
|
return state_dir() / f"{session_id}.injected.jsonl"
|
|
82
82
|
|
|
83
83
|
|
|
@@ -85,8 +85,8 @@ def append_injected(session_id: str, entries: Iterable[dict[str, Any]]) -> None:
|
|
|
85
85
|
"""Append citation-registry entries to the per-session injected-items file.
|
|
86
86
|
|
|
87
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
|
|
89
|
-
|
|
88
|
+
preference it came from so the Stop hook can resolve citation ids into
|
|
89
|
+
human-readable titles for the dashboard.
|
|
90
90
|
Silently no-ops when ``entries`` is empty.
|
|
91
91
|
"""
|
|
92
92
|
records = list(entries)
|