anolisa-tokenless 0.7.14 → 0.8.1

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 (32) hide show
  1. package/README.md +226 -79
  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/claude-code/scripts/detect.sh +50 -7
  5. package/adapters/tokenless/codex/.codex-plugin/plugin.json +1 -1
  6. package/adapters/tokenless/common/cosh-extension.json +4 -4
  7. package/adapters/tokenless/common/hooks/compress_response_hook.py +180 -96
  8. package/adapters/tokenless/common/hooks/compress_schema_hook.py +19 -31
  9. package/adapters/tokenless/common/hooks/hook_utils.py +240 -70
  10. package/adapters/tokenless/common/hooks/rewrite_hook.py +53 -169
  11. package/adapters/tokenless/dsh/dist/index.js +353 -264
  12. package/adapters/tokenless/dsh/package.json +2 -2
  13. package/adapters/tokenless/hermes/__init__.py +338 -363
  14. package/adapters/tokenless/hermes/plugin.yaml +2 -2
  15. package/adapters/tokenless/manifest.json +17 -1
  16. package/adapters/tokenless/openclaw/dist/index.d.ts +4 -16
  17. package/adapters/tokenless/openclaw/dist/index.js +291 -507
  18. package/adapters/tokenless/openclaw/index.ts +408 -628
  19. package/adapters/tokenless/openclaw/openclaw.plugin.json +4 -20
  20. package/adapters/tokenless/openclaw/package.json +6 -4
  21. package/adapters/tokenless/openclaw/scripts/install.sh +51 -12
  22. package/adapters/tokenless/qoder/.qoder-plugin/plugin.json +1 -1
  23. package/adapters/tokenless/qwencode/hooks/run-hook.sh +62 -0
  24. package/adapters/tokenless/qwencode/qwen-extension.json +4 -4
  25. package/adapters/tokenless/qwenpaw/plugin.json +17 -0
  26. package/adapters/tokenless/qwenpaw/plugin.py +390 -0
  27. package/adapters/tokenless/qwenpaw/requirements.txt +6 -0
  28. package/adapters/tokenless/qwenpaw/scripts/detect.sh +131 -0
  29. package/adapters/tokenless/qwenpaw/scripts/install.sh +98 -0
  30. package/adapters/tokenless/qwenpaw/scripts/uninstall.sh +61 -0
  31. package/package.json +5 -5
  32. package/adapters/tokenless/common/hooks/compress_toon_hook.py +0 -174
@@ -1,30 +1,11 @@
1
- """Token-Less Plugin for Hermes Agent.
2
-
3
- Combines multiple context-compression strategies into a single plugin:
4
-
5
- 1. **Response compression** ``transform_tool_result`` : compresses tool
6
- results via ``tokenless compress-response`` with per-layer thresholds
7
- (moderate for shell, zero-truncation for API tools), stripping debug
8
- fields, nulls, empty values.
9
- 2. **TOON encoding** — ``transform_tool_result`` : pipeline step after
10
- response compression; re-encodes JSON results to TOON format via
11
- ``tokenless compress-toon`` for additional token savings (15-40%)
12
- with proper stats recording and size check.
13
- 3. **Tool Ready** — ``pre_tool_call`` : environment readiness pre-check
14
- with auto-fix and skip-retry feedback for missing dependencies.
15
- 4. **Command rewriting** — ``pre_tool_call`` : blocks shell commands
16
- and suggests RTK-rewritten equivalents. Hermes's hook cannot modify
17
- arguments, so the agent must re-execute with the suggested command
18
- (one extra round-trip). Safe: ``rtk rewrite`` only does text
19
- substitution, never executes the command.
20
- 5. **Session tracking** — ``on_session_start`` : propagates agent/session
21
- IDs to tokenless stats recording.
22
-
23
- Not available in Hermes: schema compression (Hermes hooks do not expose
24
- tool schemas).
25
-
26
- Every hook degrades gracefully: if ``tokenless`` is not installed, all
27
- hooks are silently skipped.
1
+ """Tokenless lifecycle adapter for Hermes Agent.
2
+
3
+ Hermes cannot replace tool arguments on older supported releases, so PreTool
4
+ blocks a shell call and suggests the Core-rewritten command. PostTool sends the
5
+ final model-bound result to Core and applies only the returned disposition.
6
+ Schema compression is not available from the Hermes hook surface. Marker-directed
7
+ recovery uses Hermes's existing shell tool and the trusted local Tokenless CLI.
8
+ Tool Ready remains product-wide hard-disabled.
28
9
 
29
10
  Activation is controlled by the Hermes plugin system — list ``tokenless`` in
30
11
  ``plugins.enabled`` in ``config.yaml``, or enable via
@@ -33,10 +14,11 @@ Activation is controlled by the Hermes plugin system — list ``tokenless`` in
33
14
 
34
15
  from __future__ import annotations
35
16
 
17
+ import inspect
36
18
  import json
37
19
  import logging
38
20
  import os
39
- import subprocess
21
+ import shlex
40
22
  import sys
