anolisa-tokenless 0.7.13 → 0.8.0

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 (34) hide show
  1. package/README.md +219 -87
  2. package/adapters/tokenless/claude-code/.claude-plugin/plugin.json +1 -1
  3. package/adapters/tokenless/claude-code/hooks/run-hook.sh +62 -0
  4. package/adapters/tokenless/codex/.codex-plugin/plugin.json +5 -4
  5. package/adapters/tokenless/codex/README.md +22 -32
  6. package/adapters/tokenless/codex/hooks/hooks.json +3 -3
  7. package/adapters/tokenless/codex/scripts/response-diagnostics +170 -0
  8. package/adapters/tokenless/common/cosh-extension.json +4 -4
  9. package/adapters/tokenless/common/hooks/compress_response_hook.py +250 -307
  10. package/adapters/tokenless/common/hooks/compress_schema_hook.py +34 -42
  11. package/adapters/tokenless/common/hooks/hook_utils.py +281 -44
  12. package/adapters/tokenless/common/hooks/rewrite_hook.py +53 -169
  13. package/adapters/tokenless/dsh/dist/index.js +353 -264
  14. package/adapters/tokenless/dsh/package.json +2 -2
  15. package/adapters/tokenless/hermes/__init__.py +191 -355
  16. package/adapters/tokenless/hermes/plugin.yaml +2 -2
  17. package/adapters/tokenless/manifest.json +18 -3
  18. package/adapters/tokenless/openclaw/dist/index.d.ts +4 -16
  19. package/adapters/tokenless/openclaw/dist/index.js +291 -507
  20. package/adapters/tokenless/openclaw/index.ts +408 -628
  21. package/adapters/tokenless/openclaw/openclaw.plugin.json +4 -20
  22. package/adapters/tokenless/openclaw/package.json +6 -4
  23. package/adapters/tokenless/qoder/.qoder-plugin/plugin.json +1 -1
  24. package/adapters/tokenless/qwencode/hooks/run-hook.sh +62 -0
  25. package/adapters/tokenless/qwencode/qwen-extension.json +4 -4
  26. package/adapters/tokenless/qwenpaw/plugin.json +17 -0
  27. package/adapters/tokenless/qwenpaw/plugin.py +390 -0
  28. package/adapters/tokenless/qwenpaw/requirements.txt +6 -0
  29. package/adapters/tokenless/qwenpaw/scripts/detect.sh +131 -0
  30. package/adapters/tokenless/qwenpaw/scripts/install.sh +98 -0
  31. package/adapters/tokenless/qwenpaw/scripts/uninstall.sh +61 -0
  32. package/package.json +5 -5
  33. package/adapters/tokenless/codex/scripts/compress-response +0 -445
  34. package/adapters/tokenless/common/hooks/compress_toon_hook.py +0 -171
@@ -1,20 +1,15 @@
1
1
  #!/usr/bin/env python3
2
2
  """Tokenless response compression hook for Cosh-NG, Claude Code, Qoder, and OpenCode.
3
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.
4
+ Reads a PostToolUse JSON from stdin, forwards the model-visible tool
5
+ response to the unified ``tokenless compress`` Protocol v2 PostTool operation
6
+ and translates the result into the host's
7
+ envelope. JSON detection, tool threshold selection, TOON selection, and
8
+ final acceptance all live behind the entry point; this hook only parses the
9
+ host object, declares capabilities, and builds envelopes (§4.5).
10
+
11
+ One Tokenless subprocess per invocation. Environment-error attribution is
12
+ owned by the Rust PostTool service.
18
13
 
19
14
  Hook point: **PostToolUse**
20
15
 
@@ -29,23 +24,22 @@ Output contract per agent:
29
24
  - qoder-cli: the compressed payload replaces the response via the string
30
25
  field ``hookSpecificOutput.updatedToolOutput``. Structured responses are
31
26
  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
27
  - opencode: the adapter translates ``updatedToolOutput`` to OpenCode's
35
- mutable ``tool.execute.after`` output. ``additionalContext`` remains
36
- reserved for additive readiness and environment diagnostics.
28
+ mutable ``tool.execute.after`` output.
37
29
  - cosh-ng: the compressed payload replaces the response via
38
30
  ``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.
31
+ from wrapped responses; never include ``returnDisplay``. Unsupported
32
+ Cosh-NG versions fail open with compression disabled.
33
+ - other agents (additionalContext-only hosts): passthrough. Additive
34
+ injection would append the compressed copy beside the still-visible
35
+ original a net token increase — so hosts without true output
36
+ replacement remain passthrough (roadmap §7). Environment attribution is
37
+ still injected: it is additive by design.
38
+
39
+ The agent ID is resolved from the host runtime, ``--agent-id`` argument, or
40
+ TOKENLESS_AGENT_ID environment variable. When running under Cosh-NG, runtime
41
+ detection overrides the declared ID for correct stats attribution. Fallback
42
+ paths follow the ANOLISA FHS spec: /usr/bin/tokenless.
49
43
  """
