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.
- package/README.md +219 -87
- package/adapters/tokenless/claude-code/.claude-plugin/plugin.json +1 -1
- package/adapters/tokenless/claude-code/hooks/run-hook.sh +62 -0
- package/adapters/tokenless/codex/.codex-plugin/plugin.json +5 -4
- package/adapters/tokenless/codex/README.md +22 -32
- package/adapters/tokenless/codex/hooks/hooks.json +3 -3
- package/adapters/tokenless/codex/scripts/response-diagnostics +170 -0
- package/adapters/tokenless/common/cosh-extension.json +4 -4
- package/adapters/tokenless/common/hooks/compress_response_hook.py +250 -307
- package/adapters/tokenless/common/hooks/compress_schema_hook.py +34 -42
- package/adapters/tokenless/common/hooks/hook_utils.py +281 -44
- package/adapters/tokenless/common/hooks/rewrite_hook.py +53 -169
- package/adapters/tokenless/dsh/dist/index.js +353 -264
- package/adapters/tokenless/dsh/package.json +2 -2
- package/adapters/tokenless/hermes/__init__.py +191 -355
- package/adapters/tokenless/hermes/plugin.yaml +2 -2
- package/adapters/tokenless/manifest.json +18 -3
- package/adapters/tokenless/openclaw/dist/index.d.ts +4 -16
- package/adapters/tokenless/openclaw/dist/index.js +291 -507
- package/adapters/tokenless/openclaw/index.ts +408 -628
- package/adapters/tokenless/openclaw/openclaw.plugin.json +4 -20
- package/adapters/tokenless/openclaw/package.json +6 -4
- package/adapters/tokenless/qoder/.qoder-plugin/plugin.json +1 -1
- package/adapters/tokenless/qwencode/hooks/run-hook.sh +62 -0
- package/adapters/tokenless/qwencode/qwen-extension.json +4 -4
- package/adapters/tokenless/qwenpaw/plugin.json +17 -0
- package/adapters/tokenless/qwenpaw/plugin.py +390 -0
- package/adapters/tokenless/qwenpaw/requirements.txt +6 -0
- package/adapters/tokenless/qwenpaw/scripts/detect.sh +131 -0
- package/adapters/tokenless/qwenpaw/scripts/install.sh +98 -0
- package/adapters/tokenless/qwenpaw/scripts/uninstall.sh +61 -0
- package/package.json +5 -5
- package/adapters/tokenless/codex/scripts/compress-response +0 -445
- package/adapters/tokenless/common/hooks/compress_toon_hook.py +0 -171
|
@@ -1,119 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Tokenless lifecycle adapter for OpenClaw.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
21
|
-
import {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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(
|
|
135
|
-
if (
|
|
54
|
+
const candidate = join(directory, name);
|
|
55
|
+
if (isExecutable(candidate))
|
|
136
56
|
return candidate;
|
|
137
|
-
}
|
|
138
57
|
}
|
|
139
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
|
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
|
|
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:
|
|
312
|
-
input
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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:
|
|
330
|
-
env:
|
|
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
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
"
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
421
|
-
|
|
422
|
-
return fallback;
|
|
210
|
+
catch {
|
|
211
|
+
return null;
|
|
423
212
|
}
|
|
424
213
|
}
|
|
425
|
-
|
|
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: "
|
|
231
|
+
description: "Protocol v2 RTK rewriting and PostTool optimization for OpenClaw",
|
|
431
232
|
register(api) {
|
|
432
|
-
const pluginConfig = api.
|
|
233
|
+
const pluginConfig = api.pluginConfig ?? {};
|
|
433
234
|
const rtkEnabled = pluginConfig.rtk_enabled !== false;
|
|
434
|
-
const
|
|
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
|
-
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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}
|
|
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
|
-
|
|
487
|
-
if (rtkEnabled && checkRtk()) {
|
|
305
|
+
if ((rtkEnabled || postToolEnabled) && available) {
|
|
488
306
|
api.on("before_tool_call", (event, ctx) => {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
307
|
+
const sessionId = sessionIdFor(ctx);
|
|
308
|
+
if (!rtkEnabled
|
|
309
|
+
|| event.toolName !== "exec"
|
|
310
|
+
|| typeof event.params?.command !== "string") {
|
|
493
311
|
return;
|
|
494
|
-
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
503
|
-
const rewritten = tryRtkRewrite(command, callContext);
|
|
504
|
-
if (!rewritten)
|
|
317
|
+
if (!context.toolCallId)
|
|
505
318
|
return;
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
533
|
-
if (
|
|
356
|
+
const slot = contentSlot(event.message);
|
|
357
|
+
if (slot === null)
|
|
534
358
|
return;
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
572
|
-
if (!usedResponseCompression && !usedToon)
|
|
390
|
+
if (result.disposition !== "applied" || typeof result.output !== "string")
|
|
573
391
|
return;
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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 &&
|
|
618
|
-
|
|
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
|
}
|