41
23
  from typing import Any
42
24
 
@@ -95,18 +77,153 @@ def _validate_hooks_dir(path: str) -> str | None:
95
77
  return None
96
78
 
97
79
 
80
+ # Symbols that must exist in the shared hook_utils module, paired with the
81
+ # exact call shape this adapter uses at its hook entry points. When a
82
+ # candidate passes the trust check but ships an older hook_utils.py (e.g. a
83
+ # stale install from a previous adapter version), the candidate is rejected and
84
+ # the search continues to later paths. The lifecycle migration made the Hermes
85
+ # adapter a thin Core client, so the required symbols are the Protocol v2
86
+ # request builders, the compress runner, and the Retrieve helpers this module
87
+ # from-imports at load time. The remaining load-time imports (resolve_binary,
88
+ # SHELL_TOOLS, SKIP_TOOLS, and the local-path constants) predate Protocol v2,
89
+ # so every module that ships the v2 API also ships them. A module missing any
90
+ # listed symbol would pass a narrower check and then raise ImportError at the
91
+ # top-level from-import, after the candidate search has already ended — with no
92
+ # fallback to later complete candidates.
93
+ #
94
+ # Symbol names alone are not enough: hook_utils has also changed *call
95
+ # signatures* while keeping names stable. ``build_post_tool_request`` took a
96
+ # keyword-only ``retrieval_available`` flag until c2c7e580e replaced it with
97
+ # the ``recovery`` mapping, so the module shipped by 9f109d559 exports all five
98
+ # required symbols yet rejects the ``recovery=`` this adapter passes — the
99
+ # plugin would import cleanly and every PostTool request would then die with
100
+ # "unexpected keyword argument 'recovery'", again after the candidate search
101
+ # has ended and with no fallback. Each shape is therefore bound with
102
+ # :func:`inspect.signature`; the values below are placeholders mirroring the
103
+ # real call sites (``bind`` only checks arity and parameter names, so nothing
104
+ # is ever called here).
105
+ _HOOK_UTILS_CALL_SHAPES: tuple[tuple[str, tuple[Any, ...], dict[str, Any]], ...] = (
106
+ # on_compress_pre_tool:
107
+ # build_pre_tool_request(args, AGENT_ID, tool_name, "command",
108
+ # session_id, tool_call_id,
109
+ # replace_arguments=False, block_and_suggest=True)
110
+ (
111
+ "build_pre_tool_request",
112
+ ({}, "", "", "", "", ""),
113
+ {"replace_arguments": False, "block_and_suggest": True},
114
+ ),
115
+ # on_transform_tool_result:
116
+ # build_post_tool_request(content, AGENT_ID, tool_name, protocol_status,
117
+ # content_origin, output_optimization,
118
+ # result_kind=..., recovery=..., session_id=...,
119
+ # tool_use_id=..., replace_output=True,
120
+ # replace_with_text=True)
121
+ (
122
+ "build_post_tool_request",
123
+ ("", "", "", "", "", ""),
124
+ {
125
+ "result_kind": "tool",
126
+ "recovery": {"kind": "none"},
127
+ "session_id": "",
128
+ "tool_use_id": "",
129
+ "replace_output": True,
130
+ "replace_with_text": True,
131
+ },
132
+ ),
133
+ # Both hooks: run_compress(tokenless_bin, request, timeout, operation)
134
+ ("run_compress", ("", {}, 0, ""), {}),
135
+ # on_transform_tool_result: is_tokenless_retrieve_command(tool_name, args)
136
+ ("is_tokenless_retrieve_command", ("", {}), {}),
137
+ # on_transform_tool_result: tokenless_retrieve_command_available()
138
+ ("tokenless_retrieve_command_available", (), {}),
139
+ )
140
+
141
+ _HOOK_UTILS_REQUIRED_SYMBOLS = tuple(name for name, _, _ in _HOOK_UTILS_CALL_SHAPES)
142
+
143
+
144
+ def _restore_cached_hook_utils(saved: Any) -> None:
145
+ """Reinstate the module cached before a trial import, or drop the trial."""
146
+ if saved is not None:
147
+ sys.modules["hook_utils"] = saved
148
+ else:
149
+ sys.modules.pop("hook_utils", None)
150
+
151
+
152
+ def _call_shape_rejection(
153
+ symbol: Any, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]
154
+ ) -> str | None:
155
+ """Return why *symbol* cannot take the adapter call shape, else ``None``."""
156
+ try:
157
+ signature = inspect.signature(symbol)
158
+ except (TypeError, ValueError) as exc:
159
+ return f"{name} is not an introspectable callable ({exc})"
160
+ try:
161
+ signature.bind(*args, **kwargs)
162
+ except TypeError as exc:
163
+ # Name the shape the adapter needs, not only what the module offers:
164
+ # a renamed keyword (retrieval_available -> recovery) reports as a
165
+ # missing argument on some Python versions, which alone would not tell
166
+ # the reader which side of the contract moved.
167
+ return (
168
+ f"{name}{signature} rejects the adapter call shape "
169
+ f"({len(args)} positional, kwargs: {', '.join(sorted(kwargs)) or 'none'}): "
170
+ f"{exc}"
171
+ )
172
+ return None
173
+
174
+
175
+ def _check_api_compat(candidate_dir: str) -> str | None:
176
+ """Trial-import hook_utils from *candidate_dir* and verify its API.
177
+
178
+ Returns ``None`` when the module loads, exposes every symbol in
179
+ :data:`_HOOK_UTILS_REQUIRED_SYMBOLS`, and every one of them accepts the
180
+ matching call shape in :data:`_HOOK_UTILS_CALL_SHAPES`; otherwise a
181
+ human-readable rejection reason. On success the freshly imported module is
182
+ kept in ``sys.modules`` so the subsequent ``from hook_utils import …``
183
+ reuses it rather than a stale cached copy. On rejection the ``sys.path``
184
+ mutation is cleaned up and the previously cached module (if any) is
185
+ restored so later candidates start from a clean state.
186
+ """
187
+ sys.path.insert(0, candidate_dir)
188
+ saved = sys.modules.pop("hook_utils", None)
189
+ try:
190
+ import hook_utils as _trial # type: ignore[import-not-found]
191
+ missing = [s for s in _HOOK_UTILS_REQUIRED_SYMBOLS
192
+ if not hasattr(_trial, s)]
193
+ if missing:
194
+ _restore_cached_hook_utils(saved)
195
+ return f"API mismatch: missing {', '.join(missing)}"
196
+ mismatched = []
197
+ for name, args, kwargs in _HOOK_UTILS_CALL_SHAPES:
198
+ reason = _call_shape_rejection(getattr(_trial, name), name, args, kwargs)
199
+ if reason is not None:
200
+ mismatched.append(reason)
201
+ if mismatched:
202
+ _restore_cached_hook_utils(saved)
203
+ return "API mismatch: " + "; ".join(mismatched)
204
+ # Success — keep the freshly imported module in sys.modules.
205
+ return None
206
+ except Exception as exc:
207
+ _restore_cached_hook_utils(saved)
208
+ return f"import failed: {exc}"
209
+ finally:
210
+ sys.path.pop(0)
211
+
212
+
213
+
98
214
  def _resolve_hook_utils() -> tuple[str, list[str]]:
