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,487 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tokenless response compression hook for Cosh-NG, Claude Code, Qoder, and OpenCode.
|
|
3
|
+
|
|
4
|
+
Reads a PostToolUse JSON from stdin, compresses the tool response
|
|
5
|
+
via ``tokenless compress-response``, then optionally re-encodes to TOON
|
|
6
|
+
format via ``tokenless compress-toon`` for additional token savings.
|
|
7
|
+
|
|
8
|
+
Pipeline: Env Attribution -> Layered dispatch -> Compression -> TOON Encoding
|
|
9
|
+
1. If tool_response contains errors, classify as environment vs logic issue
|
|
10
|
+
and inject "Skip retry" guidance for LLM
|
|
11
|
+
2. 3-layer tool dispatch:
|
|
12
|
+
- Content retrieval (Read/Glob/Grep) -> skip all compression
|
|
13
|
+
- Shell/exec (Bash/Shell) -> moderate truncation (64K strings)
|
|
14
|
+
- Other tools -> zero-truncation compress-response + TOON
|
|
15
|
+
3. Strip debug fields, nulls, empty values (no truncation risk)
|
|
16
|
+
4. If the compressed result is still valid JSON, encode to TOON format
|
|
17
|
+
5. Stats are recorded automatically by tokenless CLI commands.
|
|
18
|
+
|
|
19
|
+
Hook point: **PostToolUse**
|
|
20
|
+
|
|
21
|
+
Output contract per agent:
|
|
22
|
+
- claude-code (>= 2.1.121): the compressed payload *replaces* the
|
|
23
|
+
model-visible tool result via ``hookSpecificOutput.updatedToolOutput``.
|
|
24
|
+
``additionalContext`` is additive in Claude Code (appended alongside
|
|
25
|
+
the original tool result), so it only carries genuinely additive
|
|
26
|
+
diagnostics (environment attribution). Older Claude Code versions fail
|
|
27
|
+
open: compression is disabled instead of injecting a duplicate payload
|
|
28
|
+
(issue #1645).
|
|
29
|
+
- qoder-cli: the compressed payload replaces the response via the string
|
|
30
|
+
field ``hookSpecificOutput.updatedToolOutput``. Structured responses are
|
|
31
|
+
serialized as compact JSON because Qoder rejects object and array values.
|
|
32
|
+
Qoder supports replacement for every tool, so compressed data is never
|
|
33
|
+
appended beside the original.
|
|
34
|
+
- opencode: the adapter translates ``updatedToolOutput`` to OpenCode's
|
|
35
|
+
mutable ``tool.execute.after`` output. ``additionalContext`` remains
|
|
36
|
+
reserved for additive readiness and environment diagnostics.
|
|
37
|
+
- cosh-ng: the compressed payload replaces the response via
|
|
38
|
+
``hookSpecificOutput.updatedToolResponse``. Extract only ``llmContent``
|
|
39
|
+
from wrapped responses; never include ``returnDisplay``. Keep
|
|
40
|
+
environment/error attribution in ``additionalContext`` (additive).
|
|
41
|
+
Unsupported Cosh-NG versions fail open with compression disabled.
|
|
42
|
+
- other agents: the compressed payload is injected via
|
|
43
|
+
``additionalContext`` per each runtime's hook contract.
|
|
44
|
+
|
|
45
|
+
The agent ID is read from the TOKENLESS_AGENT_ID environment variable
|
|
46
|
+
(set by the install action script). When running under Cosh-NG, the
|
|
47
|
+
agent ID is overridden to ``cosh-ng`` for correct stats attribution.
|
|
48
|
+
Fallback paths follow the ANOLISA FHS spec: /usr/bin/tokenless.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import subprocess
|
|
56
|
+
import sys
|
|
57
|
+
|
|
58
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
59
|
+
|
|
60
|
+
from hook_utils import (
|
|
61
|
+
_TOKENLESS_FALLBACK,
|
|
62
|
+
_TOKENLESS_LOCAL_LIB,
|
|
63
|
+
_TOKENLESS_LOCAL_SHARE,
|
|
64
|
+
SKIP_TOOLS,
|
|
65
|
+
classify_env_error,
|
|
66
|
+
detect_cosh_ng_runtime,
|
|
67
|
+
get_thresholds,
|
|
68
|
+
is_skill_file,
|
|
69
|
+
parse_version,
|
|
70
|
+
resolve_agent_id,
|
|
71
|
+
resolve_binary,
|
|
72
|
+
resolve_tool_call_id,
|
|
73
|
+
secure_write_text,
|
|
74
|
+
skip,
|
|
75
|
+
try_parse_json,
|
|
76
|
+
unwrap_string_json,
|
|
77
|
+
warn,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# -- constants ---------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
_MIN_RESPONSE_CHARS = 200
|
|
83
|
+
|
|
84
|
+
# Claude Code added hookSpecificOutput.updatedToolOutput (normal-path tool
|
|
85
|
+
# output replacement for all tools) in v2.1.121. Older versions only support
|
|
86
|
+
# the additive additionalContext, which would duplicate the payload.
|
|
87
|
+
_CLAUDE_AGENT_ID = "claude-code"
|
|
88
|
+
_CLAUDE_MIN_REPLACE_VERSION = (2, 1, 121)
|
|
89
|
+
_QODER_AGENT_ID = "qoder-cli"
|
|
90
|
+
_OPENCODE_AGENT_ID = "opencode"
|
|
91
|
+
|
|
92
|
+
# Cache for `claude --version`, keyed on binary path+mtime+size so upgrades
|
|
93
|
+
# invalidate it. Hooks run as a fresh process per tool call and spawning the
|
|
94
|
+
# node CLI every time would add noticeable latency.
|
|
95
|
+
_CLAUDE_VERSION_CACHE = os.path.join(
|
|
96
|
+
os.path.expanduser("~"), ".tokenless", ".claude-version"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# -- helpers -------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _build_additional_context(
|
|
104
|
+
content: str,
|
|
105
|
+
env_attribution: str = "",
|
|
106
|
+
) -> str:
|
|
107
|
+
parts = []
|
|
108
|
+
if env_attribution:
|
|
109
|
+
parts.append(env_attribution)
|
|
110
|
+
parts.append(content)
|
|
111
|
+
return "\n".join(parts)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _emit(output: dict) -> None:
|
|
115
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _emit_attribution_or_skip(env_attribution: str) -> None:
|
|
119
|
+
"""Pass the original result through, keeping only additive diagnostics.
|
|
120
|
+
|
|
121
|
+
Emits an attribution-only additionalContext when present (it is genuinely
|
|
122
|
+
additive and safe on every agent), otherwise a plain skip. Never returns.
|
|
123
|
+
"""
|
|
124
|
+
if env_attribution:
|
|
125
|
+
_emit({
|
|
126
|
+
"suppressOutput": True,
|
|
127
|
+
"hookSpecificOutput": {
|
|
128
|
+
"hookEventName": "PostToolUse",
|
|
129
|
+
"additionalContext": env_attribution,
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
sys.exit(0)
|
|
133
|
+
skip()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _cached_claude_version(claude_bin: str) -> tuple | None:
|
|
137
|
+
"""Return the Claude Code version tuple, caching `claude --version`."""
|
|
138
|
+
try:
|
|
139
|
+
st = os.stat(claude_bin)
|
|
140
|
+
cache_key = f"{claude_bin}:{int(st.st_mtime)}:{st.st_size}"
|
|
141
|
+
except OSError:
|
|
142
|
+
cache_key = claude_bin
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
with open(_CLAUDE_VERSION_CACHE) as f:
|
|
146
|
+
key, _, ver_str = f.read().strip().partition("\n")
|
|
147
|
+
if key == cache_key:
|
|
148
|
+
return parse_version(ver_str)
|
|
149
|
+
except OSError:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
proc = subprocess.run(
|
|
154
|
+
[claude_bin, "--version"],
|
|
155
|
+
capture_output=True, text=True, timeout=5,
|
|
156
|
+
)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
warn(f"claude --version failed: {e}")
|
|
159
|
+
return None
|
|
160
|
+
if proc.returncode != 0:
|
|
161
|
+
return None
|
|
162
|
+
ver = parse_version(proc.stdout)
|
|
163
|
+
if ver:
|
|
164
|
+
try:
|
|
165
|
+
# Same hardened write as other ~/.tokenless state files (0o600,
|
|
166
|
+
# symlink-safe) so the cache stays private on shared HOMEs.
|
|
167
|
+
secure_write_text(
|
|
168
|
+
_CLAUDE_VERSION_CACHE, f"{cache_key}\n{proc.stdout.strip()}"
|
|
169
|
+
)
|
|
170
|
+
except OSError:
|
|
171
|
+
pass
|
|
172
|
+
return ver
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _claude_supports_replacement() -> bool:
|
|
176
|
+
"""Whether the running Claude Code supports updatedToolOutput (>= 2.1.121).
|
|
177
|
+
|
|
178
|
+
Returns False when the version cannot be determined; the caller then
|
|
179
|
+
fails open by disabling compression, so unknown versions never receive a
|
|
180
|
+
duplicate compressed payload through additionalContext.
|
|
181
|
+
"""
|
|
182
|
+
claude_bin = resolve_binary("claude")
|
|
183
|
+
if not claude_bin:
|
|
184
|
+
return False
|
|
185
|
+
ver = _cached_claude_version(claude_bin)
|
|
186
|
+
return ver is not None and ver >= _CLAUDE_MIN_REPLACE_VERSION
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _restore_dropped_schema_fields(original: dict, compressed: dict) -> dict:
|
|
190
|
+
"""Restore top-level keys dropped by compression when originally empty.
|
|
191
|
+
|
|
192
|
+
compress-response drops nulls, empty values ("" / {} / []) and configured
|
|
193
|
+
debug fields. Built-in Claude Code tools expect a stable output schema
|
|
194
|
+
(e.g. Bash: stdout/stderr/interrupted/isImage), so cheap empty fields are
|
|
195
|
+
restored for updatedToolOutput; intentionally dropped non-empty debug
|
|
196
|
+
payloads stay dropped.
|
|
197
|
+
"""
|
|
198
|
+
restored = dict(compressed)
|
|
199
|
+
for key, value in original.items():
|
|
200
|
+
if key in restored:
|
|
201
|
+
continue
|
|
202
|
+
if value is None or value == "" or value == {} or value == []:
|
|
203
|
+
restored[key] = value
|
|
204
|
+
return restored
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _build_replacement_output(
|
|
208
|
+
tool_response_raw: object,
|
|
209
|
+
tool_response: str,
|
|
210
|
+
compressed: str,
|
|
211
|
+
final_output: str,
|
|
212
|
+
used_resp_compression: bool,
|
|
213
|
+
) -> tuple[bool, object]:
|
|
214
|
+
"""Build a schema-safe replacement for runtimes that support one."""
|
|
215
|
+
if not isinstance(tool_response_raw, (dict, list)):
|
|
216
|
+
return True, final_output
|
|
217
|
+
|
|
218
|
+
# TOON text cannot replace a structured response without changing the
|
|
219
|
+
# host tool schema, so this path requires a real JSON compression win.
|
|
220
|
+
if not used_resp_compression:
|
|
221
|
+
return False, None
|
|
222
|
+
|
|
223
|
+
compressed_parsed = try_parse_json(compressed)
|
|
224
|
+
if isinstance(tool_response_raw, dict) and isinstance(compressed_parsed, dict):
|
|
225
|
+
updated_output = _restore_dropped_schema_fields(
|
|
226
|
+
tool_response_raw, compressed_parsed
|
|
227
|
+
)
|
|
228
|
+
elif compressed_parsed is not None:
|
|
229
|
+
updated_output = compressed_parsed
|
|
230
|
+
else:
|
|
231
|
+
return False, None
|
|
232
|
+
|
|
233
|
+
# Restoring empty schema fields can cancel out a marginal win.
|
|
234
|
+
serialized = json.dumps(updated_output, separators=(",", ":"))
|
|
235
|
+
if len(serialized) >= len(tool_response):
|
|
236
|
+
return False, None
|
|
237
|
+
return True, updated_output
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# -- main --------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _warn_subprocess(label: str, proc: subprocess.CompletedProcess) -> None:
|
|
244
|
+
"""Log a non-zero subprocess exit with truncated stderr."""
|
|
245
|
+
detail = (proc.stderr or "").strip()[:200]
|
|
246
|
+
warn(
|
|
247
|
+
f"{label} exited {proc.returncode}: {detail}"
|
|
248
|
+
if detail
|
|
249
|
+
else f"{label} exited {proc.returncode} with empty stderr"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def main() -> None:
|
|
254
|
+
# 1. Detect runtime (Cosh-NG vs copilot-shell)
|
|
255
|
+
cosh_ng_version = detect_cosh_ng_runtime()
|
|
256
|
+
cosh_ng_detected = cosh_ng_version is not None
|
|
257
|
+
|
|
258
|
+
# If Cosh-NG is detected but unsupported version, fail open
|
|
259
|
+
if cosh_ng_detected and cosh_ng_version == (0, 0, 0):
|
|
260
|
+
warn("Unsupported Cosh-NG version. Response compression disabled (fail open).")
|
|
261
|
+
skip()
|
|
262
|
+
|
|
263
|
+
# 2. Resolve agent ID based on runtime
|
|
264
|
+
agent_id = resolve_agent_id()
|
|
265
|
+
|
|
266
|
+
# 3. Resolve binaries
|
|
267
|
+
tokenless_bin = resolve_binary(
|
|
268
|
+
"tokenless", _TOKENLESS_FALLBACK, _TOKENLESS_LOCAL_SHARE, _TOKENLESS_LOCAL_LIB
|
|
269
|
+
)
|
|
270
|
+
if not tokenless_bin:
|
|
271
|
+
warn("tokenless is not installed. Response compression hook disabled.")
|
|
272
|
+
skip()
|
|
273
|
+
|
|
274
|
+
# 4. Read stdin JSON
|
|
275
|
+
try:
|
|
276
|
+
input_data = json.load(sys.stdin)
|
|
277
|
+
except (json.JSONDecodeError, EOFError, ValueError):
|
|
278
|
+
warn("failed to read PostToolUse payload. Passing through unchanged.")
|
|
279
|
+
skip()
|
|
280
|
+
|
|
281
|
+
# 5. Extract tool_name (skip-tools handled after attribution)
|
|
282
|
+
tool_name = input_data.get("tool_name", "unknown")
|
|
283
|
+
|
|
284
|
+
# 6. Extract tool_response
|
|
285
|
+
tool_response_raw = input_data.get("tool_response", "")
|
|
286
|
+
if not tool_response_raw or tool_response_raw == "{}":
|
|
287
|
+
skip()
|
|
288
|
+
|
|
289
|
+
# 7. For Cosh-NG, extract only llmContent from the wrapped response.
|
|
290
|
+
# Never include returnDisplay in the provider-visible replacement.
|
|
291
|
+
llm_content = None
|
|
292
|
+
if isinstance(tool_response_raw, dict):
|
|
293
|
+
llm_content = tool_response_raw.get("llmContent")
|
|
294
|
+
if llm_content is None:
|
|
295
|
+
llm_content = tool_response_raw.get("returnDisplay")
|
|
296
|
+
elif isinstance(tool_response_raw, str):
|
|
297
|
+
# Try to parse as the {llmContent, returnDisplay} wrapper
|
|
298
|
+
parsed_wrapper = try_parse_json(tool_response_raw)
|
|
299
|
+
if isinstance(parsed_wrapper, dict) and "llmContent" in parsed_wrapper:
|
|
300
|
+
llm_content = parsed_wrapper["llmContent"]
|
|
301
|
+
|
|
302
|
+
# The model-visible content we will compress
|
|
303
|
+
model_visible_before = llm_content if llm_content is not None else tool_response_raw
|
|
304
|
+
|
|
305
|
+
# 8. Skip skill files (YAML frontmatter)
|
|
306
|
+
if isinstance(model_visible_before, str) and is_skill_file(model_visible_before):
|
|
307
|
+
skip()
|
|
308
|
+
|
|
309
|
+
# 9. Normalize response
|
|
310
|
+
if isinstance(model_visible_before, str):
|
|
311
|
+
unwrapped = unwrap_string_json(model_visible_before)
|
|
312
|
+
if not unwrapped:
|
|
313
|
+
skip() # Plain text, not JSON
|
|
314
|
+
tool_response = unwrapped
|
|
315
|
+
elif isinstance(model_visible_before, (dict, list)):
|
|
316
|
+
tool_response = json.dumps(model_visible_before, separators=(",", ":"))
|
|
317
|
+
else:
|
|
318
|
+
skip()
|
|
319
|
+
|
|
320
|
+
# 10. Validate it's JSON (needed for attribution on skip-tools too)
|
|
321
|
+
parsed = try_parse_json(tool_response)
|
|
322
|
+
if parsed is None:
|
|
323
|
+
skip()
|
|
324
|
+
|
|
325
|
+
# 11. Extract caller context
|
|
326
|
+
session_id = input_data.get("session_id", "")
|
|
327
|
+
tool_use_id = resolve_tool_call_id(agent_id, input_data)
|
|
328
|
+
|
|
329
|
+
# 12. Environment attribution analysis
|
|
330
|
+
env_attribution = ""
|
|
331
|
+
attr_category, attr_fix_hint = classify_env_error(parsed)
|
|
332
|
+
if attr_category:
|
|
333
|
+
env_attribution = (
|
|
334
|
+
f"[tokenless:env] {tool_name} failed: "
|
|
335
|
+
f"{attr_category} ({attr_fix_hint}). Skip retry."
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# 13. Content retrieval -- skip entirely (preserve integrity)
|
|
339
|
+
if tool_name in SKIP_TOOLS:
|
|
340
|
+
_emit_attribution_or_skip(env_attribution)
|
|
341
|
+
|
|
342
|
+
# 14. All other tools -- skip small responses, but still inject
|
|
343
|
+
# env attribution for error cases (small size doesn't mean the
|
|
344
|
+
# error classification is unimportant to the agent).
|
|
345
|
+
if len(tool_response) < _MIN_RESPONSE_CHARS:
|
|
346
|
+
_emit_attribution_or_skip(env_attribution)
|
|
347
|
+
|
|
348
|
+
# 15. Step 1: Response compression with 3-layer thresholds
|
|
349
|
+
compressed = tool_response
|
|
350
|
+
used_resp_compression = False
|
|
351
|
+
|
|
352
|
+
if isinstance(parsed, (dict, list)):
|
|
353
|
+
thresholds = get_thresholds(tool_name)
|
|
354
|
+
cmd = [
|
|
355
|
+
tokenless_bin, "compress-response",
|
|
356
|
+
"--agent-id", agent_id,
|
|
357
|
+
"--truncate-strings-at", str(thresholds[0]),
|
|
358
|
+
"--truncate-arrays-at", str(thresholds[1]),
|
|
359
|
+
"--max-depth", str(thresholds[2]),
|
|
360
|
+
]
|
|
361
|
+
if session_id:
|
|
362
|
+
cmd.extend(["--session-id", session_id])
|
|
363
|
+
if tool_use_id:
|
|
364
|
+
cmd.extend(["--tool-use-id", tool_use_id])
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
proc = subprocess.run(
|
|
368
|
+
cmd,
|
|
369
|
+
input=tool_response,
|
|
370
|
+
capture_output=True, text=True, timeout=3,
|
|
371
|
+
)
|
|
372
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
373
|
+
candidate = proc.stdout.strip()
|
|
374
|
+
# Compare against actual model-visible before size
|
|
375
|
+
if len(candidate) < len(tool_response):
|
|
376
|
+
compressed = candidate
|
|
377
|
+
used_resp_compression = True
|
|
378
|
+
elif proc.returncode != 0:
|
|
379
|
+
_warn_subprocess("compress-response", proc)
|
|
380
|
+
except Exception as e:
|
|
381
|
+
warn(f"Response compression error: {e}")
|
|
382
|
+
|
|
383
|
+
# 16. Step 2: TOON encoding
|
|
384
|
+
toon_output = ""
|
|
385
|
+
|
|
386
|
+
if tokenless_bin:
|
|
387
|
+
toon_parsed = try_parse_json(compressed)
|
|
388
|
+
if toon_parsed is not None:
|
|
389
|
+
toon_cmd = [tokenless_bin, "compress-toon", "--agent-id", agent_id]
|
|
390
|
+
if session_id:
|
|
391
|
+
toon_cmd.extend(["--session-id", session_id])
|
|
392
|
+
if tool_use_id:
|
|
393
|
+
toon_cmd.extend(["--tool-use-id", tool_use_id])
|
|
394
|
+
try:
|
|
395
|
+
proc = subprocess.run(
|
|
396
|
+
toon_cmd,
|
|
397
|
+
input=compressed,
|
|
398
|
+
capture_output=True, text=True, timeout=1,
|
|
399
|
+
)
|
|
400
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
401
|
+
candidate = proc.stdout.strip()
|
|
402
|
+
if len(candidate) < len(compressed):
|
|
403
|
+
toon_output = candidate
|
|
404
|
+
elif proc.returncode != 0:
|
|
405
|
+
_warn_subprocess("compress-toon", proc)
|
|
406
|
+
except Exception as e:
|
|
407
|
+
warn(f"TOON encoding error: {e}")
|
|
408
|
+
|
|
409
|
+
# Determine final output
|
|
410
|
+
final_output = toon_output if toon_output else compressed
|
|
411
|
+
|
|
412
|
+
# Nothing shrank — pass the original through untouched instead of
|
|
413
|
+
# emitting a same-size duplicate of the response (applies to all agents).
|
|
414
|
+
if not used_resp_compression and not toon_output:
|
|
415
|
+
_emit_attribution_or_skip(env_attribution)
|
|
416
|
+
|
|
417
|
+
# 17. Build response — dispatch by agent runtime.
|
|
418
|
+
#
|
|
419
|
+
# Claude Code, Qoder, and OpenCode support real tool-output replacement. Keep
|
|
420
|
+
# additionalContext for additive diagnostics only; using it for compressed
|
|
421
|
+
# data would leave the original result in context and increase token use.
|
|
422
|
+
if agent_id in {_CLAUDE_AGENT_ID, _QODER_AGENT_ID, _OPENCODE_AGENT_ID}:
|
|
423
|
+
if agent_id == _CLAUDE_AGENT_ID and not _claude_supports_replacement():
|
|
424
|
+
warn(
|
|
425
|
+
"Claude Code < 2.1.121 (or version unknown): "
|
|
426
|
+
"updatedToolOutput unsupported, response compression disabled."
|
|
427
|
+
)
|
|
428
|
+
_emit_attribution_or_skip(env_attribution)
|
|
429
|
+
|
|
430
|
+
replace, updated_output = _build_replacement_output(
|
|
431
|
+
tool_response_raw,
|
|
432
|
+
tool_response,
|
|
433
|
+
compressed,
|
|
434
|
+
final_output,
|
|
435
|
+
used_resp_compression,
|
|
436
|
+
)
|
|
437
|
+
if not replace:
|
|
438
|
+
_emit_attribution_or_skip(env_attribution)
|
|
439
|
+
|
|
440
|
+
# Qoder validates updatedToolOutput as a string even when the original
|
|
441
|
+
# tool response is structured. Preserve the compact schema as JSON text.
|
|
442
|
+
if agent_id == _QODER_AGENT_ID and not isinstance(updated_output, str):
|
|
443
|
+
updated_output = json.dumps(
|
|
444
|
+
updated_output, separators=(",", ":"), ensure_ascii=False
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
hook_output = {
|
|
448
|
+
"hookEventName": "PostToolUse",
|
|
449
|
+
"updatedToolOutput": updated_output,
|
|
450
|
+
}
|
|
451
|
+
if env_attribution:
|
|
452
|
+
hook_output["additionalContext"] = env_attribution
|
|
453
|
+
_emit({"suppressOutput": True, "hookSpecificOutput": hook_output})
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
# Cosh-NG: use updatedToolResponse for response replacement.
|
|
457
|
+
# Skip compression if it doesn't reduce model-visible size.
|
|
458
|
+
if cosh_ng_detected:
|
|
459
|
+
if len(final_output) >= len(tool_response):
|
|
460
|
+
_emit_attribution_or_skip(env_attribution)
|
|
461
|
+
|
|
462
|
+
hook_specific = {
|
|
463
|
+
"hookEventName": "PostToolUse",
|
|
464
|
+
"updatedToolResponse": final_output,
|
|
465
|
+
}
|
|
466
|
+
if env_attribution:
|
|
467
|
+
hook_specific["additionalContext"] = env_attribution
|
|
468
|
+
_emit({"suppressOutput": True, "hookSpecificOutput": hook_specific})
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
# Other agents: inject via additionalContext per their hook contracts.
|
|
472
|
+
context = _build_additional_context(
|
|
473
|
+
final_output,
|
|
474
|
+
env_attribution=env_attribution,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
_emit({
|
|
478
|
+
"suppressOutput": True,
|
|
479
|
+
"hookSpecificOutput": {
|
|
480
|
+
"hookEventName": "PostToolUse",
|
|
481
|
+
"additionalContext": context,
|
|
482
|
+
},
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
if __name__ == "__main__":
|
|
487
|
+
main()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tokenless schema compression hook.
|
|
3
|
+
|
|
4
|
+
Reads a BeforeModel JSON from stdin, extracts the tools array,
|
|
5
|
+
invokes ``tokenless compress-schema --batch`` via subprocess, and
|
|
6
|
+
writes a HookOutput JSON to stdout.
|
|
7
|
+
|
|
8
|
+
Hook point: **BeforeModel**
|
|
9
|
+
|
|
10
|
+
The agent ID is read from the TOKENLESS_AGENT_ID environment variable
|
|
11
|
+
(set by the install action script).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
|
|
21
|
+
from hook_utils import (
|
|
22
|
+
_TOKENLESS_FALLBACK,
|
|
23
|
+
_TOKENLESS_LOCAL_LIB,
|
|
24
|
+
_TOKENLESS_LOCAL_SHARE,
|
|
25
|
+
resolve_agent_id,
|
|
26
|
+
resolve_binary,
|
|
27
|
+
resolve_tool_call_id,
|
|
28
|
+
skip,
|
|
29
|
+
warn,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# -- constants ---------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
_AGENT_ID = resolve_agent_id()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# -- helpers -----------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_json_array(data: str) -> bool:
|
|
41
|
+
try:
|
|
42
|
+
obj = json.loads(data)
|
|
43
|
+
return isinstance(obj, list)
|
|
44
|
+
except (json.JSONDecodeError, ValueError):
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# -- main --------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main() -> None:
|
|
52
|
+
# 1. Check tokenless binary
|
|
53
|
+
tokenless_bin = resolve_binary(
|
|
54
|
+
"tokenless",
|
|
55
|
+
_TOKENLESS_FALLBACK,
|
|
56
|
+
_TOKENLESS_LOCAL_SHARE,
|
|
57
|
+
_TOKENLESS_LOCAL_LIB,
|
|
58
|
+
)
|
|
59
|
+
if not tokenless_bin:
|
|
60
|
+
warn(
|
|
61
|
+
"tokenless is not installed or not in PATH. Schema compression hook disabled."
|
|
62
|
+
)
|
|
63
|
+
skip()
|
|
64
|
+
|
|
65
|
+
# 2. Read stdin JSON
|
|
66
|
+
try:
|
|
67
|
+
input_data = json.load(sys.stdin)
|
|
68
|
+
except (json.JSONDecodeError, EOFError, ValueError):
|
|
69
|
+
warn("failed to read BeforeModel payload. Passing through unchanged.")
|
|
70
|
+
skip()
|
|
71
|
+
|
|
72
|
+
# 3. Extract tools array. `config.tools` is the canonical position (both
|
|
73
|
+
# copilot-shell's Hook Translator and Cosh-NG put it there); the top-level
|
|
74
|
+
# `tools` is the older position, kept for hosts that still emit it.
|
|
75
|
+
# Presence of the canonical key decides, not its truthiness: a host that
|
|
76
|
+
# declares no tools sends an empty canonical array, and falling through to
|
|
77
|
+
# a stale legacy field there would compress declarations this request never
|
|
78
|
+
# carried. This mirrors the host-side precedence.
|
|
79
|
+
llm_request = input_data.get("llm_request", {})
|
|
80
|
+
config = llm_request.get("config")
|
|
81
|
+
if isinstance(config, dict) and "tools" in config:
|
|
82
|
+
tools = config["tools"]
|
|
83
|
+
else:
|
|
84
|
+
tools = llm_request.get("tools")
|
|
85
|
+
if not tools:
|
|
86
|
+
skip()
|
|
87
|
+
|
|
88
|
+
tools_json = json.dumps(tools, separators=(",", ":"))
|
|
89
|
+
|
|
90
|
+
# 4. Extract caller context
|
|
91
|
+
session_id = input_data.get("session_id", "")
|
|
92
|
+
tool_use_id = resolve_tool_call_id(_AGENT_ID, input_data)
|
|
93
|
+
|
|
94
|
+
# 5. Compress schemas via tokenless compress-schema --batch
|
|
95
|
+
cmd = [tokenless_bin, "compress-schema", "--batch", "--agent-id", _AGENT_ID]
|
|
96
|
+
if session_id:
|
|
97
|
+
cmd.extend(["--session-id", session_id])
|
|
98
|
+
if tool_use_id:
|
|
99
|
+
cmd.extend(["--tool-use-id", tool_use_id])
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
proc = subprocess.run(
|
|
103
|
+
cmd,
|
|
104
|
+
input=tools_json,
|
|
105
|
+
capture_output=True,
|
|
106
|
+
text=True,
|
|
107
|
+
timeout=10,
|
|
108
|
+
)
|
|
109
|
+
except Exception:
|
|
110
|
+
warn("Schema compression subprocess failed. Passing through unchanged.")
|
|
111
|
+
skip()
|
|
112
|
+
|
|
113
|
+
if proc.returncode != 0:
|
|
114
|
+
detail = (proc.stderr or "").strip()[:200]
|
|
115
|
+
warn(
|
|
116
|
+
f"Schema compression failed with exit code {proc.returncode}: {detail}"
|
|
117
|
+
if detail
|
|
118
|
+
else f"Schema compression failed with exit code {proc.returncode}. Passing through unchanged."
|
|
119
|
+
)
|
|
120
|
+
skip()
|
|
121
|
+
|
|
122
|
+
compressed = proc.stdout.strip()
|
|
123
|
+
if not compressed or not _is_json_array(compressed):
|
|
124
|
+
warn(
|
|
125
|
+
"Schema compression returned invalid JSON. Passing through unchanged."
|
|
126
|
+
)
|
|
127
|
+
skip()
|
|
128
|
+
|
|
129
|
+
# 6. Build response at the canonical position.
|
|
130
|
+
output = {
|
|
131
|
+
"hookSpecificOutput": {
|
|
132
|
+
"hookEventName": "BeforeModel",
|
|
133
|
+
"llm_request": {
|
|
134
|
+
"config": {
|
|
135
|
+
"tools": json.loads(compressed),
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
print(json.dumps(output))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
main()
|