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