anolisa-tokenless 0.7.13 → 0.8.0

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