anolisa-tokenless 0.7.7
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/LICENSE +190 -0
- package/README.md +770 -0
- package/adapters/tokenless/claude-code/.claude-plugin/marketplace.json +15 -0
- package/adapters/tokenless/claude-code/.claude-plugin/plugin.json +9 -0
- package/adapters/tokenless/claude-code/hooks/hooks.json +38 -0
- package/adapters/tokenless/claude-code/scripts/detect.sh +193 -0
- package/adapters/tokenless/claude-code/scripts/install.sh +92 -0
- package/adapters/tokenless/claude-code/scripts/uninstall.sh +50 -0
- package/adapters/tokenless/codex/.codex-plugin/plugin.json +20 -0
- package/adapters/tokenless/codex/README.md +160 -0
- package/adapters/tokenless/codex/hooks/hooks.json +52 -0
- package/adapters/tokenless/codex/scripts/_common.sh +25 -0
- package/adapters/tokenless/codex/scripts/check-tokenless +94 -0
- package/adapters/tokenless/codex/scripts/compress-response +441 -0
- package/adapters/tokenless/codex/scripts/detect.sh +61 -0
- package/adapters/tokenless/codex/scripts/install.sh +151 -0
- package/adapters/tokenless/codex/scripts/rewrite-hook +282 -0
- package/adapters/tokenless/codex/scripts/tool-ready +303 -0
- package/adapters/tokenless/codex/scripts/uninstall.sh +78 -0
- package/adapters/tokenless/common/commands/tokenless-stats.toml +2 -0
- package/adapters/tokenless/common/cosh-extension.json +65 -0
- package/adapters/tokenless/common/hooks/compress_response_hook.py +487 -0
- package/adapters/tokenless/common/hooks/compress_schema_hook.py +144 -0
- package/adapters/tokenless/common/hooks/compress_toon_hook.py +160 -0
- package/adapters/tokenless/common/hooks/hook_utils.py +584 -0
- package/adapters/tokenless/common/hooks/rewrite_hook.py +223 -0
- package/adapters/tokenless/common/hooks/run-hook.sh +62 -0
- package/adapters/tokenless/common/hooks/tool_categories.json +99 -0
- package/adapters/tokenless/common/hooks/tool_ready_hook.sh +571 -0
- package/adapters/tokenless/common/tokenless-env-fix.sh +730 -0
- package/adapters/tokenless/common/tool-ready-spec.json +113 -0
- package/adapters/tokenless/dsh/cordis.patch.yml +8 -0
- package/adapters/tokenless/dsh/dist/index.js +399 -0
- package/adapters/tokenless/dsh/package.json +26 -0
- package/adapters/tokenless/hermes/__init__.py +572 -0
- package/adapters/tokenless/hermes/plugin.yaml +9 -0
- package/adapters/tokenless/hermes/scripts/detect.sh +80 -0
- package/adapters/tokenless/hermes/scripts/install.sh +60 -0
- package/adapters/tokenless/hermes/scripts/uninstall.sh +45 -0
- package/adapters/tokenless/manifest.json +147 -0
- package/adapters/tokenless/openclaw/dist/index.d.ts +27 -0
- package/adapters/tokenless/openclaw/dist/index.js +598 -0
- package/adapters/tokenless/openclaw/dist/tool_categories.json +99 -0
- package/adapters/tokenless/openclaw/index.ts +720 -0
- package/adapters/tokenless/openclaw/openclaw.plugin.json +40 -0
- package/adapters/tokenless/openclaw/package.json +24 -0
- package/adapters/tokenless/openclaw/scripts/detect.sh +102 -0
- package/adapters/tokenless/openclaw/scripts/install.sh +75 -0
- package/adapters/tokenless/openclaw/scripts/uninstall.sh +46 -0
- package/adapters/tokenless/openclaw/tsconfig.json +13 -0
- package/adapters/tokenless/opencode/plugin.js +246 -0
- package/adapters/tokenless/opencode/scripts/detect.sh +38 -0
- package/adapters/tokenless/opencode/scripts/install.sh +56 -0
- package/adapters/tokenless/opencode/scripts/uninstall.sh +29 -0
- package/adapters/tokenless/qoder/.qoder-plugin/plugin.json +11 -0
- package/adapters/tokenless/qoder/commands/tokenless-stats.md +8 -0
- package/adapters/tokenless/qoder/hooks/hooks.json +36 -0
- package/adapters/tokenless/qoder/hooks/run-hook.sh +51 -0
- package/adapters/tokenless/qoder/scripts/detect.sh +35 -0
- package/adapters/tokenless/qoder/scripts/install.sh +136 -0
- package/adapters/tokenless/qoder/scripts/uninstall.sh +106 -0
- package/adapters/tokenless/qwencode/qwen-extension.json +69 -0
- package/adapters/tokenless/qwencode/scripts/detect.sh +125 -0
- package/adapters/tokenless/qwencode/scripts/install.sh +128 -0
- package/adapters/tokenless/qwencode/scripts/uninstall.sh +62 -0
- package/bin/rtk +6 -0
- package/bin/tokenless +6 -0
- package/bin/toon +6 -0
- package/package.json +57 -0
- package/scripts/postinstall.js +208 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionStart hook: check tokenless availability and report version.
|
|
3
|
+
|
|
4
|
+
This hook runs once per session to verify the tokenless CLI is installed
|
|
5
|
+
and functional. On failure it emits a silent warning via stderr but never
|
|
6
|
+
blocks the session (fail-open).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Optional, Tuple
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import pwd as _pwd
|
|
18
|
+
|
|
19
|
+
_REAL_HOME = _pwd.getpwuid(os.getuid()).pw_dir
|
|
20
|
+
except (ImportError, KeyError):
|
|
21
|
+
_REAL_HOME = ""
|
|
22
|
+
if not os.path.isabs(_REAL_HOME):
|
|
23
|
+
_REAL_HOME = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _warn(msg: str) -> None:
|
|
27
|
+
print(f"[tokenless] {msg}", file=sys.stderr)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _known_tokenless_paths(home: Optional[str] = None) -> Tuple[str, ...]:
|
|
31
|
+
"""Return standalone fallbacks for the supported installation layouts."""
|
|
32
|
+
# Codex can cache this script without common/hooks. Keep this standalone
|
|
33
|
+
# copy aligned with hook_utils.py::_known_binary_paths.
|
|
34
|
+
user_home = _REAL_HOME if home is None else home
|
|
35
|
+
user_home = user_home if user_home and os.path.isabs(user_home) else ""
|
|
36
|
+
paths = []
|
|
37
|
+
if user_home:
|
|
38
|
+
paths.append(os.path.join(user_home, ".local", "bin", "tokenless"))
|
|
39
|
+
paths.extend(("/usr/local/bin/tokenless", "/usr/bin/tokenless"))
|
|
40
|
+
if user_home:
|
|
41
|
+
paths.extend(
|
|
42
|
+
(
|
|
43
|
+
os.path.join(
|
|
44
|
+
user_home,
|
|
45
|
+
".local",
|
|
46
|
+
"share",
|
|
47
|
+
"anolisa",
|
|
48
|
+
"tokenless",
|
|
49
|
+
"tokenless",
|
|
50
|
+
),
|
|
51
|
+
os.path.join(
|
|
52
|
+
user_home,
|
|
53
|
+
".local",
|
|
54
|
+
"lib",
|
|
55
|
+
"anolisa",
|
|
56
|
+
"tokenless",
|
|
57
|
+
"tokenless",
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return tuple(paths)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main() -> None:
|
|
65
|
+
# Find tokenless
|
|
66
|
+
tokenless_bin = shutil.which("tokenless")
|
|
67
|
+
if not tokenless_bin:
|
|
68
|
+
for fp in _known_tokenless_paths():
|
|
69
|
+
if os.path.isfile(fp) and os.access(fp, os.X_OK):
|
|
70
|
+
tokenless_bin = fp
|
|
71
|
+
break
|
|
72
|
+
|
|
73
|
+
if not tokenless_bin:
|
|
74
|
+
_warn("tokenless not found. Compression disabled for this session.")
|
|
75
|
+
sys.exit(0)
|
|
76
|
+
|
|
77
|
+
# Version check
|
|
78
|
+
try:
|
|
79
|
+
proc = subprocess.run(
|
|
80
|
+
[tokenless_bin, "--version"],
|
|
81
|
+
capture_output=True, text=True, timeout=5,
|
|
82
|
+
)
|
|
83
|
+
if proc.returncode == 0:
|
|
84
|
+
_warn(f"tokenless ready: {proc.stdout.strip()}")
|
|
85
|
+
else:
|
|
86
|
+
_warn(f"tokenless --version failed (exit {proc.returncode})")
|
|
87
|
+
except Exception as e:
|
|
88
|
+
_warn(f"tokenless version check failed: {e}")
|
|
89
|
+
|
|
90
|
+
sys.exit(0)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
main()
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Codex PostToolUse compression hook for the Tokenless plugin.
|
|
3
|
+
|
|
4
|
+
Reads a PostToolUse hook request from stdin, compresses the tool response
|
|
5
|
+
via ``tokenless compress-response`` and optional TOON encoding, then
|
|
6
|
+
injects a compressed summary and environment error classification as
|
|
7
|
+
``additionalContext``.
|
|
8
|
+
|
|
9
|
+
Protocol:
|
|
10
|
+
stdin ← codex PostToolUse JSON (tool_name, tool_response, ...)
|
|
11
|
+
stdout → {"hookSpecificOutput": {"hookEventName": "PostToolUse",
|
|
12
|
+
"additionalContext": "..."}}
|
|
13
|
+
|
|
14
|
+
Design notes for Codex:
|
|
15
|
+
- PostToolUse CANNOT suppress the original tool output (``suppressOutput``
|
|
16
|
+
is rejected by the codex hook engine for this event).
|
|
17
|
+
- ``additionalContext`` is additive — the model sees both the original
|
|
18
|
+
output AND the injected context.
|
|
19
|
+
- Therefore we inject a compressed *summary* rather than duplicating the
|
|
20
|
+
full content, plus environment error classification with fix hints.
|
|
21
|
+
- For very large JSON responses (> 4 KB), we include the full compressed
|
|
22
|
+
content so the model has a compact alternative to the raw output.
|
|
23
|
+
|
|
24
|
+
Fail-open: every error path silently passes through (exit 0 with empty
|
|
25
|
+
stdout) so the agent session is never blocked by a compression failure.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import shutil
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
from typing import Optional
|
|
34
|
+
|
|
35
|
+
# Resolve shared hook utilities (common/hooks/) with FHS fallback paths.
|
|
36
|
+
# Primary: relative path (works for source-tree and FHS/RPM install).
|
|
37
|
+
# Fallbacks: system and user FHS paths (works when Codex caches only the
|
|
38
|
+
# plugin subdirectory and the relative path resolves outside the cache).
|
|
39
|
+
#
|
|
40
|
+
# Trust model (aligned with bash is_trusted_file / Rust is_trusted_path):
|
|
41
|
+
# - System FHS paths (/usr/share, /usr/local/share) are unconditional.
|
|
42
|
+
# - User-relative paths use passwd DB home (NOT $HOME — env-controllable),
|
|
43
|
+
# with ownership and parent-dir permission checks.
|
|
44
|
+
# - Relative ../../common/hooks is trusted only if the resolved target
|
|
45
|
+
# exists and is under a system prefix or a passwd-validated home path.
|
|
46
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_trusted_dir(path: str) -> bool:
|
|
50
|
+
"""Check whether a directory is trusted for Python module imports.
|
|
51
|
+
|
|
52
|
+
Mirrors the trust criteria in bash tool_ready_hook.sh (is_trusted_file)
|
|
53
|
+
and Rust env_check.rs (is_trusted_path): system paths unconditional,
|
|
54
|
+
user paths validated via passwd + ownership + parent permission checks.
|
|
55
|
+
|
|
56
|
+
NOTE: Callers should pass realpath-resolved paths (not just abspath)
|
|
57
|
+
so that symlink targets are validated before the trust check — a
|
|
58
|
+
symlinked /usr/share/anolisa → /tmp/ would bypass the prefix check
|
|
59
|
+
if only abspath is used.
|
|
60
|
+
"""
|
|
61
|
+
# System FHS prefixes are always trusted
|
|
62
|
+
for prefix in ("/usr/share/", "/usr/local/share/", "/usr/libexec/", "/usr/lib/anolisa/"):
|
|
63
|
+
if path.startswith(prefix):
|
|
64
|
+
return True
|
|
65
|
+
# For paths outside system prefixes (e.g. source-tree cloned to
|
|
66
|
+
# /opt/anolisa/), trust them if owned by current uid or root AND
|
|
67
|
+
# parent directory is not world-writable. This covers developer
|
|
68
|
+
# worktrees without requiring the path to be under passwd home.
|
|
69
|
+
try:
|
|
70
|
+
st = os.stat(path)
|
|
71
|
+
except OSError:
|
|
72
|
+
return False
|
|
73
|
+
if st.st_uid != os.getuid() and st.st_uid != 0:
|
|
74
|
+
# Not owned by us or root — refuse. Home paths must be owned by
|
|
75
|
+
# us or root; source-tree paths outside system prefixes are
|
|
76
|
+
# already covered by the ownership check above (current uid/root).
|
|
77
|
+
return False
|
|
78
|
+
# Ownership OK — check parent directory is not world-writable
|
|
79
|
+
parent = os.path.dirname(path)
|
|
80
|
+
try:
|
|
81
|
+
pst = os.stat(parent)
|
|
82
|
+
except OSError:
|
|
83
|
+
return False
|
|
84
|
+
if pst.st_uid != os.getuid() and pst.st_uid != 0:
|
|
85
|
+
return False
|
|
86
|
+
if pst.st_mode & 0o002: # world-writable
|
|
87
|
+
return False
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Resolve real home from passwd DB for user-install fallback path.
|
|
92
|
+
# Falls back to "" (empty) when unavailable — candidate is then skipped.
|
|
93
|
+
try:
|
|
94
|
+
import pwd as _pwd
|
|
95
|
+
_REAL_HOME = _pwd.getpwuid(os.getuid()).pw_dir
|
|
96
|
+
except (ImportError, KeyError):
|
|
97
|
+
_REAL_HOME = ""
|
|
98
|
+
if not os.path.isabs(_REAL_HOME):
|
|
99
|
+
_REAL_HOME = ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _user_path(*parts: str) -> str:
|
|
103
|
+
return os.path.join(_REAL_HOME, *parts) if _REAL_HOME else ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_HOOK_UTILS_CANDIDATES = [
|
|
107
|
+
os.path.join(_HERE, "..", "..", "common", "hooks"), # source-tree / FHS
|
|
108
|
+
"/usr/share/anolisa/adapters/tokenless/common/hooks", # RPM system
|
|
109
|
+
"/usr/local/share/anolisa/adapters/tokenless/common/hooks", # manual system
|
|
110
|
+
os.path.join(_REAL_HOME, ".local", "share",
|
|
111
|
+
"anolisa", "adapters", "tokenless", "common", "hooks") if _REAL_HOME else "",
|
|
112
|
+
]
|
|
113
|
+
_HOOK_UTILS_RESOLVED = ""
|
|
114
|
+
for _C in _HOOK_UTILS_CANDIDATES:
|
|
115
|
+
if _C and _is_trusted_dir(os.path.realpath(_C)) and os.path.isdir(_C):
|
|
116
|
+
_HOOK_UTILS_RESOLVED = os.path.realpath(_C)
|
|
117
|
+
sys.path.insert(0, _C)
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
if _HOOK_UTILS_RESOLVED:
|
|
121
|
+
from hook_utils import ( # noqa: E402
|
|
122
|
+
skip_silent as _skip,
|
|
123
|
+
try_parse_json as _try_parse_json,
|
|
124
|
+
warn as _warn,
|
|
125
|
+
classify_env_error as _classify_env_error,
|
|
126
|
+
get_thresholds as _get_thresholds,
|
|
127
|
+
SKIP_TOOLS as _SKIP_TOOLS_SHARED,
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
# Fail-open: no trusted hook_utils found → provide inline fallbacks
|
|
131
|
+
# so the hook script exits 0 (passthrough) rather than ImportError → exit 1.
|
|
132
|
+
def _skip() -> None: # type: ignore[assignment,misc]
|
|
133
|
+
sys.exit(0)
|
|
134
|
+
|
|
135
|
+
def _try_parse_json(data: str) -> object | None: # type: ignore[assignment,misc]
|
|
136
|
+
try:
|
|
137
|
+
return json.loads(data)
|
|
138
|
+
except (json.JSONDecodeError, ValueError):
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def _warn(msg: str) -> None: # type: ignore[assignment,misc]
|
|
142
|
+
print(f"[tokenless] WARNING: {msg}", file=sys.stderr)
|
|
143
|
+
|
|
144
|
+
def _classify_env_error(tool_response) -> tuple[str | None, str | None]: # type: ignore[assignment,misc]
|
|
145
|
+
# Classification unavailable without hook_utils — return no-op.
|
|
146
|
+
# Caller handles (None, None) by skipping env error injection.
|
|
147
|
+
return None, None
|
|
148
|
+
|
|
149
|
+
def _get_thresholds(tool_name: str) -> tuple[int, int, int]: # type: ignore[assignment,misc]
|
|
150
|
+
# Hardcoded defaults mirroring hook_utils.get_thresholds().
|
|
151
|
+
# Keep in sync with common/hooks/hook_utils.py thresholds.
|
|
152
|
+
return (65_536, 128, 8) if tool_name in {"Bash", "bash", "Shell", "shell", "exec", "terminal"} else (1_048_576, 65_536, 32)
|
|
153
|
+
|
|
154
|
+
_SKIP_TOOLS_SHARED: set[str] = { # type: ignore[assignment,misc]
|
|
155
|
+
"Read", "read", "read_file", "read_many_files",
|
|
156
|
+
"Glob", "glob", "list_directory",
|
|
157
|
+
"Grep", "grep", "grep_search", "search_files",
|
|
158
|
+
"Lsp", "lsp",
|
|
159
|
+
"NotebookRead", "notebook_read", "notebookread",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# constants
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
AGENT_ID = os.environ.get("TOKENLESS_AGENT_ID", "codex")
|
|
167
|
+
MIN_RESPONSE_CHARS: int = 500
|
|
168
|
+
LARGE_RESPONSE_CHARS: int = 4000
|
|
169
|
+
|
|
170
|
+
# 3-layer compression strategy:
|
|
171
|
+
# Layer 1: Content retrieval + task management → skip all compression
|
|
172
|
+
# Layer 2: Shell/exec → moderate truncation (64K/128/8)
|
|
173
|
+
# Layer 3: API/structured → zero-truncation (1M/64K/32)
|
|
174
|
+
|
|
175
|
+
# Layer 1: content retrieval (shared) + codex-specific task management
|
|
176
|
+
SKIP_TOOLS: set[str] = _SKIP_TOOLS_SHARED | {
|
|
177
|
+
"TodoWrite", "Task", "TaskStatus",
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# Cap for compressed content included in additionalContext (large responses only).
|
|
181
|
+
# Layer 2 thresholds (64K strings) preserve 95% of real shell output; Layer 3
|
|
182
|
+
# zero-truncation (1M strings) can still produce large payloads for API tools.
|
|
183
|
+
COMPRESSED_CONTENT_CAP: int = 32_768
|
|
184
|
+
|
|
185
|
+
# -- tokenless binary search paths (ANOLISA FHS spec) ------------------------
|
|
186
|
+
# Codex can cache this script without common/hooks. Keep this standalone list
|
|
187
|
+
# aligned with hook_utils.py::_known_binary_paths.
|
|
188
|
+
|
|
189
|
+
_TOKENLESS_FALLBACK = "/usr/bin/tokenless"
|
|
190
|
+
_TOKENLESS_SYSTEM_FALLBACK = "/usr/local/bin/tokenless"
|
|
191
|
+
_TOKENLESS_LOCAL_BIN = _user_path(".local", "bin", "tokenless")
|
|
192
|
+
_TOKENLESS_LOCAL_SHARE = _user_path(
|
|
193
|
+
".local", "share", "anolisa", "tokenless", "tokenless"
|
|
194
|
+
)
|
|
195
|
+
_TOKENLESS_LOCAL_LIB = _user_path(
|
|
196
|
+
".local", "lib", "anolisa", "tokenless", "tokenless"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# helpers
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _find_tokenless() -> Optional[str]:
|
|
205
|
+
"""Locate the tokenless CLI binary."""
|
|
206
|
+
explicit = os.environ.get("TOKENLESS_BIN", "")
|
|
207
|
+
if explicit and os.path.isfile(explicit) and os.access(explicit, os.X_OK):
|
|
208
|
+
return explicit
|
|
209
|
+
|
|
210
|
+
path = shutil.which("tokenless")
|
|
211
|
+
if path:
|
|
212
|
+
return path
|
|
213
|
+
|
|
214
|
+
for fp in (
|
|
215
|
+
_TOKENLESS_LOCAL_BIN,
|
|
216
|
+
_TOKENLESS_SYSTEM_FALLBACK,
|
|
217
|
+
_TOKENLESS_FALLBACK,
|
|
218
|
+
_TOKENLESS_LOCAL_SHARE,
|
|
219
|
+
_TOKENLESS_LOCAL_LIB,
|
|
220
|
+
):
|
|
221
|
+
if os.path.isfile(fp) and os.access(fp, os.X_OK):
|
|
222
|
+
return fp
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _is_json_serializable(obj) -> bool:
|
|
227
|
+
"""Check if a value is a JSON object or array (compressible)."""
|
|
228
|
+
return isinstance(obj, (dict, list))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _extract_text_for_compression(tool_response) -> Optional[str]:
|
|
232
|
+
"""Extract a JSON string from tool_response for compression."""
|
|
233
|
+
if isinstance(tool_response, str):
|
|
234
|
+
return tool_response
|
|
235
|
+
if isinstance(tool_response, (dict, list)):
|
|
236
|
+
return json.dumps(tool_response, separators=(",", ":"), ensure_ascii=False)
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _run_tokenless(tokenless_bin: str, subcommand: str, input_data: str,
|
|
241
|
+
session_id: str = "", tool_use_id: str = "",
|
|
242
|
+
timeout: int = 10,
|
|
243
|
+
extra_args: Optional[list[str]] = None) -> Optional[str]:
|
|
244
|
+
"""Run a tokenless subcommand with input, return stdout or None on failure."""
|
|
245
|
+
cmd = [tokenless_bin, subcommand, "--agent-id", AGENT_ID]
|
|
246
|
+
if extra_args:
|
|
247
|
+
cmd.extend(extra_args)
|
|
248
|
+
if session_id:
|
|
249
|
+
cmd.extend(["--session-id", session_id])
|
|
250
|
+
if tool_use_id:
|
|
251
|
+
cmd.extend(["--tool-use-id", tool_use_id])
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
proc = subprocess.run(
|
|
255
|
+
cmd,
|
|
256
|
+
input=input_data,
|
|
257
|
+
capture_output=True,
|
|
258
|
+
text=True,
|
|
259
|
+
timeout=timeout,
|
|
260
|
+
)
|
|
261
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
262
|
+
return proc.stdout.strip()
|
|
263
|
+
if proc.returncode != 0 and proc.stderr:
|
|
264
|
+
_warn(f"{subcommand}: {proc.stderr.strip()[:200]}")
|
|
265
|
+
except subprocess.TimeoutExpired:
|
|
266
|
+
_warn(f"{subcommand} timed out after {timeout}s")
|
|
267
|
+
except OSError as e:
|
|
268
|
+
_warn(f"{subcommand} OS error: {e}")
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
# main
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def main() -> None:
|
|
278
|
+
# 1. Locate tokenless binary (fail-open: skip if not installed)
|
|
279
|
+
tokenless_bin = _find_tokenless()
|
|
280
|
+
if not tokenless_bin:
|
|
281
|
+
_skip()
|
|
282
|
+
|
|
283
|
+
# 2. Read and parse stdin JSON from codex
|
|
284
|
+
try:
|
|
285
|
+
raw = sys.stdin.read()
|
|
286
|
+
if not raw.strip():
|
|
287
|
+
_skip()
|
|
288
|
+
input_data = json.loads(raw)
|
|
289
|
+
except (json.JSONDecodeError, EOFError, ValueError):
|
|
290
|
+
_skip()
|
|
291
|
+
|
|
292
|
+
# 3. Extract fields
|
|
293
|
+
tool_name = input_data.get("tool_name", "")
|
|
294
|
+
tool_response = input_data.get("tool_response")
|
|
295
|
+
session_id = input_data.get("session_id", "")
|
|
296
|
+
tool_use_id = input_data.get("tool_use_id", "")
|
|
297
|
+
|
|
298
|
+
# 4. Layer 1: Content retrieval + task management → skip entirely
|
|
299
|
+
if tool_name in SKIP_TOOLS:
|
|
300
|
+
_skip()
|
|
301
|
+
|
|
302
|
+
# 5. Skip empty or trivial responses
|
|
303
|
+
if tool_response is None:
|
|
304
|
+
_skip()
|
|
305
|
+
|
|
306
|
+
raw_text = _extract_text_for_compression(tool_response)
|
|
307
|
+
if not raw_text or raw_text in ("{}", "[]", '""'):
|
|
308
|
+
_skip()
|
|
309
|
+
|
|
310
|
+
# 6. Detect environment errors (always, regardless of size)
|
|
311
|
+
env_category, env_hint = _classify_env_error(tool_response)
|
|
312
|
+
|
|
313
|
+
# 7. Skip compression for small responses, but still report env errors
|
|
314
|
+
if len(raw_text) < MIN_RESPONSE_CHARS:
|
|
315
|
+
if env_category:
|
|
316
|
+
response = {
|
|
317
|
+
"hookSpecificOutput": {
|
|
318
|
+
"hookEventName": "PostToolUse",
|
|
319
|
+
"additionalContext": (
|
|
320
|
+
f"[tokenless:env:{env_category}] {env_hint} "
|
|
321
|
+
f"Do NOT retry the same command — fix the environment first."
|
|
322
|
+
),
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
print(json.dumps(response, ensure_ascii=False))
|
|
326
|
+
_skip()
|
|
327
|
+
|
|
328
|
+
# 8. Determine JSON status (needed for compression pipeline)
|
|
329
|
+
parsed = _try_parse_json(raw_text)
|
|
330
|
+
is_json = parsed is not None and _is_json_serializable(parsed)
|
|
331
|
+
|
|
332
|
+
# 9. Compression pipeline: 3-layer dispatch thresholds
|
|
333
|
+
# Layer 1 (content retrieval): already skipped above
|
|
334
|
+
# Layer 2 (shell/exec): moderate truncation (64K/128/8)
|
|
335
|
+
# Layer 3 (API/structured): zero-truncation (1M/64K/32)
|
|
336
|
+
compressed_text: Optional[str] = None
|
|
337
|
+
toon_text: Optional[str] = None
|
|
338
|
+
|
|
339
|
+
# -- Build compression input --
|
|
340
|
+
# If the response is plain text (not JSON), wrap it as a structured
|
|
341
|
+
# dict so the compression pipeline can still run and record stats.
|
|
342
|
+
# Otherwise bare strings like git log output would be silently skipped
|
|
343
|
+
# and never appear in tokenless stats, unlike openclaw/hermes where the
|
|
344
|
+
# agent framework natively wraps Bash output in {"stdout":...,...}.
|
|
345
|
+
if is_json:
|
|
346
|
+
compression_input = raw_text
|
|
347
|
+
else:
|
|
348
|
+
compression_input = json.dumps(
|
|
349
|
+
{"stdout": raw_text}, separators=(",", ":"), ensure_ascii=False
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# -- Determine thresholds based on tool category --
|
|
353
|
+
thresholds = _get_thresholds(tool_name)
|
|
354
|
+
|
|
355
|
+
# -- Response compression --
|
|
356
|
+
compressed_text = _run_tokenless(
|
|
357
|
+
tokenless_bin, "compress-response",
|
|
358
|
+
compression_input, session_id, tool_use_id, timeout=10,
|
|
359
|
+
extra_args=[
|
|
360
|
+
"--truncate-strings-at", str(thresholds[0]),
|
|
361
|
+
"--truncate-arrays-at", str(thresholds[1]),
|
|
362
|
+
"--max-depth", str(thresholds[2]),
|
|
363
|
+
],
|
|
364
|
+
)
|
|
365
|
+
if compressed_text and len(compressed_text) >= len(compression_input):
|
|
366
|
+
compressed_text = None # no savings → skip
|
|
367
|
+
|
|
368
|
+
# -- TOON encoding (if still valid JSON after compression) --
|
|
369
|
+
if compressed_text:
|
|
370
|
+
toon_input = compressed_text
|
|
371
|
+
else:
|
|
372
|
+
toon_input = compression_input
|
|
373
|
+
|
|
374
|
+
toon_parsed = _try_parse_json(toon_input)
|
|
375
|
+
if toon_parsed is not None:
|
|
376
|
+
toon_text = _run_tokenless(
|
|
377
|
+
tokenless_bin, "compress-toon",
|
|
378
|
+
toon_input, session_id, tool_use_id, timeout=10,
|
|
379
|
+
)
|
|
380
|
+
if toon_text and len(toon_text) >= len(toon_input):
|
|
381
|
+
toon_text = None # no savings → skip
|
|
382
|
+
|
|
383
|
+
# 10. Build additionalContext
|
|
384
|
+
parts: list[str] = []
|
|
385
|
+
|
|
386
|
+
original_chars = len(raw_text)
|
|
387
|
+
final_text = toon_text or compressed_text
|
|
388
|
+
is_large = len(raw_text) >= LARGE_RESPONSE_CHARS
|
|
389
|
+
|
|
390
|
+
if final_text:
|
|
391
|
+
final_chars = len(final_text)
|
|
392
|
+
savings_pct = round((1 - final_chars / max(original_chars, 1)) * 100)
|
|
393
|
+
parts.append(
|
|
394
|
+
f"[tokenless:compressed] {tool_name}: "
|
|
395
|
+
f"{original_chars:,} → {final_chars:,} chars ({savings_pct}% reduction)"
|
|
396
|
+
)
|
|
397
|
+
else:
|
|
398
|
+
# No compression savings
|
|
399
|
+
if is_large:
|
|
400
|
+
parts.append(
|
|
401
|
+
f"[tokenless] {tool_name}: {original_chars:,} chars, "
|
|
402
|
+
f"no compression savings achieved"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Environment error hints
|
|
406
|
+
if env_category:
|
|
407
|
+
parts.append(
|
|
408
|
+
f"[tokenless:env:{env_category}] {env_hint} "
|
|
409
|
+
f"Do NOT retry the same command — fix the environment first."
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# For large responses, include the compressed content so the model has a
|
|
413
|
+
# compact alternative to the raw output.
|
|
414
|
+
if final_text and is_large:
|
|
415
|
+
parts.append("--- compressed content ---")
|
|
416
|
+
if len(final_text) > COMPRESSED_CONTENT_CAP:
|
|
417
|
+
parts.append(final_text[:COMPRESSED_CONTENT_CAP])
|
|
418
|
+
parts.append(
|
|
419
|
+
f"... (compressed content truncated at {COMPRESSED_CONTENT_CAP:,} chars)"
|
|
420
|
+
)
|
|
421
|
+
else:
|
|
422
|
+
parts.append(final_text)
|
|
423
|
+
parts.append("--- end compressed content ---")
|
|
424
|
+
|
|
425
|
+
if not parts:
|
|
426
|
+
_skip()
|
|
427
|
+
|
|
428
|
+
additional_context = "\n".join(parts)
|
|
429
|
+
|
|
430
|
+
# 11. Output codex hook response
|
|
431
|
+
response = {
|
|
432
|
+
"hookSpecificOutput": {
|
|
433
|
+
"hookEventName": "PostToolUse",
|
|
434
|
+
"additionalContext": additional_context,
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
print(json.dumps(response, ensure_ascii=False))
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
if __name__ == "__main__":
|
|
441
|
+
main()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Detect whether the tokenless CLI is installed and functional.
|
|
3
|
+
#
|
|
4
|
+
# Output:
|
|
5
|
+
# JSON on stdout: {"installed": true, "version": "0.1.0", "path": "/usr/bin/tokenless"}
|
|
6
|
+
# or {"installed": false}
|
|
7
|
+
# Exit code: 0 either way (fail-open — plugin activation is controlled by capabilities)
|
|
8
|
+
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
TOKENLESS_BIN=""
|
|
12
|
+
|
|
13
|
+
# Search PATH first
|
|
14
|
+
if command -v tokenless >/dev/null 2>&1; then
|
|
15
|
+
TOKENLESS_BIN="$(command -v tokenless)"
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
# Fallback paths
|
|
19
|
+
if [[ -z "$TOKENLESS_BIN" ]]; then
|
|
20
|
+
for fp in \
|
|
21
|
+
"$HOME/.local/bin/tokenless" \
|
|
22
|
+
"/usr/local/bin/tokenless" \
|
|
23
|
+
"/usr/bin/tokenless" \
|
|
24
|
+
"$HOME/.local/share/anolisa/tokenless/tokenless" \
|
|
25
|
+
"$HOME/.local/lib/anolisa/tokenless/tokenless"; do
|
|
26
|
+
if [[ -f "$fp" && -x "$fp" ]]; then
|
|
27
|
+
TOKENLESS_BIN="$fp"
|
|
28
|
+
break
|
|
29
|
+
fi
|
|
30
|
+
done
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
if [[ -z "$TOKENLESS_BIN" ]]; then
|
|
34
|
+
echo '{"installed": false}'
|
|
35
|
+
exit 0
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
# Verify it actually runs
|
|
39
|
+
VERSION="$("$TOKENLESS_BIN" --version 2>/dev/null || true)"
|
|
40
|
+
if [[ -z "$VERSION" ]]; then
|
|
41
|
+
echo '{"installed": false}'
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# Extract version number
|
|
46
|
+
VERSION_CLEAN="$(echo "$VERSION" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown")"
|
|
47
|
+
|
|
48
|
+
# Use jq --arg to safely embed variables into JSON — avoids broken output
|
|
49
|
+
# when paths contain characters that need JSON escaping.
|
|
50
|
+
if command -v jq &>/dev/null; then
|
|
51
|
+
jq -n --arg ver "$VERSION_CLEAN" --arg p "$TOKENLESS_BIN" \
|
|
52
|
+
'{installed: true, version: $ver, path: $p}'
|
|
53
|
+
else
|
|
54
|
+
cat <<EOF
|
|
55
|
+
{
|
|
56
|
+
"installed": true,
|
|
57
|
+
"version": "$VERSION_CLEAN",
|
|
58
|
+
"path": "$TOKENLESS_BIN"
|
|
59
|
+
}
|
|
60
|
+
EOF
|
|
61
|
+
fi
|