50
44
 
51
45
  from __future__ import annotations
@@ -61,31 +55,38 @@ from hook_utils import (
61
55
  _TOKENLESS_FALLBACK,
62
56
  _TOKENLESS_LOCAL_LIB,
63
57
  _TOKENLESS_LOCAL_SHARE,
58
+ SHELL_TOOLS,
64
59
  SKIP_TOOLS,
65
- classify_env_error,
60
+ build_post_tool_request,
61
+ consume_output_optimization,
66
62
  detect_cosh_ng_runtime,
67
- get_thresholds,
68
63
  is_skill_file,
64
+ is_tokenless_retrieve_command,
69
65
  parse_version,
70
66
  resolve_agent_id,
71
67
  resolve_binary,
72
68
  resolve_tool_call_id,
69
+ run_compress,
73
70
  secure_write_text,
74
71
  skip,
72
+ tokenless_retrieve_command_available,
75
73
  try_parse_json,
76
- unwrap_string_json,
77
74
  warn,
78
75
  )
79
76
 
80
77
  # -- constants ---------------------------------------------------------------
81
78
 
82
- _MIN_RESPONSE_CHARS = 200
79
+ # Shell tool envelopes carry the log in one dominant text field. Unwrapping
80
+ # is worth a rebuilt envelope only when that field is large enough for the
81
+ # build/log engine to bite (its own gates start at 30 lines / 200 chars;
82
+ # 2000 chars keeps the rewrap machinery out of trivial outputs).
83
+ _SHELL_TEXT_FIELDS = ("stdout", "stderr")
84
+ _SHELL_UNWRAP_MIN_CHARS = 2_000
83
85
 
84
- # Minimum payload size for the TOON encoding step. TOON on small JSON
85
- # saves only a few characters (observed ~0.3% below ~500 chars) while the
86
- # per-event encode cost stays the same, so payloads under this threshold
87
- # keep the response-compressed form and skip the TOON pass entirely.
88
- _MIN_TOON_CHARS = 500
86
+ # Below the qwen/cosh extension manifests' 10 s host wrapper so a
87
+ # pathological input is killed here (fail-open skip) before the host kills
88
+ # the whole hook.
89
+ _COMPRESS_TIMEOUT = 8
89
90
 
90
91
  # Claude Code added hookSpecificOutput.updatedToolOutput (normal-path tool
91
92
  # output replacement for all tools) in v2.1.121. Older versions only support
@@ -98,25 +99,12 @@ _OPENCODE_AGENT_ID = "opencode"
98
99
  # Cache for `claude --version`, keyed on binary path+mtime+size so upgrades
99
100
  # invalidate it. Hooks run as a fresh process per tool call and spawning the
100
101
  # node CLI every time would add noticeable latency.
101
- _CLAUDE_VERSION_CACHE = os.path.join(
102
- os.path.expanduser("~"), ".tokenless", ".claude-version"
103
- )
102
+ _CLAUDE_VERSION_CACHE = os.path.join(os.path.expanduser("~"), ".tokenless", ".claude-version")
104
103
 
105
104
 
106
105
  # -- helpers -------------------------------------------------------------------
107
106
 
108
107
 
109
- def _build_additional_context(
110
- content: str,
111
- env_attribution: str = "",
112
- ) -> str:
113
- parts = []
114
- if env_attribution:
115
- parts.append(env_attribution)
116
- parts.append(content)
117
- return "\n".join(parts)
118
-
119
-
120
108
  def _emit(output: dict) -> None:
