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