99
215
  """Locate a trusted shared hooks directory and make it importable.
100
216
 
101
217
  Returns ``(resolved_path, candidate_list)``. The resolved path is
102
218
  inserted at the front of ``sys.path`` so the shared ``hook_utils``
103
219
  module can be imported. Raises :exc:`ImportError` when no candidate
104
- passes the trust policy.
220
+ passes both the trust policy and the API compatibility check.
105
221
  """
106
222
  # Resolve real home from passwd DB for user-install fallback path
107
223
  # (NOT $HOME — env-controllable).
108
224
  try:
109
225
  import pwd as _pwd
226
+
110
227
  real_home = _pwd.getpwuid(os.getuid()).pw_dir
111
228
  except (ImportError, KeyError):
112
229
  real_home = ""
@@ -114,9 +231,10 @@ def _resolve_hook_utils() -> tuple[str, list[str]]:
114
231
  real_home = ""
115
232
 
116
233
  candidates = [
117
- os.path.join(_HERE, "..", "common", "hooks"), # source-tree / symlink install
118
- "/usr/share/anolisa/adapters/tokenless/common/hooks", # RPM system
119
- "/usr/local/share/anolisa/adapters/tokenless/common/hooks", # manual system
234
+ # Source-tree / symlink install.
235
+ os.path.join(_HERE, "..", "common", "hooks"),
236
+ "/usr/share/anolisa/adapters/tokenless/common/hooks", # RPM system
237
+ "/usr/local/share/anolisa/adapters/tokenless/common/hooks", # Manual system
120
238
  ]
121
239
  # XDG user data dir first (anolisa FsLayout::user precedence), then the
122
240
  # passwd-home default. XDG_DATA_HOME is env-controllable, but candidates
@@ -124,20 +242,29 @@ def _resolve_hook_utils() -> tuple[str, list[str]]:
124
242
  xdg_data = os.environ.get("XDG_DATA_HOME", "")
125
243
  if xdg_data and os.path.isabs(xdg_data):
126
244
  candidates.append(
127
- os.path.join(xdg_data, "anolisa", "adapters", "tokenless", "common", "hooks"))
245
+ os.path.join(xdg_data, "anolisa", "adapters", "tokenless", "common", "hooks")
246
+ )
128
247
  if real_home:
129
248
  candidates.append(
130
- os.path.join(real_home, ".local", "share",
131
- "anolisa", "adapters", "tokenless", "common", "hooks"))
249
+ os.path.join(
250
+ real_home, ".local", "share", "anolisa", "adapters", "tokenless", "common", "hooks"
251
+ )
252
+ )
132
253
 
133
254
  rejections: list[str] = []
134
255
  for candidate in candidates:
135
256
  reason = _validate_hooks_dir(candidate)
