@elvatis_com/openclaw-cli-bridge-elvatis 3.4.1 → 3.5.1
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 +1 -1
- package/SKILL.md +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/cli-runner.ts +13 -4
- package/src/proxy-server.ts +12 -5
- package/src/tool-protocol.ts +50 -28
- package/test/cli-runner.test.ts +6 -4
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code, OpenCode, Pi) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
|
|
4
4
|
|
|
5
|
-
**Current version:** `3.
|
|
5
|
+
**Current version:** `3.5.1`
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
package/SKILL.md
CHANGED
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-cli-bridge-elvatis",
|
|
3
3
|
"slug": "openclaw-cli-bridge-elvatis",
|
|
4
4
|
"name": "OpenClaw CLI Bridge",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.5.1",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
|
|
8
8
|
"providers": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elvatis_com/openclaw-cli-bridge-elvatis",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.1",
|
|
4
4
|
"description": "Bridges gemini, claude, and codex CLI tools as OpenClaw model providers. Reads existing CLI auth without re-login.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"openclaw": {
|
package/src/cli-runner.ts
CHANGED
|
@@ -80,11 +80,16 @@ export function formatPrompt(messages: ChatMessage[], toolCount = 0): string {
|
|
|
80
80
|
// Reduce history when tool schemas dominate the prompt
|
|
81
81
|
const maxMsgs = toolCount > TOOL_HEAVY_THRESHOLD ? MAX_MESSAGES_HEAVY_TOOLS : MAX_MESSAGES;
|
|
82
82
|
|
|
83
|
-
// Keep system message (if any) + last N non-system messages
|
|
83
|
+
// Keep system message (if any) + first user message (original request) + last N non-system messages
|
|
84
84
|
const system = messages.find((m) => m.role === "system");
|
|
85
85
|
const nonSystem = messages.filter((m) => m.role !== "system");
|
|
86
|
+
const firstUser = nonSystem.find((m) => m.role === "user");
|
|
86
87
|
const recent = nonSystem.slice(-maxMsgs);
|
|
87
|
-
|
|
88
|
+
// Pin the first user message so the model never loses the original request
|
|
89
|
+
const pinned = firstUser && !recent.includes(firstUser)
|
|
90
|
+
? [firstUser, ...recent]
|
|
91
|
+
: recent;
|
|
92
|
+
const truncated = system ? [system, ...pinned] : pinned;
|
|
88
93
|
|
|
89
94
|
// Single short user message — send bare (no wrapping needed)
|
|
90
95
|
if (truncated.length === 1 && truncated[0].role === "user") {
|
|
@@ -1022,9 +1027,13 @@ export async function routeToCliRunner(
|
|
|
1022
1027
|
let prompt = formatPrompt(messages, toolCount);
|
|
1023
1028
|
const hasTools = toolCount > 0;
|
|
1024
1029
|
|
|
1025
|
-
// Auto-detect project from
|
|
1030
|
+
// Auto-detect project from user messages only (not tool results which mention other projects)
|
|
1026
1031
|
if (!opts.workdir) {
|
|
1027
|
-
const
|
|
1032
|
+
const userText = messages
|
|
1033
|
+
.filter((m) => m.role === "user")
|
|
1034
|
+
.map((m) => typeof m.content === "string" ? m.content : "")
|
|
1035
|
+
.join(" ");
|
|
1036
|
+
const detected = detectProjectFromPrompt(userText);
|
|
1028
1037
|
if (detected) {
|
|
1029
1038
|
opts = { ...opts, workdir: detected.path };
|
|
1030
1039
|
prompt = `[Context: Working directory is ${detected.path}]\n\n${prompt}`;
|
package/src/proxy-server.ts
CHANGED
|
@@ -448,6 +448,9 @@ async function handleRequest(
|
|
|
448
448
|
const promptPreview = typeof lastUserMsg?.content === "string" ? lastUserMsg.content.slice(0, 80) : "";
|
|
449
449
|
|
|
450
450
|
debugLog("REQ", `${model} start`, { msgs: cleanMessages.length, tools: tools?.length ?? 0, stream, media: mediaFiles.length, promptPreview: promptPreview.slice(0, 60) });
|
|
451
|
+
if (hasTools && tools!.length > 0) {
|
|
452
|
+
debugLog("TOOLS", `${tools!.length} tools available`, { names: tools!.map(t => t.function?.name ?? t.name ?? "?").join(", ") });
|
|
453
|
+
}
|
|
451
454
|
|
|
452
455
|
// Track active request for dashboard
|
|
453
456
|
activeRequests.set(id, { id, model, startedAt: Date.now(), messageCount: cleanMessages.length, toolCount: tools?.length ?? 0, promptPreview });
|
|
@@ -939,7 +942,14 @@ async function handleRequest(
|
|
|
939
942
|
opts.warn(`[cli-bridge] ${model} failed (${reason}), trying fallback chain: ${fallbackChain.join(" → ")}`);
|
|
940
943
|
|
|
941
944
|
let chainSuccess = false;
|
|
945
|
+
const lastMsg = cleanMessages[cleanMessages.length - 1];
|
|
946
|
+
const inToolLoop = hasTools && (lastMsg?.role === "tool" || lastMsg?.role === "function");
|
|
942
947
|
for (const fallbackModel of fallbackChain) {
|
|
948
|
+
// Skip Haiku in tool loops — it consistently returns text instead of tool_calls, wasting ~8-12s
|
|
949
|
+
if (inToolLoop && fallbackModel.includes("haiku")) {
|
|
950
|
+
debugLog("FALLBACK-SKIP", `skipping ${fallbackModel} in tool loop (unreliable for tool_calls)`, {});
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
943
953
|
debugLog("FALLBACK", `${model} → ${fallbackModel}`, { reason: isTimeout ? "timeout" : "error", primaryDuration: Math.round(primaryDuration / 1000), chain: fallbackChain });
|
|
944
954
|
if (sseHeadersSent) {
|
|
945
955
|
res.write(`: fallback — trying ${fallbackModel}\n\n`);
|
|
@@ -952,12 +962,9 @@ async function handleRequest(
|
|
|
952
962
|
debugLog("FALLBACK-EMPTY", `${fallbackModel} returned empty`, {});
|
|
953
963
|
throw new Error(`empty response from ${fallbackModel}`);
|
|
954
964
|
}
|
|
955
|
-
// If
|
|
956
|
-
// tool continuation), but the fallback model returned text instead of tool_calls —
|
|
965
|
+
// If we're in a tool loop but the fallback returned text instead of tool_calls —
|
|
957
966
|
// it ignored the JSON format. Try next model in chain.
|
|
958
|
-
|
|
959
|
-
const inToolLoop = lastMsg?.role === "tool" || lastMsg?.role === "function";
|
|
960
|
-
if (hasTools && inToolLoop && !result.tool_calls?.length && result.content) {
|
|
967
|
+
if (inToolLoop && !result.tool_calls?.length && result.content) {
|
|
961
968
|
debugLog("FALLBACK-NO-TOOLS", `${fallbackModel} returned text instead of tool_calls in tool loop`, { contentLen: result.content.length, preview: result.content.slice(0, 80) });
|
|
962
969
|
throw new Error(`${fallbackModel} returned text instead of tool_calls`);
|
|
963
970
|
}
|
package/src/tool-protocol.ts
CHANGED
|
@@ -188,6 +188,16 @@ export function parseToolCallResponse(text: string): CliToolResult {
|
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// Last resort: try to rescue tool_calls from anywhere in the text
|
|
192
|
+
// Models sometimes output tool_calls JSON with surrounding text that breaks other strategies
|
|
193
|
+
if (trimmed.includes("tool_calls")) {
|
|
194
|
+
const rescued = tryRescueToolCallsFromContent(trimmed);
|
|
195
|
+
if (rescued) {
|
|
196
|
+
debugLog("PARSE", `rescue-from-raw → tool_calls`, { toolCalls: rescued.tool_calls?.length ?? 0 });
|
|
197
|
+
return rescued;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
191
201
|
// Fallback: treat entire text as content
|
|
192
202
|
debugLog("PARSE", "no JSON found → raw content", { len: trimmed.length, preview });
|
|
193
203
|
return { content: trimmed || null };
|
|
@@ -307,37 +317,49 @@ function tryExtractCodeBlock(text: string): string | null {
|
|
|
307
317
|
return match?.[1]?.trim() ?? null;
|
|
308
318
|
}
|
|
309
319
|
|
|
310
|
-
/** Find
|
|
320
|
+
/** Find a balanced { ... } JSON object in text. Tries multiple start positions if the first fails to parse. */
|
|
311
321
|
function tryExtractEmbeddedJson(text: string): string | null {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
depth
|
|
337
|
-
if (
|
|
338
|
-
|
|
322
|
+
let searchFrom = 0;
|
|
323
|
+
while (searchFrom < text.length) {
|
|
324
|
+
const start = text.indexOf("{", searchFrom);
|
|
325
|
+
if (start === -1) return null;
|
|
326
|
+
|
|
327
|
+
let depth = 0;
|
|
328
|
+
let inString = false;
|
|
329
|
+
let escaped = false;
|
|
330
|
+
|
|
331
|
+
for (let i = start; i < text.length; i++) {
|
|
332
|
+
const ch = text[i];
|
|
333
|
+
if (escaped) {
|
|
334
|
+
escaped = false;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (ch === "\\") {
|
|
338
|
+
escaped = true;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (ch === '"') {
|
|
342
|
+
inString = !inString;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (inString) continue;
|
|
346
|
+
if (ch === "{") depth++;
|
|
347
|
+
if (ch === "}") {
|
|
348
|
+
depth--;
|
|
349
|
+
if (depth === 0) {
|
|
350
|
+
const candidate = text.slice(start, i + 1);
|
|
351
|
+
// Verify it actually parses as JSON before returning
|
|
352
|
+
try {
|
|
353
|
+
JSON.parse(candidate);
|
|
354
|
+
return candidate;
|
|
355
|
+
} catch {
|
|
356
|
+
// This balanced-brace block isn't valid JSON — try next { in text
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
339
360
|
}
|
|
340
361
|
}
|
|
362
|
+
searchFrom = start + 1;
|
|
341
363
|
}
|
|
342
364
|
return null;
|
|
343
365
|
}
|
package/test/cli-runner.test.ts
CHANGED
|
@@ -19,18 +19,19 @@ describe("formatPrompt", () => {
|
|
|
19
19
|
expect(result).toBe("hello");
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
it("truncates to MAX_MESSAGES (20) non-system messages", () => {
|
|
22
|
+
it("truncates to MAX_MESSAGES (20) non-system messages but pins first user message", () => {
|
|
23
23
|
const messages = Array.from({ length: 30 }, (_, i) => ({
|
|
24
24
|
role: "user" as const,
|
|
25
25
|
content: `msg ${i}`,
|
|
26
26
|
}));
|
|
27
27
|
const result = formatPrompt(messages);
|
|
28
28
|
expect(result).toContain("msg 29");
|
|
29
|
-
expect(result).
|
|
29
|
+
expect(result).toContain("msg 0"); // first user message is always pinned
|
|
30
|
+
expect(result).not.toContain("msg 1\n"); // but intermediate messages are truncated
|
|
30
31
|
expect(result).toContain("[User]");
|
|
31
32
|
});
|
|
32
33
|
|
|
33
|
-
it("keeps system message + last 20 non-system messages", () => {
|
|
34
|
+
it("keeps system message + first user message + last 20 non-system messages", () => {
|
|
34
35
|
const sys = { role: "system" as const, content: "You are helpful" };
|
|
35
36
|
const msgs = Array.from({ length: 25 }, (_, i) => ({
|
|
36
37
|
role: "user" as const,
|
|
@@ -40,7 +41,8 @@ describe("formatPrompt", () => {
|
|
|
40
41
|
expect(result).toContain("[System]");
|
|
41
42
|
expect(result).toContain("You are helpful");
|
|
42
43
|
expect(result).toContain("msg 24");
|
|
43
|
-
expect(result).
|
|
44
|
+
expect(result).toContain("msg 0"); // first user message is always pinned
|
|
45
|
+
expect(result).not.toContain("msg 1\n"); // but intermediate messages are truncated
|
|
44
46
|
});
|
|
45
47
|
|
|
46
48
|
it("truncates individual message content at MAX_MSG_CHARS (4000)", () => {
|