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.
Files changed (70) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +770 -0
  3. package/adapters/tokenless/claude-code/.claude-plugin/marketplace.json +15 -0
  4. package/adapters/tokenless/claude-code/.claude-plugin/plugin.json +9 -0
  5. package/adapters/tokenless/claude-code/hooks/hooks.json +38 -0
  6. package/adapters/tokenless/claude-code/scripts/detect.sh +193 -0
  7. package/adapters/tokenless/claude-code/scripts/install.sh +92 -0
  8. package/adapters/tokenless/claude-code/scripts/uninstall.sh +50 -0
  9. package/adapters/tokenless/codex/.codex-plugin/plugin.json +20 -0
  10. package/adapters/tokenless/codex/README.md +160 -0
  11. package/adapters/tokenless/codex/hooks/hooks.json +52 -0
  12. package/adapters/tokenless/codex/scripts/_common.sh +25 -0
  13. package/adapters/tokenless/codex/scripts/check-tokenless +94 -0
  14. package/adapters/tokenless/codex/scripts/compress-response +441 -0
  15. package/adapters/tokenless/codex/scripts/detect.sh +61 -0
  16. package/adapters/tokenless/codex/scripts/install.sh +151 -0
  17. package/adapters/tokenless/codex/scripts/rewrite-hook +282 -0
  18. package/adapters/tokenless/codex/scripts/tool-ready +303 -0
  19. package/adapters/tokenless/codex/scripts/uninstall.sh +78 -0
  20. package/adapters/tokenless/common/commands/tokenless-stats.toml +2 -0
  21. package/adapters/tokenless/common/cosh-extension.json +65 -0
  22. package/adapters/tokenless/common/hooks/compress_response_hook.py +487 -0
  23. package/adapters/tokenless/common/hooks/compress_schema_hook.py +144 -0
  24. package/adapters/tokenless/common/hooks/compress_toon_hook.py +160 -0
  25. package/adapters/tokenless/common/hooks/hook_utils.py +584 -0
  26. package/adapters/tokenless/common/hooks/rewrite_hook.py +223 -0
  27. package/adapters/tokenless/common/hooks/run-hook.sh +62 -0
  28. package/adapters/tokenless/common/hooks/tool_categories.json +99 -0
  29. package/adapters/tokenless/common/hooks/tool_ready_hook.sh +571 -0
  30. package/adapters/tokenless/common/tokenless-env-fix.sh +730 -0
  31. package/adapters/tokenless/common/tool-ready-spec.json +113 -0
  32. package/adapters/tokenless/dsh/cordis.patch.yml +8 -0
  33. package/adapters/tokenless/dsh/dist/index.js +399 -0
  34. package/adapters/tokenless/dsh/package.json +26 -0
  35. package/adapters/tokenless/hermes/__init__.py +572 -0
  36. package/adapters/tokenless/hermes/plugin.yaml +9 -0
  37. package/adapters/tokenless/hermes/scripts/detect.sh +80 -0
  38. package/adapters/tokenless/hermes/scripts/install.sh +60 -0
  39. package/adapters/tokenless/hermes/scripts/uninstall.sh +45 -0
  40. package/adapters/tokenless/manifest.json +147 -0
  41. package/adapters/tokenless/openclaw/dist/index.d.ts +27 -0
  42. package/adapters/tokenless/openclaw/dist/index.js +598 -0
  43. package/adapters/tokenless/openclaw/dist/tool_categories.json +99 -0
  44. package/adapters/tokenless/openclaw/index.ts +720 -0
  45. package/adapters/tokenless/openclaw/openclaw.plugin.json +40 -0
  46. package/adapters/tokenless/openclaw/package.json +24 -0
  47. package/adapters/tokenless/openclaw/scripts/detect.sh +102 -0
  48. package/adapters/tokenless/openclaw/scripts/install.sh +75 -0
  49. package/adapters/tokenless/openclaw/scripts/uninstall.sh +46 -0
  50. package/adapters/tokenless/openclaw/tsconfig.json +13 -0
  51. package/adapters/tokenless/opencode/plugin.js +246 -0
  52. package/adapters/tokenless/opencode/scripts/detect.sh +38 -0
  53. package/adapters/tokenless/opencode/scripts/install.sh +56 -0
  54. package/adapters/tokenless/opencode/scripts/uninstall.sh +29 -0
  55. package/adapters/tokenless/qoder/.qoder-plugin/plugin.json +11 -0
  56. package/adapters/tokenless/qoder/commands/tokenless-stats.md +8 -0
  57. package/adapters/tokenless/qoder/hooks/hooks.json +36 -0
  58. package/adapters/tokenless/qoder/hooks/run-hook.sh +51 -0
  59. package/adapters/tokenless/qoder/scripts/detect.sh +35 -0
  60. package/adapters/tokenless/qoder/scripts/install.sh +136 -0
  61. package/adapters/tokenless/qoder/scripts/uninstall.sh +106 -0
  62. package/adapters/tokenless/qwencode/qwen-extension.json +69 -0
  63. package/adapters/tokenless/qwencode/scripts/detect.sh +125 -0
  64. package/adapters/tokenless/qwencode/scripts/install.sh +128 -0
  65. package/adapters/tokenless/qwencode/scripts/uninstall.sh +62 -0
  66. package/bin/rtk +6 -0
  67. package/bin/tokenless +6 -0
  68. package/bin/toon +6 -0
  69. package/package.json +57 -0
  70. 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()