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,157 +1,105 @@
|
|
|
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
8
|
|
|
21
|
-
import { execFileSync
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
18
|
+
let tokenlessAvailable: boolean | null = null;
|
|
19
|
+
let tokenlessCheckedAt: number | null = null;
|
|
20
|
+
let tokenlessPath = "tokenless";
|
|
42
21
|
|
|
43
|
-
|
|
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
|
|
46
|
-
agentId: string;
|
|
33
|
+
interface CallContext {
|
|
47
34
|
sessionId: string;
|
|
48
35
|
toolCallId: string;
|
|
49
36
|
}
|
|
50
37
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
38
|
+
interface OptimizationState {
|
|
39
|
+
optimization: "rtk";
|
|
40
|
+
createdAt: number;
|
|
41
|
+
}
|
|
54
42
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
...buildContextEnv(context),
|
|
59
|
-
};
|
|
43
|
+
interface ToolCategories {
|
|
44
|
+
layer_1_skip: { tools: string[] };
|
|
45
|
+
layer_2_shell: { tools: string[] };
|
|
60
46
|
}
|
|
61
47
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
};
|
|
48
|
+
interface ToolResultEvent {
|
|
49
|
+
toolName?: string;
|
|
50
|
+
toolCallId?: string;
|
|
51
|
+
message: unknown;
|
|
52
|
+
isSynthetic?: boolean;
|
|
68
53
|
}
|
|
69
54
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
64
|
+
interface TextBlock {
|
|
65
|
+
type: "text";
|
|
66
|
+
text: string;
|
|
67
|
+
[key: string]: unknown;
|
|
85
68
|
}
|
|
86
69
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
109
|
-
|
|
79
|
+
| {
|
|
80
|
+
kind: "structured";
|
|
81
|
+
content: string;
|
|
82
|
+
replaceWithText: false;
|
|
83
|
+
message: Record<string, unknown> | unknown[];
|
|
84
|
+
};
|
|
110
85
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
238
|
-
|
|
138
|
+
tokenlessAvailable = resolved !== null;
|
|
139
|
+
if (resolved !== null) tokenlessPath = resolved;
|
|
239
140
|
tokenlessCheckedAt = Date.now();
|
|
240
141
|
return tokenlessAvailable;
|
|
241
142
|
}
|
|
242
143
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
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:
|
|
362
|
-
input
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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:
|
|
380
|
-
env:
|
|
215
|
+
timeout: 3_000,
|
|
216
|
+
env: process.env,
|
|
381
217
|
}).trim();
|
|
382
|
-
const parsed = JSON.parse(result);
|
|
383
|
-
const status
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
timeout:
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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: "
|
|
310
|
+
description: "Protocol v2 RTK rewriting and PostTool optimization for OpenClaw",
|
|
507
311
|
register(api: any) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
"
|
|
566
|
-
(event: {
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
"
|
|
587
|
-
(event: {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
};
|