121
109
  print(json.dumps(output, ensure_ascii=False))
122
110
 
@@ -128,17 +116,44 @@ def _emit_attribution_or_skip(env_attribution: str) -> None:
128
116
  additive and safe on every agent), otherwise a plain skip. Never returns.
129
117
  """
130
118
  if env_attribution:
131
- _emit({
132
- "suppressOutput": True,
133
- "hookSpecificOutput": {
134
- "hookEventName": "PostToolUse",
135
- "additionalContext": env_attribution,
136
- },
137
- })
119
+ _emit(
120
+ {
121
+ "suppressOutput": True,
122
+ "hookSpecificOutput": {
123
+ "hookEventName": "PostToolUse",
124
+ "additionalContext": env_attribution,
125
+ },
126
+ }
127
+ )
138
128
  sys.exit(0)
139
129
  skip()
140
130
 
141
131
 
132
+ def _shell_text_field(tool_name: str, envelope) -> tuple | None:
133
+ """The dominant text field of a shell tool's envelope, or ``None``.
134
+
135
+ Shell envelopes (``{"stdout": …, "stderr": …}``) are JSON to the entry
136
+ point, which would compress them log-blind. Unwrapping the largest text
137
+ field sends the log itself through the text slot; step 13 re-injects the
138
+ compressed text into a same-shaped envelope, so the host's tool protocol
139
+ is untouched (adapters own envelope knowledge, §4.5). Only the single
140
+ largest field is compressed — one Tokenless subprocess per invocation
141
+ (§5.6) — the other field stays byte-identical.
142
+ """
143
+ if tool_name not in SHELL_TOOLS or not isinstance(envelope, dict):
144
+ return None
145
+ best = None
146
+ for name in _SHELL_TEXT_FIELDS:
147
+ value = envelope.get(name)
148
+ if (
149
+ isinstance(value, str)
150
+ and len(value) >= _SHELL_UNWRAP_MIN_CHARS
151
+ and (best is None or len(value) > len(best[1]))
152
+ ):
153
+ best = (name, value)
154
+ return best
155
+
156
+
142
157
  def _cached_claude_version(claude_bin: str) -> tuple | None:
143
158
  """Return the Claude Code version tuple, caching `claude --version`."""
144
159
  try:
@@ -158,7 +173,9 @@ def _cached_claude_version(claude_bin: str) -> tuple | None:
158
173
  try:
159
174
  proc = subprocess.run(
160
175
  [claude_bin, "--version"],
161
- capture_output=True, text=True, timeout=5,
176
+ capture_output=True,
177
+ text=True,
178
+ timeout=5,
162
179
  )
163
180
  except Exception as e:
164
181
  warn(f"claude --version failed: {e}")
@@ -170,9 +187,7 @@ def _cached_claude_version(claude_bin: str) -> tuple | None:
170
187
  try:
171
188
  # Same hardened write as other ~/.tokenless state files (0o600,
172
189
  # symlink-safe) so the cache stays private on shared HOMEs.
173
- secure_write_text(
174
- _CLAUDE_VERSION_CACHE, f"{cache_key}\n{proc.stdout.strip()}"
175
- )
190
+ secure_write_text(_CLAUDE_VERSION_CACHE, f"{cache_key}\n{proc.stdout.strip()}")
176
191
  except OSError:
177
192
  pass
178
193
  return ver
@@ -181,8 +196,8 @@ def _cached_claude_version(claude_bin: str) -> tuple | None:
181
196
  def _claude_supports_replacement() -> bool:
182
197
  """Whether the running Claude Code supports updatedToolOutput (>= 2.1.121).
183
198
 
184
- Returns False when the version cannot be determined; the caller then
185
- fails open by disabling compression, so unknown versions never receive a
199
+ Returns False when the version cannot be determined; the hook then
200
+ declares no replacement capability, so unknown versions never receive a
186
201
  duplicate compressed payload through additionalContext.