136
- if reason is None:
137
- resolved = os.path.realpath(candidate)
138
- sys.path.insert(0, resolved)
139
- return resolved, candidates
140
- rejections.append(f" - {candidate}: {reason}")
257
+ if reason is not None:
258
+ rejections.append(f" - {candidate}: {reason}")
259
+ continue
260
+ # Trust check passed — verify API compat (version mismatch guard).
261
+ resolved = os.path.realpath(candidate)
262
+ api_reason = _check_api_compat(resolved)
263
+ if api_reason is not None:
264
+ rejections.append(f" - {candidate}: {api_reason}")
265
+ continue
266
+ sys.path.insert(0, resolved)
267
+ return resolved, candidates
141
268
 
142
269
  raise ImportError(
143
270
  "tokenless: no trusted shared hook_utils module (common/hooks/) found.\n"
@@ -153,22 +280,22 @@ def _resolve_hook_utils() -> tuple[str, list[str]]:
153
280
  _HOOK_UTILS_RESOLVED, _HOOK_UTILS_CANDIDATES = _resolve_hook_utils()
154
281
 
155
282
  from hook_utils import (
156
- _TOKENLESS_FALLBACK,
157
- _TOKENLESS_LOCAL_SHARE,
158
- _TOKENLESS_LOCAL_LIB,
159
283
  _RTK_FALLBACK,
160
- _RTK_LOCAL_SHARE,
161
284
  _RTK_LOCAL_LIB,
285
+ _RTK_LOCAL_SHARE,
286
+ _TOKENLESS_FALLBACK,
287
+ _TOKENLESS_LOCAL_LIB,
288
+ _TOKENLESS_LOCAL_SHARE,
289
+ )
290
+ from hook_utils import SHELL_TOOLS as _SHELL_TOOLS_SHARED
291
+ from hook_utils import SKIP_TOOLS as _SKIP_TOOLS_SHARED
292
+ from hook_utils import (
293
+ build_post_tool_request,
294
+ build_pre_tool_request,
295
+ is_tokenless_retrieve_command,
162
296
  resolve_binary,
163
- warn as _warn_shared,
164
- try_parse_json as _try_parse_json,
165
- is_skill_file as _is_skill_file,
166
- write_context as _write_context,
167
- run as _run,
168
- parse_version as _parse_version,
169
- SKIP_TOOLS as _SKIP_TOOLS_SHARED,
170
- SHELL_TOOLS as _SHELL_TOOLS_SHARED,
171
- get_thresholds,
297
+ run_compress,
298
+ tokenless_retrieve_command_available,
172
299
  )
173
300
 
174
301
  logger = logging.getLogger(__name__)
@@ -178,26 +305,17 @@ logger = logging.getLogger(__name__)
178
305
  # ---------------------------------------------------------------------------
179
306
 
180
307
  AGENT_ID = "hermes-agent"
181
- _MIN_RESPONSE_LEN = 200
182
-
183
- # Minimum payload size for the TOON encoding step. TOON on small JSON
184
- # saves only a few characters (observed ~0.3% below ~500 chars) while
185
- # the per-event encode cost stays the same, so payloads under this
186
- # threshold keep the response-compressed form and skip TOON entirely.
187
- # Mirrors tokenless-runtime's MIN_TOON_CHARS, the default the
188
- # compress-toon CLI applies; keep the two values in sync.
189
- _MIN_TOON_CHARS = 500
308
+ _COMPRESS_TIMEOUT_SECONDS = 8
190
309
 
191
310
  _SKIP_TOOLS: set[str] = _SKIP_TOOLS_SHARED | {
192
- "session_search", "list_sessions",
311
+ "session_search",
312
+ "list_sessions",
193
313
  }
194
314
 
195
315
  # Use shared SHELL_TOOLS directly - all tools (including "terminal") are now
196
316
  # defined in the unified tool_categories.json
197
317
  _SHELL_TOOLS: set[str] = _SHELL_TOOLS_SHARED
198
318
 
199
- _MIN_RTK_VERSION = (0, 35, 0)
200
-
201
319
  # ---------------------------------------------------------------------------
202
320
  # Binary resolution (thin wrapper over shared cached resolve_binary)
203
321
  # ---------------------------------------------------------------------------
@@ -210,7 +328,14 @@ def _resolve_binary(name: str, fallback: str) -> str | None:
210
328
  """Resolve binary with hermes-specific fallback paths (cached via shared)."""
211
329
  local_bin = os.path.join(os.path.expanduser("~"), ".local", "bin", name)
212
330
  if name == "rtk":
213
- return resolve_binary(name, fallback, _RTK_LIB_FALLBACK, local_bin, _RTK_LOCAL_LIB, _RTK_LOCAL_SHARE)
331
+ return resolve_binary(
332
+ name,
333
+ fallback,
334
+ _RTK_LIB_FALLBACK,
335
+ local_bin,
336
+ _RTK_LOCAL_LIB,
337
+ _RTK_LOCAL_SHARE,
338
+ )
214
339
  return resolve_binary(name, fallback, local_bin, _TOKENLESS_LOCAL_LIB, _TOKENLESS_LOCAL_SHARE)
215
340
 
216
341
 
@@ -218,211 +343,60 @@ def _have(name: str, fallback: str) -> bool:
218
343
  return _resolve_binary(name, fallback) is not None
219
344
 
220
345
 
221
- # ---------------------------------------------------------------------------
222
- # Helpers (shared via hook_utils)
223
- # ---------------------------------------------------------------------------
224
-
225
-
226
- # ---------------------------------------------------------------------------
227
- # 1. Response Compression (via tokenless compress-response)
228
- # ---------------------------------------------------------------------------
229
-
230
-
231
- def _compress_response(
232
- tool_name: str,
233
- result: str,
234
- session_id: str,
235
- tool_call_id: str,
236
- ) -> str | None:
237
- tokenless_bin = _resolve_binary("tokenless", _TOKENLESS_FALLBACK)
238
- if not tokenless_bin:
239
- return None
240
-
241
- parsed = _try_parse_json(result)
242
- if not isinstance(parsed, (dict, list)):
243
- return None
244
-
245
- # 3-layer dispatch: shell tools use moderate truncation, API tools use zero-truncation
246
- thresholds = get_thresholds(tool_name)
247
-
248
- cmd = [
249
- tokenless_bin, "compress-response",
250
- "--agent-id", AGENT_ID,
251
- "--truncate-strings-at", str(thresholds[0]),
252
- "--truncate-arrays-at", str(thresholds[1]),
253
- "--max-depth", str(thresholds[2]),
254
- ]
255
- if session_id:
256
- cmd.extend(["--session-id", session_id])
257
- if tool_call_id:
258
- cmd.extend(["--tool-use-id", tool_call_id])
259
-
260
- proc = _run(cmd, result)
261
- if not proc or proc.returncode != 0 or not proc.stdout.strip():
262
- return None
263
-
264
- compressed = proc.stdout.strip()
265
- if len(compressed) >= len(result):
266
- return None
267
- return compressed
268
-
269
-
270
- # ---------------------------------------------------------------------------
271
- # 2. TOON Encoding (via tokenless compress-toon)
272
- # ---------------------------------------------------------------------------
273
-
274
-
275
- def _encode_toon(data: str, session_id: str = "", tool_call_id: str = "") -> tuple[str, int] | None:
276
- tokenless_bin = _resolve_binary("tokenless", _TOKENLESS_FALLBACK)
277
- if not tokenless_bin:
278
- return None
279
-
280
- parsed = _try_parse_json(data)
281
- if not isinstance(parsed, (dict, list)):
282
- return None
283
-
284
- cmd = [tokenless_bin, "compress-toon", "--agent-id", AGENT_ID]
285
- if session_id:
286
- cmd.extend(["--session-id", session_id])
287
- if tool_call_id:
288
- cmd.extend(["--tool-use-id", tool_call_id])
289
-
290
- proc = _run(cmd, data, timeout=1)
291
- if not proc or proc.returncode != 0 or not proc.stdout.strip():
292
- return None
293
-
294
- toon_text = proc.stdout.strip()
295
- if len(toon_text) >= len(data):
296
- return None
297
-
298
- savings_pct = 0
299
- if len(data) > 0:
300
- savings_pct = (len(data) - len(toon_text)) * 100 // len(data)
301
-
302
- return toon_text, savings_pct
303
-
304
-
305
- # ---------------------------------------------------------------------------
306
- # 3. Tool Ready (via tokenless env-check)
307
- # ---------------------------------------------------------------------------
308
-
309
-
310
- def _env_check(tool_name: str) -> str | None:
311
- """Run tool-ready env-check and return feedback if tool is not ready."""
312
- tokenless_bin = _resolve_binary("tokenless", _TOKENLESS_FALLBACK)
313
- if not tokenless_bin:
314
- return None
315
-
316
- proc = _run([tokenless_bin, "env-check", "--tool", tool_name, "--json"], "", timeout=5)
317
- if not proc or not proc.stdout.strip():
346
+ def _protocol_status(status: Any, result: str) -> str | None:
347
+ """Map Hermes status, deriving it for hosts that omit the field."""
348
+ if isinstance(status, str) and status:
349
+ return {
350
+ "ok": "success",
351
+ "success": "success",
352
+ "error": "error",
353
+ "blocked": "denied",
354
+ "denied": "denied",
355
+ "interrupted": "interrupted",
356
+ }.get(status.lower())
357
+ if status not in (None, ""):
318
358
  return None
319
-
320
- try:
321
- parsed = json.loads(proc.stdout)
322
- except json.JSONDecodeError:
323
- return None
324
-
325
- status = parsed.get("status", "UNKNOWN")
326
- if status in ("UNKNOWN", "READY"):
327
- return None
328
-
329
- # Attempt auto-fix
330
- proc = _run([tokenless_bin, "env-check", "--tool", tool_name, "--fix", "--json"], "", timeout=10)
331
- if not proc or not proc.stdout.strip():
332
- return _not_ready_msg(tool_name)
333
-
334
359
  try:
335
- fix_parsed = json.loads(proc.stdout)
360
+ parsed = json.loads(result)
336
361
  except json.JSONDecodeError:
337
- return _not_ready_msg(tool_name)
338
-
339
- if fix_parsed.get("status") == "READY":
340
- return None
341
-
342
- diagnostic = fix_parsed.get("diagnostic", "")
343
- return diagnostic or _not_ready_msg(tool_name)
344
-
345
-
346
- def _not_ready_msg(tool_name: str) -> str:
347
- return f"[tokenless:ready] {tool_name}: NOT_READY. Skip retry."
362
+ return "success"
363
+ return "error" if isinstance(parsed, dict) and parsed.get("error") else "success"
348
364
 
349
365
 
350
- # ---------------------------------------------------------------------------
351
- # 4. Command Rewriting (via rtk rewrite)
352
- # ---------------------------------------------------------------------------
353
-
354
-
355
- def _try_rewrite(
356
- args: Any,
357
- session_id: str,
358
- tool_call_id: str,
359
- ) -> dict[str, str] | None:
360
- """Attempt RTK command rewrite for terminal tool calls.
366
+ def _content_origin(tool_name: str) -> str:
367
+ if tool_name in _SKIP_TOOLS:
368
+ return "file_content"
369
+ if tool_name in _SHELL_TOOLS:
370
+ return "command_output"
371
+ return "api_response"
361
372
 
362
- Calls ``rtk rewrite <command>`` — a pure text substitution that never
363
- executes the command. On success, returns a block directive suggesting
364
- the rewritten command so the agent re-executes with the optimized version.
365
- """
366
- rtk_bin = _resolve_binary("rtk", _RTK_FALLBACK)
367
- if not rtk_bin:
368
- return None
369
373
 
374
+ def _output_optimization(args: Any) -> str:
375
+ """Recognize the attributed RTK wrapper in the command Hermes executed."""
370
376
  if not isinstance(args, dict):
371
- return None
372
-
373
- command = args.get("command", "")
374
- if not command:
375
- return None
376
-
377
- # Version guard — non-fatal
377
+ return "none"
378
+ command = args.get("command")
379
+ if not isinstance(command, str):
380
+ return "none"
378
381
  try:
379
- ver_proc = subprocess.run(
380
- [rtk_bin, "--version"], capture_output=True, text=True, timeout=3,
381
- )
382
- ver = _parse_version(ver_proc.stdout)
383
- if ver and ver < _MIN_RTK_VERSION:
384
- logger.warning("tokenless: rtk %s too old (need >= 0.35.0), rewrite skipped", ver_proc.stdout.strip())
385
- return None
386
- except Exception:
387
- pass
388
-
389
- # Write context file so rtk (running as proxy later) can recover IDs
390
- _write_context(AGENT_ID, session_id, tool_call_id)
391
-
392
- # Set env vars for rtk stats context
393
- env = os.environ.copy()
394
- env["TOKENLESS_AGENT_ID"] = AGENT_ID
395
- if session_id:
396
- env["TOKENLESS_SESSION_ID"] = session_id
397
- if tool_call_id:
398
- env["TOKENLESS_TOOL_USE_ID"] = tool_call_id
399
-
400
- proc = subprocess.run(
401
- [rtk_bin, "rewrite", command],
402
- capture_output=True, text=True, timeout=5, env=env,
403
- )
404
-
405
- # Exit code protocol (from rtk rewrite_cmd.rs):
406
- # 0 = rewrite available, Allow verdict (auto-allow by permission rule)
407
- # 1 = no RTK equivalent (passthrough)
408
- # 2 = deny rule matched (let Hermes handle)
409
- # 3 = Ask/Default verdict (rewrite available but permission model requires
410
- # user confirmation; in non-interactive hook context, treat as valid
411
- # rewrite since the intent is token optimization, not permission gating)
412
- if proc.returncode == 1 or proc.returncode == 2:
413
- return None
414
- if proc.returncode != 0 and proc.returncode != 3:
415
- return None
416
-
417
- rewritten = proc.stdout.strip()
418
- if not rewritten or rewritten == command:
419
- return None
420
-
421
- logger.info("tokenless: rtk rewrite %s → %s", command, rewritten)
422
- return {
423
- "action": "block",
424
- "message": f"[tokenless:rewrite] Re-execute as: {rewritten}",
425
- }
382
+ lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
383
+ lexer.whitespace_split = True
384
+ tokens = list(lexer)
385
+ except ValueError:
386
+ return "none"
387
+ for index, token in enumerate(tokens):
388
+ wrapper = tokens[index : index + 6]
389
+ if len(wrapper) < 6 or token != "env":
390
+ continue
391
+ if (
392
+ wrapper[1] == f"TOKENLESS_AGENT_ID={AGENT_ID}"
393
+ and wrapper[2].startswith("TOKENLESS_SESSION_ID=")
394
+ and wrapper[3].startswith("TOKENLESS_TOOL_USE_ID=")
395
+ and wrapper[4].startswith("TOKENLESS_DATA_DIR=")
396
+ and os.path.basename(wrapper[5]) == "rtk"
397
+ ):
398
+ return "rtk"
399
+ return "none"
426
400
 
427
401
 
428
402
  # ---------------------------------------------------------------------------
@@ -446,28 +420,39 @@ def on_pre_tool_call(
446
420
  tool_call_id: str = "",
447
421
  **kwargs: Any,
448
422
  ) -> dict[str, str] | None:
449
- """Tool Ready + RTK rewrite pre-check.
450
-
451
- Step 1: env-check blocks when the tool's environment is not ready.
452
- Step 2: for ``terminal`` calls, blocks and suggests RTK-rewritten
453
- command (one extra round-trip; safe — rtk rewrite never executes).
454
- """
455
- # Step 1: env-check (all tools, needs tokenless)
456
- if _have("tokenless", _TOKENLESS_FALLBACK):
457
- if session_id:
458
- os.environ["TOKENLESS_SESSION_ID"] = str(session_id)
459
- feedback = _env_check(tool_name)
460
- if feedback:
461
- logger.info("tokenless: tool-ready blocking %s — %s", tool_name, feedback)
462
- return {"action": "block", "message": feedback}
463
-
464
- # Step 2: RTK rewrite (terminal only, needs rtk)
465
- if tool_name in _SHELL_TOOLS and _have("rtk", _RTK_FALLBACK):
466
- result = _try_rewrite(args, str(session_id), str(tool_call_id))
467
- if result:
468
- return result
469
-
470
- return None
423
+ """Ask Core for an RTK rewrite and translate it to a Hermes block."""
424
+ if tool_name not in _SHELL_TOOLS or not isinstance(args, dict):
425
+ return None
426
+ tokenless_bin = _resolve_binary("tokenless", _TOKENLESS_FALLBACK)
427
+ if not tokenless_bin:
428
+ return None
429
+ request = build_pre_tool_request(
430
+ args,
431
+ AGENT_ID,
432
+ tool_name,
433
+ "command",
434
+ str(session_id),
435
+ str(tool_call_id),
436
+ replace_arguments=False,
437
+ block_and_suggest=True,
438
+ )
439
+ result = run_compress(tokenless_bin, request, _COMPRESS_TIMEOUT_SECONDS, "pre_tool")
440
+ if not isinstance(result, dict):
441
+ return None
442
+ rewritten_args = result.get("arguments")
443
+ rewritten = rewritten_args.get("command") if isinstance(rewritten_args, dict) else None
444
+ if (
445
+ result.get("action") != "block_and_suggest"
446
+ or result.get("output_optimization") != "rtk"
447
+ or not isinstance(rewritten, str)
448
+ or rewritten == args.get("command")
449
+ ):
450
+ return None
451
+ logger.info("tokenless: Core rewrote %s", tool_name)
452
+ return {
453
+ "action": "block",
454
+ "message": f"[tokenless:rewrite] Re-execute as: {rewritten}",
455
+ }
471
456
 
472
457
 
473
458
  def on_transform_tool_result(
@@ -478,83 +463,81 @@ def on_transform_tool_result(
478
463
  session_id: str = "",
479
464
  tool_call_id: str = "",
480
465
  duration_ms: int = 0,
466
+ status: str = "",
481
467
  **kwargs: Any,
482
468
  ) -> str | None:
483
- """Response compression + TOON encoding pipeline.
484
-
485
- Replaces the tool result string with a compressed/TOON-encoded version.
486
- Runs after post_tool_call; first valid string return wins.
487
-
488
- Content retrieval tools (Read/Glob/Grep) are skipped entirely.
489
- Shell/exec tools (Bash/Shell) use moderate truncation (64K/128/8).
490
- All other tools use zero-truncation compress-response + TOON.
491
- """
492
- if not _have("tokenless", _TOKENLESS_FALLBACK):
469
+ """Send one final Hermes result to Core and apply its disposition."""
470
+ if not isinstance(result, str):
493
471
  return None
494
-
495
- # Content retrieval — skip entirely (preserve integrity)
496
- if tool_name in _SKIP_TOOLS:
497
- return None
498
-
499
- if not result or result in ("{}", "[]"):
472
+ protocol_status = _protocol_status(status, result)
473
+ if protocol_status is None:
500
474
  return None
501
-
502
- # Skip skill files (YAML frontmatter)
503
- if _is_skill_file(result):
475
+ tokenless_bin = _resolve_binary("tokenless", _TOKENLESS_FALLBACK)
476
+ if not tokenless_bin:
504
477
  return None
478
+ output_optimization = _output_optimization(args)
479
+ retrieve_result = protocol_status == "success" and is_tokenless_retrieve_command(
480
+ tool_name, args
481
+ )
505
482
 
506
- # Skip small responses
507
- if len(result) < _MIN_RESPONSE_LEN:
483
+ # Hermes's terminal tool returns a JSON envelope whose `output` field is
484
+ # the model-visible command output. Compress that field so structured JSON
485
+ # produced by the command remains visible to JsonCompressor, then restore
486
+ # the host envelope below. Other tools already expose their model-bound
487
+ # result directly and must keep the existing path.
488
+ shell_envelope = None
489
+ content = result
490
+ if tool_name in _SHELL_TOOLS:
491
+ try:
492
+ parsed_result = json.loads(result)
493
+ except json.JSONDecodeError:
494
+ parsed_result = None
495
+ if isinstance(parsed_result, dict) and isinstance(parsed_result.get("output"), str):
496
+ shell_envelope = parsed_result
497
+ content = parsed_result["output"]
498
+
499
+ request = build_post_tool_request(
500
+ content,
501
+ AGENT_ID,
502
+ tool_name,
503
+ protocol_status,
504
+ _content_origin(tool_name),
505
+ output_optimization,
506
+ result_kind="retrieve" if retrieve_result else "tool",
507
+ recovery={
508
+ "kind": (
509
+ "shell"
510
+ if (
511
+ protocol_status == "success"
512
+ and output_optimization == "none"
513
+ and not retrieve_result
514
+ and tokenless_retrieve_command_available()
515
+ )
516
+ else "none"
517
+ )
518
+ },
519
+ session_id=str(session_id),
520
+ tool_use_id=str(tool_call_id),
521
+ replace_output=True,
522
+ replace_with_text=True,
523
+ )
524
+ response = run_compress(tokenless_bin, request, _COMPRESS_TIMEOUT_SECONDS, "post_tool")
525
+ if not isinstance(response, dict):
508
526
  return None
509
-
510
- # Validate it's JSON
511
- parsed = _try_parse_json(result)
512
- if parsed is None:
527
+ if response.get("disposition") == "applied":
528
+ output = response.get("output")
529
+ if isinstance(output, str):
530
+ logger.info("tokenless: Core optimized %s", tool_name)
531
+ if shell_envelope is not None:
532
+ shell_envelope["output"] = output
533
+ return json.dumps(shell_envelope, ensure_ascii=False)
534
+ return output
513
535
  return None
514
-
515
- # Normalize: result is already a JSON string (Hermes tool contract)
516
- original = result
517
- original_len = len(original)
518
-
519
- # Step 1: Response compression (per-layer thresholds via get_thresholds)
520
- compressed = _compress_response(tool_name, result,
521
- str(session_id), str(tool_call_id))
522
- current = compressed if compressed else result
523
-
524
- # Step 2: TOON encoding — only for payloads at or above the minimum
525
- # threshold; small JSON gains near-zero chars from TOON but would still
526
- # pay the full encode cost on every tool result.
527
- toon_result = None
528
- if len(current) >= _MIN_TOON_CHARS:
529
- toon_result = _encode_toon(current, str(session_id), str(tool_call_id))
530
- used_compression = compressed is not None
531
- used_toon = toon_result is not None
532
-
533
- if not used_compression and not used_toon:
534
- return None
535
-
536
- # Build final output
537
- if used_toon:
538
- toon_text, savings_pct = toon_result
539
- final = toon_text
540
- final_len = len(toon_text)
541
- savings_label = (
542
- "response compressed + TOON encoded"
543
- if used_compression
544
- else "TOON encoded"
545
- )
546
- else:
547
- final = current # type: ignore[assignment]
548
- final_len = len(final)
549
- savings_pct = (original_len - final_len) * 100 // original_len if original_len else 0
550
- savings_label = "response compressed"
551
-
552
- logger.info(
553
- "tokenless: %s %s: %d -> %d chars (%d%% reduction)",
554
- savings_label, tool_name, original_len, final_len, savings_pct,
555
- )
556
-
557
- return final
536
+ if response.get("disposition") == "tool_error":
537
+ additional_context = response.get("additional_context")
538
+ if isinstance(additional_context, str) and additional_context:
539
+ return f"{result}\n\n{additional_context}"
540
+ return None
558
541
 
559
542
 
560
543
  # ---------------------------------------------------------------------------
@@ -569,16 +552,8 @@ def register(ctx: Any) -> None:
569
552
  ctx.register_hook("pre_tool_call", on_pre_tool_call)
570
553
  ctx.register_hook("transform_tool_result", on_transform_tool_result)
571
554
 
572
- # Log what's active
573
- features: list[str] = []
574
- if _have("tokenless", _TOKENLESS_FALLBACK):
575
- features.append("response-compression")
576
- features.append("toon-encoding")
577
- features.append("tool-ready")
578
- if _have("rtk", _RTK_FALLBACK):
579
- features.append("rtk-rewrite")
580
-
555
+ features = ["pre-tool", "post-tool"] if _have("tokenless", _TOKENLESS_FALLBACK) else []
581
556
  logger.info(
582
557
  "tokenless: Hermes plugin registered — active features: %s",
583
- ", ".join(features) if features else "none (install tokenless/rtk binary)",
558
+ ", ".join(features) if features else "none (install tokenless binary)",
584
559
  )