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,720 @@
1
+ /**
2
+ * Token-Less Unified Plugin for OpenClaw v5
3
+ *
4
+ * Combines multiple complementary optimisation strategies into a single plugin:
5
+ *
6
+ * 1. RTK command rewriting — transparently rewrites exec tool commands to
7
+ * their RTK equivalents (delegated to `rtk rewrite`).
8
+ * 2. Tokenless response compression — compresses tool responses via
9
+ * `tokenless compress-response` (removes debug/null/empty values).
10
+ * 3. TOON context compression — encodes JSON tool responses to TOON format
11
+ * via `tokenless compress-toon`, reducing token usage for structured data. When both
12
+ * response and TOON compression are enabled, they run sequentially:
13
+ * Response Compression strips noise → TOON eliminates JSON format overhead.
14
+ *
15
+ * Stats are recorded automatically by tokenless compress-response.
16
+ * RTK rewrite and proxy processes receive the same per-call context snapshot;
17
+ * the rewrite-context file remains a compatibility fallback for launch paths
18
+ * that do not preserve exec environment overrides.
19
+ */
20
+
21
+ import { execFileSync, spawnSync } from "node:child_process";
22
+ import {
23
+ closeSync,
24
+ constants,
25
+ existsSync,
26
+ fchmodSync,
27
+ lstatSync,
28
+ mkdirSync,
29
+ openSync,
30
+ readFileSync,
31
+ statSync,
32
+ writeFileSync,
33
+ } from "node:fs";
34
+ import { delimiter, isAbsolute, join } from "node:path";
35
+
36
+ // ---- Session ID mapping --------------------------------------------------------
37
+ // OpenClaw's tool_result_persist ctx provides sessionKey ("agent:main:main")
38
+ // but NOT sessionId (UUID). We maintain a sessionKey → sessionId map built
39
+ // from session_start events so response compression can use the correct UUID.
40
+
41
+ const sessionMap: Map<string, string> = new Map();
42
+
43
+ // ---- In-memory env context (replaces global process.env mutation) -------------
44
+
45
+ interface TokenlessCallContext {
46
+ agentId: string;
47
+ sessionId: string;
48
+ toolCallId: string;
49
+ }
50
+
51
+ const envContext: TokenlessCallContext = {
52
+ agentId: "openclaw", sessionId: "", toolCallId: "",
53
+ };
54
+
55
+ function buildEnv(context: TokenlessCallContext = envContext): Record<string, string> {
56
+ return {
57
+ ...process.env as Record<string, string>,
58
+ ...buildContextEnv(context),
59
+ };
60
+ }
61
+
62
+ function buildContextEnv(context: TokenlessCallContext): Record<string, string> {
63
+ return {
64
+ TOKENLESS_AGENT_ID: context.agentId,
65
+ TOKENLESS_SESSION_ID: context.sessionId,
66
+ TOKENLESS_TOOL_USE_ID: context.toolCallId,
67
+ };
68
+ }
69
+
70
+ function mergeExecContextEnv(
71
+ params: Record<string, unknown>,
72
+ context: TokenlessCallContext,
73
+ ): Record<string, string> {
74
+ const existingEnv = params.env;
75
+ const normalizedEnv = typeof existingEnv === "object"
76
+ && existingEnv !== null
77
+ && !Array.isArray(existingEnv)
78
+ ? existingEnv as Record<string, string>
79
+ : {};
80
+
81
+ return {
82
+ ...normalizedEnv,
83
+ ...buildContextEnv(context),
84
+ };
85
+ }
86
+
87
+ // ---- Binary availability cache (with TTL for negative results) -----------------
88
+
89
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes — retry after auto-fix installs
90
+
91
+ let rtkAvailable: boolean | null = null;
92
+ let rtkCheckedAt: number | null = null;
93
+ let tokenlessAvailable: boolean | null = null;
94
+ let tokenlessCheckedAt: number | null = null;
95
+
96
+ // Resolved absolute paths — set by check*() functions so subprocess calls
97
+ // use the correct path even when the binary is not on PATH.
98
+ let rtkPath: string = "rtk";
99
+ let tokenlessPath: string = "tokenless";
100
+
101
+ // KEEP IN SYNC with common/hooks/hook_utils.py, tool_ready_hook.sh,
102
+ // env_check.rs::binary_fallback_paths, and the Codex standalone scripts.
103
+ // Makefile and the Anolisa component manifest define the supported layouts;
104
+ // the canonical order is user, /usr/local, /usr, then legacy.
105
+ const LIBEXEC_FALLBACK = "/usr/libexec/anolisa/tokenless";
106
+ const LIB_FALLBACK = "/usr/lib/anolisa/tokenless";
107
+ const TOKENLESS_FALLBACK = "/usr/bin/tokenless";
108
+ const SYSTEM_BIN = "/usr/local/bin";
109
+ const SYSTEM_LIBEXEC = "/usr/local/libexec/anolisa/tokenless";
110
+ const RPM_BIN = "/usr/bin";
111
+ const USER_HOME = process.env.HOME && isAbsolute(process.env.HOME) ? process.env.HOME : null;
112
+ const REWRITE_CONTEXT_DIR = USER_HOME ? join(USER_HOME, ".tokenless") : null;
113
+ const REWRITE_CONTEXT_FILE = REWRITE_CONTEXT_DIR
114
+ ? join(REWRITE_CONTEXT_DIR, ".rewrite-context")
115
+ : null;
116
+ const LOCAL_BIN = USER_HOME ? join(USER_HOME, ".local", "bin") : null;
117
+ const LOCAL_ANOLISA_LIBEXEC = USER_HOME
118
+ ? join(USER_HOME, ".local", "lib", "anolisa", "libexec", "tokenless")
119
+ : null;
120
+ const LOCAL_MAKE_LIBEXEC = USER_HOME
121
+ ? join(USER_HOME, ".local", "libexec", "anolisa", "tokenless")
122
+ : null;
123
+ const LOCAL_LIB = USER_HOME
124
+ ? join(USER_HOME, ".local", "lib", "anolisa", "tokenless")
125
+ : null;
126
+ const LOCAL_FALLBACK = USER_HOME
127
+ ? join(USER_HOME, ".local", "share", "anolisa", "tokenless")
128
+ : null;
129
+
130
+ function binaryIn(directory: string | null, name: string): string {
131
+ return directory ? join(directory, name) : "";
132
+ }
133
+
134
+ // Check both existence and execute permission (mirrors shell `-x` test).
135
+ function isExecutable(path: string): boolean {
136
+ try {
137
+ return existsSync(path) && (statSync(path).mode & 0o111) !== 0;
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ function resolveBinaryPath(name: string, ...fallbacks: string[]): string | null {
144
+ // Search PATH directories without spawning a shell (mirrors headroom
145
+ // and agent-memory plugins). Avoids `sh -c "command -v"` so the
146
+ // only child_process calls are direct binary invocations with fixed
147
+ // paths — no shell interpolation vector exists.
148
+ const pathEnv = process.env.PATH || "";
149
+ for (const dir of pathEnv.split(delimiter)) {
150
+ if (!dir) continue;
151
+ const candidate = join(dir, name);
152
+ if (existsSync(candidate) && isExecutable(candidate)) {
153
+ return candidate;
154
+ }
155
+ }
156
+ // Fall back to known locations.
157
+ for (const fb of fallbacks) {
158
+ if (fb && isExecutable(fb)) return fb;
159
+ }
160
+ return null;
161
+ }
162
+
163
+ function checkRtk(): boolean {
164
+ // Refresh BOTH true and false cache once stale: a binary that was
165
+ // present at first check can disappear (manual uninstall, FS error,
166
+ // overlay swap) and a previously-missing binary can be installed by
167
+ // auto-fix. Asymmetric TTL would either keep using a vanished path
168
+ // (stale true) or never re-check after install (stale false).
169
+ if (rtkAvailable !== null && rtkCheckedAt && (Date.now() - rtkCheckedAt > CACHE_TTL_MS)) {
170
+ rtkAvailable = null;
171
+ }
172
+ if (rtkAvailable !== null) return rtkAvailable;
173
+ const resolved = resolveBinaryPath(
174
+ "rtk",
175
+ binaryIn(LOCAL_BIN, "rtk"),
176
+ binaryIn(LOCAL_ANOLISA_LIBEXEC, "rtk"),
177
+ binaryIn(LOCAL_MAKE_LIBEXEC, "rtk"),
178
+ join(SYSTEM_BIN, "rtk"),
179
+ join(SYSTEM_LIBEXEC, "rtk"),
180
+ join(RPM_BIN, "rtk"),
181
+ join(LIBEXEC_FALLBACK, "rtk"),
182
+ join(LIB_FALLBACK, "rtk"),
183
+ binaryIn(LOCAL_FALLBACK, "rtk"),
184
+ binaryIn(LOCAL_LIB, "rtk"),
185
+ );
186
+ if (resolved) { rtkPath = resolved; rtkAvailable = true; }
187
+ else { rtkAvailable = false; }
188
+ rtkCheckedAt = Date.now();
189
+ return rtkAvailable;
190
+ }
191
+
192
+ function isSkillContent(message: any): boolean {
193
+ // Skill files (.md with YAML frontmatter) must not be compressed because
194
+ // truncation would break the skill metadata and make agent skills unusable.
195
+ if (typeof message !== "string") return false;
196
+ const trimmed = message.trimStart();
197
+ if (!trimmed.startsWith("---")) return false;
198
+ // Check the first few lines for typical skill metadata fields
199
+ const firstLines = trimmed.split("\n", 20).join("\n");
200
+ return /^name:/m.test(firstLines) || /^description:/m.test(firstLines);
201
+ }
202
+
203
+ function checkTokenless(): boolean {
204
+ // Refresh BOTH true and false cache once stale (see checkRtk for rationale).
205
+ if (tokenlessAvailable !== null && tokenlessCheckedAt && (Date.now() - tokenlessCheckedAt > CACHE_TTL_MS)) {
206
+ tokenlessAvailable = null;
207
+ }
208
+ if (tokenlessAvailable !== null) return tokenlessAvailable;
209
+ const resolved = resolveBinaryPath(
210
+ "tokenless",
211
+ binaryIn(LOCAL_BIN, "tokenless"),
212
+ join(SYSTEM_BIN, "tokenless"),
213
+ TOKENLESS_FALLBACK,
214
+ binaryIn(LOCAL_FALLBACK, "tokenless"),
215
+ binaryIn(LOCAL_LIB, "tokenless"),
216
+ );
217
+ if (resolved) { tokenlessPath = resolved; tokenlessAvailable = true; }
218
+ else { tokenlessAvailable = false; }
219
+ tokenlessCheckedAt = Date.now();
220
+ return tokenlessAvailable;
221
+ }
222
+
223
+ // ---- Subprocess helpers -------------------------------------------------------
224
+
225
+ function tryRtkRewrite(command: string, context: TokenlessCallContext): string | null {
226
+ try {
227
+ const result = spawnSync(rtkPath, ["rewrite", command], {
228
+ encoding: "utf-8",
229
+ timeout: 2000,
230
+ stdio: ["ignore", "pipe", "pipe"],
231
+ env: buildEnv(context),
232
+ });
233
+ const rewritten = result.stdout?.trim();
234
+ // Exit code protocol (from rtk rewrite_cmd.rs):
235
+ // 0 = rewrite available, Allow verdict (auto-allow by permission rule)
236
+ // 1 = no RTK equivalent (passthrough)
237
+ // 2 = deny rule matched (let agent handle)
238
+ // 3 = Ask/Default verdict (rewrite available but permission model requires
239
+ // user confirmation; in non-interactive hook context, treat as valid
240
+ // rewrite since the intent is token optimization, not permission gating)
241
+ if ((result.status === 0 || result.status === 3) && rewritten && rewritten !== command) {
242
+ return rewritten;
243
+ }
244
+ return null;
245
+ } catch {
246
+ return null;
247
+ }
248
+ }
249
+
250
+ function writeRewriteContext(context: TokenlessCallContext): void {
251
+ if (!REWRITE_CONTEXT_DIR || !REWRITE_CONTEXT_FILE) {
252
+ console.warn("[tokenless:rtk] cannot persist rewrite context: HOME is unavailable");
253
+ return;
254
+ }
255
+
256
+ let fd: number | null = null;
257
+ try {
258
+ mkdirSync(REWRITE_CONTEXT_DIR, { recursive: true, mode: 0o700 });
259
+ // O_NOFOLLOW protects only the final component, so reject a symlinked parent.
260
+ if (!lstatSync(REWRITE_CONTEXT_DIR).isDirectory()) {
261
+ throw new Error(`rewrite context directory is not a directory: ${REWRITE_CONTEXT_DIR}`);
262
+ }
263
+ fd = openSync(
264
+ REWRITE_CONTEXT_FILE,
265
+ constants.O_WRONLY
266
+ | constants.O_CREAT
267
+ | constants.O_TRUNC
268
+ | constants.O_NOFOLLOW,
269
+ 0o600,
270
+ );
271
+ // The open mode protects new files; fchmod also tightens an existing file.
272
+ fchmodSync(fd, 0o600);
273
+ writeFileSync(
274
+ fd,
275
+ `${context.agentId}\n${context.sessionId}\n${context.toolCallId}\n`,
276
+ "utf-8",
277
+ );
278
+ } catch (error) {
279
+ const code = (error as NodeJS.ErrnoException)?.code;
280
+ const suffix = code ? ` (${code})` : "";
281
+ console.warn(`[tokenless:rtk] cannot persist rewrite context${suffix}`);
282
+ } finally {
283
+ if (fd !== null) {
284
+ try {
285
+ closeSync(fd);
286
+ } catch {
287
+ // The rewrite must proceed even if closing the fail-soft stats context fails.
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ function tryCompressResponse(response: any, sessionId?: string, toolCallId?: string, thresholds?: [number, number, number]): any | null {
294
+ try {
295
+ const input = JSON.stringify(response);
296
+ // 3-layer dispatch: thresholds vary by tool category.
297
+ // Shell/exec tools: moderate truncation (64K/128/8) — preserves 95% of real output
298
+ // API/structured tools: zero-truncation (1M/64K/32) — preserve content
299
+ const [truncateStringsAt, truncateArraysAt, maxDepth] = thresholds ?? [1048576, 65536, 32];
300
+ const args = [
301
+ "compress-response", "--agent-id", "openclaw",
302
+ "--truncate-strings-at", String(truncateStringsAt),
303
+ "--truncate-arrays-at", String(truncateArraysAt),
304
+ "--max-depth", String(maxDepth),
305
+ ];
306
+ if (sessionId) args.push("--session-id", sessionId);
307
+ if (toolCallId) args.push("--tool-use-id", toolCallId);
308
+ const result = execFileSync(tokenlessPath, args, {
309
+ encoding: "utf-8",
310
+ timeout: 3000,
311
+ input,
312
+ env: buildEnv(),
313
+ }).trim();
314
+
315
+ // Only return the compressed result if it is shorter than the input
316
+ if (result.length >= input.length) {
317
+ return null; // No actual compression occurred
318
+ }
319
+
320
+ return JSON.parse(result);
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
325
+
326
+ function tryCompressToon(response: any, sessionId?: string, toolCallId?: string): { toonText: string; savingsPct: number } | null {
327
+ try {
328
+ const input = JSON.stringify(response);
329
+ const beforeChars = input.length;
330
+ const args = ["compress-toon", "--agent-id", "openclaw"];
331
+ if (sessionId) args.push("--session-id", sessionId);
332
+ if (toolCallId) args.push("--tool-use-id", toolCallId);
333
+ const toonText = execFileSync(tokenlessPath, args, {
334
+ encoding: "utf-8",
335
+ timeout: 1000,
336
+ input,
337
+ env: buildEnv(),
338
+ }).trim();
339
+ if (!toonText || toonText.length >= beforeChars) return null;
340
+
341
+ const afterChars = toonText.length;
342
+ const savingsPct = beforeChars > 0 ? Math.round(((beforeChars - afterChars) / beforeChars) * 100) : 0;
343
+ return { toonText, savingsPct };
344
+ } catch {
345
+ return null;
346
+ }
347
+ }
348
+
349
+ function tryEnvCheck(toolName: string): { status: string; diagnostic: string } | null {
350
+ try {
351
+ const result = execFileSync(tokenlessPath, ["env-check", "--tool", toolName, "--json"], {
352
+ encoding: "utf-8",
353
+ timeout: 3000,
354
+ env: buildEnv(),
355
+ }).trim();
356
+ const parsed = JSON.parse(result);
357
+ const status: string = parsed.status || "UNKNOWN";
358
+
359
+ // Phase 1+2: UNKNOWN (not in dict) or READY → skip silently
360
+ if (status === "UNKNOWN" || status === "READY") return null;
361
+
362
+ // Phase 3: NOT_READY → attempt auto-fix
363
+ const fixResult = execFileSync(tokenlessPath, ["env-check", "--tool", toolName, "--fix", "--json"], {
364
+ encoding: "utf-8",
365
+ timeout: 10000,
366
+ env: buildEnv(),
367
+ }).trim();
368
+ const fixParsed = JSON.parse(fixResult);
369
+ const postStatus: string = fixParsed.status || "NOT_READY";
370
+
371
+ // Phase 3 success: fix worked → continue silently
372
+ if (postStatus === "READY") return null;
373
+
374
+ // Phase 4: Fix failed → feedback to Agent
375
+ const diagnostic: string = fixParsed.diagnostic
376
+ || `[tokenless:ready] ${toolName}: NOT_READY. Skip retry.`;
377
+ return { status: postStatus, diagnostic };
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+
383
+ // ---- Unified tool categorization ---------------------------------------------
384
+ // Load tool categories from tool_categories.json (single source of truth)
385
+ // This ensures consistency with Python hooks and tool-ready-spec.json
386
+
387
+ interface Thresholds {
388
+ truncate_strings_at: number;
389
+ truncate_arrays_at: number;
390
+ max_depth: number;
391
+ }
392
+
393
+ interface ToolCategories {
394
+ layer_1_skip: { tools: string[] };
395
+ layer_2_shell: { tools: string[]; thresholds?: Thresholds };
396
+ layer_3_api: { thresholds?: Thresholds };
397
+ }
398
+
399
+ // Hardcoded fallback tool sets — used only when tool_categories.json is missing
400
+ // or invalid. Mirrors Python hook_utils._FALLBACK_SKIP_TOOLS/_FALLBACK_SHELL_TOOLS
401
+ // to ensure consistent behavior across adapters even without the JSON file.
402
+ const FALLBACK_SKIP_TOOLS: string[] = [
403
+ "Read", "read", "read_file", "read_many_files",
404
+ "Glob", "glob", "search_file", "list_directory", "list_dir",
405
+ "Grep", "grep", "grep_code", "grep_search", "search_files",
406
+ "Lsp", "lsp",
407
+ "NotebookRead", "notebook_read", "notebookread",
408
+ ];
409
+ const FALLBACK_SHELL_TOOLS: string[] = [
410
+ "Bash", "bash", "Shell", "shell", "exec", "terminal",
411
+ "run_shell_command", "run_in_terminal", "get_terminal_output",
412
+ "execute_command", "process",
413
+ ];
414
+
415
+ function loadToolCategories(): ToolCategories {
416
+ const fallback: ToolCategories = {
417
+ layer_1_skip: { tools: FALLBACK_SKIP_TOOLS },
418
+ layer_2_shell: { tools: FALLBACK_SHELL_TOOLS },
419
+ layer_3_api: {},
420
+ };
421
+
422
+ try {
423
+ // Try multiple possible locations for tool_categories.json
424
+ const possiblePaths = [
425
+ join(import.meta.dirname, "tool_categories.json"),
426
+ join(import.meta.dirname, "..", "..", "common", "hooks", "tool_categories.json"),
427
+ join(import.meta.dirname, "common", "hooks", "tool_categories.json"),
428
+ "/usr/share/anolisa/adapters/tokenless/common/hooks/tool_categories.json",
429
+ "/usr/local/share/anolisa/adapters/tokenless/common/hooks/tool_categories.json",
430
+ ];
431
+
432
+ let content: string | null = null;
433
+ for (const path of possiblePaths) {
434
+ if (existsSync(path)) {
435
+ content = readFileSync(path, "utf-8");
436
+ break;
437
+ }
438
+ }
439
+
440
+ if (!content) {
441
+ console.warn("[tokenless] Could not find tool_categories.json, using hardcoded fallback categories");
442
+ return fallback;
443
+ }
444
+
445
+ const data = JSON.parse(content);
446
+
447
+ // Validate required structure
448
+ const requiredLayers = ["layer_1_skip", "layer_2_shell", "layer_3_api"];
449
+ for (const layer of requiredLayers) {
450
+ if (!(layer in data)) {
451
+ throw new Error(`Missing required layer: ${layer}`);
452
+ }
453
+ if (typeof data[layer] !== "object" || data[layer] === null) {
454
+ throw new Error(`Layer ${layer} must be an object`);
455
+ }
456
+ }
457
+ // layer_1 and layer_2 require a "tools" list; layer_3 is implicit
458
+ for (const layer of ["layer_1_skip", "layer_2_shell"]) {
459
+ if (!("tools" in data[layer])) {
460
+ throw new Error(`Layer ${layer} missing 'tools' field`);
461
+ }
462
+ if (!Array.isArray(data[layer].tools)) {
463
+ throw new Error(`Layer ${layer}.tools must be an array`);
464
+ }
465
+ }
466
+
467
+ return data as ToolCategories;
468
+ } catch (error) {
469
+ console.error("[tokenless] Failed to load tool_categories.json:", error);
470
+ return fallback;
471
+ }
472
+ }
473
+
474
+ // ---- Plugin entry point -------------------------------------------------------
475
+
476
+ export default {
477
+ id: "tokenless",
478
+ name: "Tokenless",
479
+ version: "1.0.0",
480
+ description: "Unified RTK command rewriting + response/TOON compression + hard-disabled Tool Ready",
481
+ register(api: any) {
482
+ const pluginConfig = api.config ?? {};
483
+ const rtkEnabled = pluginConfig.rtk_enabled !== false;
484
+ const responseCompressionEnabled = pluginConfig.response_compression_enabled !== false;
485
+ const toonCompressionEnabled = pluginConfig.toon_compression_enabled === true;
486
+ const toolReadyEnabled = pluginConfig.tool_ready_enabled !== false;
487
+
488
+ // Load unified tool categories from JSON (single source of truth)
489
+ const toolCategories = loadToolCategories();
490
+
491
+ // Layer 1: Skip all compression (preserve integrity for content retrieval)
492
+ // Use config override if provided and non-empty, otherwise use unified categories.
493
+ // NOTE: `??` alone is insufficient — openclaw may inject the schema default `[]`
494
+ // which is not nullish, so we also check `.length` to fall through to the JSON.
495
+ const skipTools: Set<string> = new Set(
496
+ (pluginConfig.skip_tools?.length ? pluginConfig.skip_tools : toolCategories.layer_1_skip.tools)
497
+ .map((t: string) => t.toLowerCase())
498
+ );
499
+
500
+ // Layer 2: Moderate truncation for shell/exec tools
501
+ // Use config override if provided and non-empty, otherwise use unified categories
502
+ const shellTools: Set<string> = new Set(
503
+ (pluginConfig.shell_tools?.length ? pluginConfig.shell_tools : toolCategories.layer_2_shell.tools)
504
+ .map((t: string) => t.toLowerCase())
505
+ );
506
+
507
+ // Thresholds are read from tool_categories.json (single source of truth).
508
+ // Hardcoded fallbacks match the JSON defaults.
509
+ // 64K strings: 95% of real shell output preserved (git diff ~63K, git log ~34K).
510
+ // 128 arrays: 95% of result sets preserved (test results, audit reports).
511
+ const shellThresholds: [number, number, number] = [
512
+ toolCategories.layer_2_shell.thresholds?.truncate_strings_at ?? 65536,
513
+ toolCategories.layer_2_shell.thresholds?.truncate_arrays_at ?? 128,
514
+ toolCategories.layer_2_shell.thresholds?.max_depth ?? 8,
515
+ ];
516
+ const apiThresholds: [number, number, number] = [
517
+ toolCategories.layer_3_api.thresholds?.truncate_strings_at ?? 1048576,
518
+ toolCategories.layer_3_api.thresholds?.truncate_arrays_at ?? 65536,
519
+ toolCategories.layer_3_api.thresholds?.max_depth ?? 32,
520
+ ];
521
+ const verbose = pluginConfig.verbose !== false;
522
+
523
+ // ---- 0. Session mapping (sessionKey → sessionId) ---------------------------
524
+
525
+ api.on(
526
+ "session_start",
527
+ (event: { sessionId: string; sessionKey?: string; resumedFrom?: string }) => {
528
+ if (event.sessionKey && event.sessionId) {
529
+ sessionMap.set(event.sessionKey, event.sessionId);
530
+ }
531
+ envContext.sessionId = event.sessionId;
532
+ },
533
+ );
534
+
535
+ // ---- 1. Registered hard-disabled Tool Ready hook (before_tool_call) ---------
536
+
537
+ if (toolReadyEnabled && checkTokenless()) {
538
+ api.on(
539
+ "before_tool_call",
540
+ (event: { toolName: string; params: Record<string, unknown> }, ctx: { sessionId?: string; sessionKey?: string; agentId?: string; toolCallId?: string; runId?: string }) => {
541
+ // Full 4-phase flow: Lookup → Check → Fix → Feedback
542
+ // Returns null for UNKNOWN/READY/post-fix-success (continue silently).
543
+ // Returns diagnostic only when fix fails (feedback to Agent).
544
+ const result = tryEnvCheck(event.toolName);
545
+ if (!result) return;
546
+
547
+ if (verbose) {
548
+ console.log(`[tokenless:ready] ${event.toolName}: ${result.status} — tool not available`);
549
+ }
550
+ return { contextPrefix: result.diagnostic };
551
+ },
552
+ { priority: 5 },
553
+ );
554
+ }
555
+
556
+ // ---- 2. RTK command rewriting (before_tool_call) ----------------------------
557
+
558
+ if (rtkEnabled && checkRtk()) {
559
+ api.on(
560
+ "before_tool_call",
561
+ (event: { toolName: string; params: Record<string, unknown> }, ctx: { sessionId?: string; sessionKey?: string; agentId?: string; toolCallId?: string; runId?: string }) => {
562
+ if (event.toolName !== "exec") return;
563
+
564
+ const command = event.params?.command;
565
+ if (typeof command !== "string") return;
566
+
567
+ // Snapshot each call so a missing ID never inherits the previous tool call.
568
+ const callContext: TokenlessCallContext = {
569
+ agentId: "openclaw",
570
+ sessionId: ctx?.sessionId
571
+ || (ctx?.sessionKey && sessionMap.get(ctx.sessionKey))
572
+ || "",
573
+ toolCallId: ctx?.toolCallId || "",
574
+ };
575
+ Object.assign(envContext, callContext);
576
+
577
+ const rewritten = tryRtkRewrite(command, callContext);
578
+ if (!rewritten) return;
579
+
580
+ // Keep the established file protocol for older launch paths. Current
581
+ // OpenClaw exec processes receive the same context directly below, so
582
+ // concurrent sessions do not depend on this last-write-wins fallback.
583
+ writeRewriteContext(callContext);
584
+
585
+ if (verbose) {
586
+ console.log(`[tokenless:rtk] rewrite: ${command} -> ${rewritten}`);
587
+ }
588
+
589
+ return {
590
+ params: {
591
+ ...event.params,
592
+ command: rewritten,
593
+ env: mergeExecContextEnv(event.params, callContext),
594
+ },
595
+ };
596
+ },
597
+ { priority: 10 },
598
+ );
599
+ }
600
+
601
+ // ---- 3. Response / TOON compression (tool_result_persist) -------------------
602
+ // Pipeline: Response Compression → TOON (sequential, not mutually exclusive)
603
+ // 1. Strip debug/nulls/empty, truncate long strings/arrays
604
+ // 2. If result is still valid JSON and TOON is enabled, encode to TOON format
605
+
606
+ if (checkTokenless() && (responseCompressionEnabled || toonCompressionEnabled)) {
607
+ api.on(
608
+ "tool_result_persist",
609
+ (event: { toolName?: string; toolCallId?: string; message: any; isSynthetic?: boolean }, ctx: { agentId?: string; sessionId?: string; sessionKey?: string; toolName?: string; toolCallId?: string }) => {
610
+ const beforeJson = JSON.stringify(event.message);
611
+ // Skip small responses
612
+ if (beforeJson.length < 200) return;
613
+
614
+ // Skip content-retrieval tools — agent needs complete responses
615
+ if (event.toolName && skipTools.has(event.toolName.toLowerCase())) return;
616
+
617
+ // 3-layer dispatch: determine thresholds based on tool category
618
+ const toolNameLower = (event.toolName ?? "").toLowerCase();
619
+ const thresholds = shellTools.has(toolNameLower) ? shellThresholds : apiThresholds;
620
+
621
+ // Skip skill content to avoid breaking YAML frontmatter metadata.
622
+ if (isSkillContent(event.message)) return;
623
+
624
+ const toolCallId = ctx?.toolCallId || event.toolCallId;
625
+
626
+ // Resolve sessionId with 4-level priority:
627
+ // 1. ctx.sessionId — direct from OpenClaw (newer versions)
628
+ // 2. sessionMap[sessionKey] — from session_start mapping
629
+ // 3. envContext.sessionId — from session_start / before_tool_call
630
+ // 4. ctx.sessionKey — always available ("agent:main:main"), best-effort fallback
631
+ const sessionId = ctx?.sessionId
632
+ || (ctx?.sessionKey && sessionMap.get(ctx.sessionKey))
633
+ || envContext.sessionId
634
+ || ctx?.sessionKey;
635
+
636
+ // Step 1: Response Compression
637
+ let currentMessage: any = event.message;
638
+ let usedResponseCompression = false;
639
+
640
+ if (responseCompressionEnabled) {
641
+ const compressed = tryCompressResponse(currentMessage, sessionId, toolCallId, thresholds);
642
+ if (compressed) {
643
+ currentMessage = compressed;
644
+ usedResponseCompression = true;
645
+ }
646
+ }
647
+
648
+ // Step 2: TOON Encoding (if compressed result is JSON-serializable)
649
+ let usedToon = false;
650
+ let toonText = "";
651
+
652
+ if (toonCompressionEnabled && checkTokenless()) {
653
+ const result = tryCompressToon(currentMessage, sessionId, toolCallId);
654
+ if (result) {
655
+ toonText = result.toonText;
656
+ usedToon = true;
657
+ }
658
+ }
659
+
660
+ // Nothing was compressed — pass through unchanged
661
+ if (!usedResponseCompression && !usedToon) return;
662
+
663
+ // Build the final output
664
+ let finalMessage: any;
665
+ let savingsLabel: string;
666
+ let totalSavingsPct: number;
667
+
668
+ if (usedToon) {
669
+ const before = beforeJson.length;
670
+ const after = toonText.length;
671
+ totalSavingsPct = before > 0 ? Math.round(((before - after) / before) * 100) : 0;
672
+ savingsLabel = usedResponseCompression
673
+ ? "response compressed + TOON encoded"
674
+ : "TOON encoded";
675
+ // Preserve original tool result message structure. Returning a raw
676
+ // string causes OpenClaw's tool_result_persist hook to drop
677
+ // role/toolCallId/toolName, which makes session-transcript-repair
678
+ // inject a synthetic "missing tool result" error on the next run.
679
+ if (typeof event.message === "object" && event.message?.role === "toolResult") {
680
+ finalMessage = {
681
+ ...event.message,
682
+ content: [{ type: "text" as const, text: toonText }],
683
+ };
684
+ } else {
685
+ finalMessage = toonText;
686
+ }
687
+ } else {
688
+ const before = beforeJson.length;
689
+ const after = JSON.stringify(currentMessage).length;
690
+ totalSavingsPct = before > 0 ? Math.round(((before - after) / before) * 100) : 0;
691
+ savingsLabel = "response compressed";
692
+ finalMessage = currentMessage;
693
+ }
694
+
695
+ if (verbose) {
696
+ const before = beforeJson.length;
697
+ const after = usedToon ? toonText.length : JSON.stringify(finalMessage).length;
698
+ console.log(
699
+ `[tokenless:${savingsLabel}] ${event.toolName}: ${before} -> ${after} chars (${totalSavingsPct}% reduction)`,
700
+ );
701
+ }
702
+
703
+ return { message: finalMessage };
704
+ },
705
+ { priority: 10 },
706
+ );
707
+ }
708
+
709
+ // ---- Done -------------------------------------------------------------------
710
+
711
+ if (verbose) {
712
+ const features = [
713
+ rtkEnabled && rtkAvailable ? "rtk-rewrite" : null,
714
+ responseCompressionEnabled && tokenlessAvailable ? "response-compression" : null,
715
+ toonCompressionEnabled && tokenlessAvailable ? "toon-compression" : null,
716
+ ].filter(Boolean);
717
+ console.log(`[tokenless] OpenClaw plugin registered — active features: ${features.join(", ") || "none"}`);
718
+ }
719
+ },
720
+ };