187
202
  """
188
203
  claude_bin = resolve_binary("claude")
@@ -192,88 +207,37 @@ def _claude_supports_replacement() -> bool:
192
207
  return ver is not None and ver >= _CLAUDE_MIN_REPLACE_VERSION
193
208
 
194
209
 
195
- def _restore_dropped_schema_fields(original: dict, compressed: dict) -> dict:
196
- """Restore top-level keys dropped by compression when originally empty.
197
-
198
- compress-response drops nulls, empty values ("" / {} / []) and configured
199
- debug fields. Built-in Claude Code tools expect a stable output schema
200
- (e.g. Bash: stdout/stderr/interrupted/isImage), so cheap empty fields are
201
- restored for updatedToolOutput; intentionally dropped non-empty debug
202
- payloads stay dropped.
203
- """
204
- restored = dict(compressed)
205
- for key, value in original.items():
206
- if key in restored:
207
- continue
208
- if value is None or value == "" or value == {} or value == []:
209
- restored[key] = value
210
- return restored
211
-
212
-
213
- def _build_replacement_output(
214
- tool_response_raw: object,
215
- tool_response: str,
216
- compressed: str,
217
- final_output: str,
218
- used_resp_compression: bool,
219
- ) -> tuple[bool, object]:
220
- """Build a schema-safe replacement for runtimes that support one."""
221
- if not isinstance(tool_response_raw, (dict, list)):
222
- return True, final_output
223
-
224
- # TOON text cannot replace a structured response without changing the
225
- # host tool schema, so this path requires a real JSON compression win.
226
- if not used_resp_compression:
227
- return False, None
228
-
229
- compressed_parsed = try_parse_json(compressed)
230
- if isinstance(tool_response_raw, dict) and isinstance(compressed_parsed, dict):
231
- updated_output = _restore_dropped_schema_fields(
232
- tool_response_raw, compressed_parsed
233
- )
234
- elif compressed_parsed is not None:
235
- updated_output = compressed_parsed
236
- else:
237
- return False, None
238
-
239
- # Restoring empty schema fields can cancel out a marginal win.
240
- # ensure_ascii=False keeps the size comparison in Unicode characters,
241
- # consistent with the non-escaped normalization below.
242
- serialized = json.dumps(
243
- updated_output, separators=(",", ":"), ensure_ascii=False
244
- )
245
- if len(serialized) >= len(tool_response):
246
- return False, None
247
- return True, updated_output
248
-
249
-
250
210
  # -- main --------------------------------------------------------------------
251
211
 
252
212
 
253
- def _warn_subprocess(label: str, proc: subprocess.CompletedProcess) -> None:
254
- """Log a non-zero subprocess exit with truncated stderr."""
255
- detail = (proc.stderr or "").strip()[:200]
256
- warn(
257
- f"{label} exited {proc.returncode}: {detail}"
258
- if detail
259
- else f"{label} exited {proc.returncode} with empty stderr"
260
- )
261
-
262
-
263
213
  def main() -> None:
264
214
  # 1. Detect runtime (Cosh-NG vs copilot-shell)
265
215
  cosh_ng_version = detect_cosh_ng_runtime()
266
216
  cosh_ng_detected = cosh_ng_version is not None
267
217
 
268
- # If Cosh-NG is detected but unsupported version, fail open
218
+ # 2. Resolve agent ID based on runtime
219
+ agent_id = resolve_agent_id()
220
+
221
+ # 3. Read stdin JSON and consume any matching PreTool state.
222
+ try:
223
+ input_data = json.load(sys.stdin)
224
+ except (json.JSONDecodeError, EOFError, ValueError):
225
+ warn("failed to read PostToolUse payload. Passing through unchanged.")
226
+ skip()
227
+
228
+ session_id = input_data.get("session_id", "")
229
+ tool_use_id = resolve_tool_call_id(agent_id, input_data)
230
+ try:
231
+ output_optimization = consume_output_optimization(agent_id, session_id, tool_use_id)
232
+ except OSError as error:
233
+ warn(f"failed to consume PreTool optimization state: {error}")
234
+ output_optimization = "none"
235
+
269
236
  if cosh_ng_detected and cosh_ng_version == (0, 0, 0):
270
237
  warn("Unsupported Cosh-NG version. Response compression disabled (fail open).")
271
238
  skip()
272
239
 
273
- # 2. Resolve agent ID based on runtime
274
- agent_id = resolve_agent_id()
275
-
276
- # 3. Resolve binaries
240
+ # 4. Resolve the single Core entry point after consuming per-call state.
277
241
  tokenless_bin = resolve_binary(
278
242
  "tokenless", _TOKENLESS_FALLBACK, _TOKENLESS_LOCAL_SHARE, _TOKENLESS_LOCAL_LIB
279
243
  )
@@ -281,22 +245,12 @@ def main() -> None:
281
245
  warn("tokenless is not installed. Response compression hook disabled.")
282
246
  skip()
283
247
 
284
- # 4. Read stdin JSON
285
- try:
286
- input_data = json.load(sys.stdin)
287
- except (json.JSONDecodeError, EOFError, ValueError):
288
- warn("failed to read PostToolUse payload. Passing through unchanged.")
289
- skip()
290
-
291
- # 5. Extract tool_name (skip-tools handled after attribution)
292
248
  tool_name = input_data.get("tool_name", "unknown")
293
-
294
- # 6. Extract tool_response
295
249
  tool_response_raw = input_data.get("tool_response", "")
296
250
  if not tool_response_raw or tool_response_raw == "{}":
297
251
  skip()
298
252
 
299
- # 7. For Cosh-NG, extract only llmContent from the wrapped response.
253
+ # 5. For Cosh-NG, extract only llmContent from the wrapped response.
300
254
  # Never include returnDisplay in the provider-visible replacement.
301
255
  llm_content = None
302
256
  if isinstance(tool_response_raw, dict):
@@ -304,201 +258,190 @@ def main() -> None:
304
258
  if llm_content is None:
305
259
  llm_content = tool_response_raw.get("returnDisplay")
306
260
  elif isinstance(tool_response_raw, str):
307
- # Try to parse as the {llmContent, returnDisplay} wrapper
308
261
  parsed_wrapper = try_parse_json(tool_response_raw)
309
262
  if isinstance(parsed_wrapper, dict) and "llmContent" in parsed_wrapper:
310
263
  llm_content = parsed_wrapper["llmContent"]
311
264
 
312
- # The model-visible content we will compress
265
+ # The model-visible content we will send for compression
313
266
  model_visible_before = llm_content if llm_content is not None else tool_response_raw
314
267
 
315
- # 8. Skip skill files (YAML frontmatter)
268
+ # 6. Skip skill files (YAML frontmatter). Spawn avoidance only: they are
269
+ # never JSON, so the entry point would pass them through anyway.
316
270
  if isinstance(model_visible_before, str) and is_skill_file(model_visible_before):
317
271
  skip()
318
272
 
319
- # 9. Normalize response
320
- if isinstance(model_visible_before, str):
321
- unwrapped = unwrap_string_json(model_visible_before)
322
- if not unwrapped:
323
- skip() # Plain text, not JSON
324
- tool_response = unwrapped
273
+ # 7. Copy the model-visible value into the request content (§4.5). A
274
+ # shell envelope's dominant text field goes through the text slot
275
+ # instead of log-blind JSON; ensure_ascii=False matches the entry
276
+ # point's normalization, so size gates measure Unicode characters on
277
+ # both sides.
278
+ shell_field = _shell_text_field(tool_name, model_visible_before)
279
+ if shell_field is not None:
280
+ content = shell_field[1]
281
+ elif isinstance(model_visible_before, str):
282
+ content = model_visible_before
325
283
  elif isinstance(model_visible_before, (dict, list)):
326
- # ensure_ascii=False: size gates below must count Unicode
327
- # characters (code points), not \uXXXX escape sequences, so
328
- # structured payloads are measured the same way as JSON string
329
- # inputs and the OpenClaw adapter.
330
- tool_response = json.dumps(
331
- model_visible_before, separators=(",", ":"), ensure_ascii=False
332
- )
284
+ content = json.dumps(model_visible_before, separators=(",", ":"), ensure_ascii=False)
333
285
  else:
334
286
  skip()
335
287
 
336
- # 10. Validate it's JSON (needed for attribution on skip-tools too)
337
- parsed = try_parse_json(tool_response)
338
- if parsed is None:
339
- skip()
340
-
341
- # 11. Extract caller context
342
- session_id = input_data.get("session_id", "")
343
- tool_use_id = resolve_tool_call_id(agent_id, input_data)
344
-
345
- # 12. Environment attribution analysis
346
- env_attribution = ""
347
- attr_category, attr_fix_hint = classify_env_error(parsed)
348
- if attr_category:
349
- env_attribution = (
350
- f"[tokenless:env] {tool_name} failed: "
351
- f"{attr_category} ({attr_fix_hint}). Skip retry."
288
+ # 8. Capability declaration: what can this host actually do?
289
+ if cosh_ng_detected:
290
+ can_replace = True
291
+ replace_with_text = True # updatedToolResponse accepts any text
292
+ elif agent_id in {_QODER_AGENT_ID, _OPENCODE_AGENT_ID}:
293
+ can_replace = True
294
+ # An unwrapped shell field is plain text regardless of its envelope.
295
+ replace_with_text = shell_field is not None or not isinstance(
296
+ tool_response_raw, (dict, list)
352
297
  )
353
-
354
- # 13. Content retrieval -- skip entirely (preserve integrity)
355
- if tool_name in SKIP_TOOLS:
356
- _emit_attribution_or_skip(env_attribution)
357
-
358
- # 14. All other tools -- skip small responses, but still inject
359
- # env attribution for error cases (small size doesn't mean the
360
- # error classification is unimportant to the agent).
361
- if len(tool_response) < _MIN_RESPONSE_CHARS:
362
- _emit_attribution_or_skip(env_attribution)
363
-
364
- # 15. Step 1: Response compression with 3-layer thresholds
365
- compressed = tool_response
366
- used_resp_compression = False
367
-
368
- if isinstance(parsed, (dict, list)):
369
- thresholds = get_thresholds(tool_name)
370
- cmd = [
371
- tokenless_bin, "compress-response",
372
- "--agent-id", agent_id,
373
- "--truncate-strings-at", str(thresholds[0]),
374
- "--truncate-arrays-at", str(thresholds[1]),
375
- "--max-depth", str(thresholds[2]),
376
- ]
377
- if session_id:
378
- cmd.extend(["--session-id", session_id])
379
- if tool_use_id:
380
- cmd.extend(["--tool-use-id", tool_use_id])
381
-
382
- try:
383
- proc = subprocess.run(
384
- cmd,
385
- input=tool_response,
386
- capture_output=True, text=True, timeout=3,
387
- )
388
- if proc.returncode == 0 and proc.stdout.strip():
389
- candidate = proc.stdout.strip()
390
- # Compare against actual model-visible before size
391
- if len(candidate) < len(tool_response):
392
- compressed = candidate
393
- used_resp_compression = True
394
- elif proc.returncode != 0:
395
- _warn_subprocess("compress-response", proc)
396
- except Exception as e:
397
- warn(f"Response compression error: {e}")
398
-
399
- # 16. Step 2: TOON encoding — only for payloads at or above the
400
- # minimum threshold; small JSON gains near-zero chars from TOON but
401
- # would still pay the full encode cost on every PostToolUse event.
402
- toon_output = ""
403
-
404
- if tokenless_bin and len(compressed) >= _MIN_TOON_CHARS:
405
- toon_parsed = try_parse_json(compressed)
406
- if toon_parsed is not None:
407
- toon_cmd = [tokenless_bin, "compress-toon", "--agent-id", agent_id]
408
- if session_id:
409
- toon_cmd.extend(["--session-id", session_id])
410
- if tool_use_id:
411
- toon_cmd.extend(["--tool-use-id", tool_use_id])
412
- try:
413
- proc = subprocess.run(
414
- toon_cmd,
415
- input=compressed,
416
- capture_output=True, text=True, timeout=1,
417
- )
418
- if proc.returncode == 0 and proc.stdout.strip():
419
- candidate = proc.stdout.strip()
420
- if len(candidate) < len(compressed):
421
- toon_output = candidate
422
- elif proc.returncode != 0:
423
- _warn_subprocess("compress-toon", proc)
424
- except Exception as e:
425
- warn(f"TOON encoding error: {e}")
426
-
427
- # Determine final output
428
- final_output = toon_output if toon_output else compressed
429
-
430
- # Nothing shrank — pass the original through untouched instead of
431
- # emitting a same-size duplicate of the response (applies to all agents).
432
- if not used_resp_compression and not toon_output:
433
- _emit_attribution_or_skip(env_attribution)
434
-
435
- # 17. Build response — dispatch by agent runtime.
436
- #
437
- # Claude Code, Qoder, and OpenCode support real tool-output replacement. Keep
438
- # additionalContext for additive diagnostics only; using it for compressed
439
- # data would leave the original result in context and increase token use.
440
- if agent_id in {_CLAUDE_AGENT_ID, _QODER_AGENT_ID, _OPENCODE_AGENT_ID}:
441
- if agent_id == _CLAUDE_AGENT_ID and not _claude_supports_replacement():
298
+ elif agent_id == _CLAUDE_AGENT_ID:
299
+ can_replace = _claude_supports_replacement()
300
+ replace_with_text = shell_field is not None or not isinstance(
301
+ tool_response_raw, (dict, list)
302
+ )
303
+ if not can_replace:
442
304
  warn(
443
305
  "Claude Code < 2.1.121 (or version unknown): "
444
306
  "updatedToolOutput unsupported, response compression disabled."
445
307
  )
446
- _emit_attribution_or_skip(env_attribution)
308
+ else:
309
+ # additionalContext-only hosts have no true replacement: passthrough
310
+ # (additive injection would duplicate the original — see module doc).
311
+ can_replace = False
312
+ replace_with_text = True
447
313
 
448
- replace, updated_output = _build_replacement_output(
449
- tool_response_raw,
450
- tool_response,
451
- compressed,
452
- final_output,
453
- used_resp_compression,
314
+ # 9. Map host facts into the required lifecycle fields.
315
+ if tool_name in SKIP_TOOLS:
316
+ content_origin = "file_content"
317
+ elif tool_name in SHELL_TOOLS:
318
+ content_origin = "command_output"
319
+ else:
320
+ content_origin = "api_response"
321
+ raw_status = str(input_data.get("status", "")).lower()
322
+ shell_process_result = model_visible_before if isinstance(model_visible_before, dict) else None
323
+ shell_process_error = (
324
+ tool_name in SHELL_TOOLS
325
+ and shell_process_result is not None
326
+ and (
327
+ shell_process_result.get("error") is not None
328
+ or (
329
+ shell_process_result.get("exit_code") is not None
330
+ and shell_process_result.get("exit_code") != 0
331
+ )
332
+ or (
333
+ shell_process_result.get("exitCode") is not None
334
+ and shell_process_result.get("exitCode") != 0
335
+ )
454
336
  )
455
- if not replace:
456
- _emit_attribution_or_skip(env_attribution)
337
+ )
338
+ if raw_status in {"interrupted", "denied"}:
339
+ status = raw_status
340
+ elif input_data.get("is_error") is True or (
341
+ isinstance(tool_response_raw, dict) and tool_response_raw.get("isError") is True
342
+ ):
343
+ status = "error"
344
+ elif shell_process_error:
345
+ status = "error"
346
+ else:
347
+ status = "success"
348
+
349
+ # Shell envelopes often carry a large stdout alongside the actual failure
350
+ # in a short stderr. Error results are never replaced, so send the error
351
+ # stream to Core for diagnosis while the host keeps the original envelope.
352
+ if status == "error" and tool_name in SHELL_TOOLS and isinstance(model_visible_before, dict):
353
+ error_parts = []
354
+ for field in ("stderr", "error"):
355
+ value = model_visible_before.get(field)
356
+ if isinstance(value, str) and value.strip():
357
+ error_parts.append(value)
358
+ if error_parts:
359
+ content = "\n".join(error_parts)
360
+
361
+ retrieve_result = status == "success" and is_tokenless_retrieve_command(
362
+ tool_name, input_data.get("tool_input")
363
+ )
364
+ retrieval_available = (
365
+ can_replace
366
+ and status == "success"
367
+ and output_optimization == "none"
368
+ and not retrieve_result
369
+ and tokenless_retrieve_command_available()
370
+ )
457
371
 
458
- # Qoder validates updatedToolOutput as a string even when the original
459
- # tool response is structured. Preserve the compact schema as JSON text.
460
- if agent_id == _QODER_AGENT_ID and not isinstance(updated_output, str):
461
- updated_output = json.dumps(
462
- updated_output, separators=(",", ":"), ensure_ascii=False
463
- )
372
+ # 10. The one Tokenless subprocess: Core owns all PostTool policy.
373
+ request = build_post_tool_request(
374
+ content,
375
+ agent_id,
376
+ tool_name,
377
+ status,
378
+ content_origin,
379
+ output_optimization,
380
+ result_kind="retrieve" if retrieve_result else "tool",
381
+ recovery={"kind": "shell" if retrieval_available else "none"},
382
+ session_id=session_id,
383
+ tool_use_id=tool_use_id,
384
+ replace_output=can_replace,
385
+ replace_with_text=replace_with_text,
386
+ )
387
+ response = run_compress(tokenless_bin, request, _COMPRESS_TIMEOUT, "post_tool")
388
+ env_attribution = response.get("additional_context", "") if response is not None else ""
389
+ if response is None or response.get("disposition") != "applied":
390
+ _emit_attribution_or_skip(env_attribution)
464
391
 
465
- hook_output = {
466
- "hookEventName": "PostToolUse",
467
- "updatedToolOutput": updated_output,
468
- }
469
- if env_attribution:
470
- hook_output["additionalContext"] = env_attribution
471
- _emit({"suppressOutput": True, "hookSpecificOutput": hook_output})
472
- return
392
+ output_text = response.get("output")
393
+ if not isinstance(output_text, str) or not output_text:
394
+ warn("tokenless compress returned no output. Passing through unchanged.")
395
+ _emit_attribution_or_skip(env_attribution)
473
396
 
474
- # Cosh-NG: use updatedToolResponse for response replacement.
475
- # Skip compression if it doesn't reduce model-visible size.
476
- if cosh_ng_detected:
477
- if len(final_output) >= len(tool_response):
478
- _emit_attribution_or_skip(env_attribution)
397
+ # 11. Envelope construction dispatch by agent runtime. An unwrapped
398
+ # shell field is re-injected into a same-shaped envelope: the compressed
399
+ # text replaces exactly the field that was sent, every other field stays
400
+ # byte-identical.
401
+ rewrapped = None
402
+ if shell_field is not None:
403
+ rewrapped = dict(model_visible_before)
404
+ rewrapped[shell_field[0]] = output_text
479
405
 
406
+ if cosh_ng_detected:
480
407
  hook_specific = {
481
408
  "hookEventName": "PostToolUse",
482
- "updatedToolResponse": final_output,
409
+ "updatedToolResponse": rewrapped if rewrapped is not None else output_text,
483
410
  }
484
411
  if env_attribution:
485
412
  hook_specific["additionalContext"] = env_attribution
486
413
  _emit({"suppressOutput": True, "hookSpecificOutput": hook_specific})
487
414
  return
488
415
 
489
- # Other agents: inject via additionalContext per their hook contracts.
490
- context = _build_additional_context(
491
- final_output,
492
- env_attribution=env_attribution,
493
- )
416
+ if rewrapped is not None:
417
+ updated_output = rewrapped
418
+ elif replace_with_text:
419
+ updated_output = output_text
420
+ else:
421
+ # Structured slot: the entry point guarantees schema-stable JSON for
422
+ # an applied response. A parse failure means the subprocess boundary
423
+ # was violated — fail open.
424
+ updated_output = try_parse_json(output_text)
425
+ if updated_output is None:
426
+ warn("tokenless compress returned non-JSON for a structured slot.")
427
+ _emit_attribution_or_skip(env_attribution)
494
428
 
495
- _emit({
496
- "suppressOutput": True,
497
- "hookSpecificOutput": {
498
- "hookEventName": "PostToolUse",
499
- "additionalContext": context,
500
- },
501
- })
429
+ # Qoder validates updatedToolOutput as a string even when the original
430
+ # tool response is structured. The entry point's compact serialization
431
+ # is exactly that string; a rewrapped shell envelope serializes here.
432
+ if agent_id == _QODER_AGENT_ID and not isinstance(updated_output, str):
433
+ if rewrapped is not None:
434
+ updated_output = json.dumps(rewrapped, separators=(",", ":"), ensure_ascii=False)
435
+ else:
436
+ updated_output = output_text
437
+
438
+ hook_output = {
439
+ "hookEventName": "PostToolUse",
440
+ "updatedToolOutput": updated_output,
441
+ }
442
+ if env_attribution:
443
+ hook_output["additionalContext"] = env_attribution
444
+ _emit({"suppressOutput": True, "hookSpecificOutput": hook_output})
502
445
 
503
446
 
504
447
  if __name__ == "__main__":