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