pi-free 2.0.15 → 2.1.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +100 -3
  2. package/README.md +64 -79
  3. package/banner.svg +21 -36
  4. package/config.ts +123 -9
  5. package/constants.ts +3 -9
  6. package/index.ts +14 -15
  7. package/lib/built-in-toggle.ts +29 -56
  8. package/lib/json-persistence.ts +90 -22
  9. package/lib/logger.ts +21 -12
  10. package/lib/model-detection.ts +2 -12
  11. package/lib/model-enhancer.ts +11 -2
  12. package/lib/model-metadata.ts +387 -0
  13. package/lib/open-browser.ts +74 -24
  14. package/lib/paths.ts +90 -0
  15. package/lib/probe-cache.ts +19 -19
  16. package/lib/provider-cache.ts +74 -28
  17. package/lib/provider-compat.ts +53 -37
  18. package/lib/provider-probe.ts +188 -0
  19. package/lib/registry.ts +1 -5
  20. package/lib/session-start-metrics.ts +46 -0
  21. package/lib/telemetry.ts +115 -86
  22. package/lib/types.ts +22 -2
  23. package/lib/util.ts +80 -21
  24. package/package.json +7 -2
  25. package/provider-failover/benchmark-lookup.ts +17 -5
  26. package/provider-helper.ts +12 -27
  27. package/providers/cline/cline-models.ts +7 -1
  28. package/providers/cline/cline-xml-bridge.ts +1471 -0
  29. package/providers/cline/cline.ts +67 -199
  30. package/providers/codestral/codestral.ts +0 -11
  31. package/providers/crofai/crofai.ts +6 -1
  32. package/providers/deepinfra/deepinfra.ts +69 -2
  33. package/providers/dynamic-built-in/index.ts +237 -22
  34. package/providers/kilo/kilo-models.ts +3 -1
  35. package/providers/kilo/kilo.ts +270 -60
  36. package/providers/model-fetcher.ts +18 -55
  37. package/providers/novita/novita.ts +69 -2
  38. package/providers/ollama/ollama.ts +47 -36
  39. package/providers/opencode-session.ts +67 -2
  40. package/providers/routeway/routeway.ts +25 -17
  41. package/providers/sambanova/sambanova.ts +67 -1
  42. package/providers/together/together.ts +69 -2
  43. package/providers/tokenrouter/tokenrouter.ts +634 -0
  44. package/providers/zenmux/zenmux.ts +6 -1
  45. package/scripts/check-extensions.mjs +32 -16
  46. package/providers/nvidia/nvidia.ts +0 -510
@@ -0,0 +1,1471 @@
1
+ import type {
2
+ AssistantMessage,
3
+ Context,
4
+ Message,
5
+ Model,
6
+ SimpleStreamOptions,
7
+ Tool,
8
+ ToolCall,
9
+ ToolResultMessage,
10
+ Usage,
11
+ } from "@earendil-works/pi-ai";
12
+ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
13
+ import { BASE_URL_CLINE, PROVIDER_CLINE } from "../../constants.ts";
14
+
15
+ const DEFAULT_USAGE: Usage = {
16
+ input: 0,
17
+ output: 0,
18
+ cacheRead: 0,
19
+ cacheWrite: 0,
20
+ totalTokens: 0,
21
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
22
+ };
23
+
24
+ type ClineXmlChatMessage = {
25
+ role: "assistant" | "system" | "user";
26
+ content: string | Array<{ type: "text"; text: string }>;
27
+ };
28
+
29
+ type ClineXmlChunk = {
30
+ error?: { message?: string; code?: string };
31
+ usage?: {
32
+ prompt_tokens?: number;
33
+ completion_tokens?: number;
34
+ total_tokens?: number;
35
+ };
36
+ choices?: Array<{
37
+ delta?: { content?: string | null; reasoning?: string | null };
38
+ finish_reason?: string | null;
39
+ error?: { message?: string; code?: string };
40
+ }>;
41
+ };
42
+
43
+ function normalizeApiModelId(modelId: string): string {
44
+ return modelId.startsWith(`${PROVIDER_CLINE}/`)
45
+ ? modelId.slice(`${PROVIDER_CLINE}/`.length)
46
+ : modelId;
47
+ }
48
+
49
+ /**
50
+ * Some MiMo/Cline models emit XML tags wrapped in Unicode math-italic
51
+ * characters that spell out "anthml:" before the real tag name:
52
+ * <𝑎𝑛𝑡𝑚𝑙:thinking>...</𝑎𝑛𝑡𝑚𝑙:thinking>
53
+ * <𝑎𝑛𝑡𝑚𝑙:read_file>...</𝑎𝑛𝑡𝑚𝑙:read_file>
54
+ *
55
+ * This function strips the Unicode-decorated prefix so the rest of the
56
+ * parser sees standard ASCII XML tags.
57
+ */
58
+ function normalizeDecoratedXmlTags(text: string): string {
59
+ const parts: string[] = [];
60
+ let cursor = 0;
61
+
62
+ while (cursor < text.length) {
63
+ const ltIndex = text.indexOf("<", cursor);
64
+ if (ltIndex === -1) {
65
+ parts.push(text.slice(cursor));
66
+ break;
67
+ }
68
+
69
+ parts.push(text.slice(cursor, ltIndex));
70
+ let contentStart = ltIndex + 1;
71
+ let prefix = "<";
72
+
73
+ // Handle closing tags: </𝑎𝑛𝑡𝑚𝑙:thinking> → </thinking>
74
+ if (contentStart < text.length && text[contentStart] === "/") {
75
+ prefix = "</";
76
+ contentStart += 1;
77
+ }
78
+
79
+ const gtIndex = text.indexOf(">", contentStart);
80
+ const colonIndex = text.indexOf(":", contentStart);
81
+ const spaceIndex = text.indexOf(" ", contentStart);
82
+ if (
83
+ colonIndex === -1 ||
84
+ colonIndex === contentStart ||
85
+ (gtIndex !== -1 && colonIndex > gtIndex) ||
86
+ (spaceIndex !== -1 && spaceIndex < colonIndex)
87
+ ) {
88
+ parts.push(prefix);
89
+ cursor = contentStart;
90
+ continue;
91
+ }
92
+
93
+ // Strip non-ASCII bytes between prefix and : to undo Unicode-decorated
94
+ // prefixes like <𝑎𝑛𝑡𝑚𝑙:thinking> → <thinking>.
95
+ let hasNonAscii = false;
96
+ for (let i = contentStart; i < colonIndex; i++) {
97
+ if (text.charCodeAt(i) > 127) {
98
+ hasNonAscii = true;
99
+ break;
100
+ }
101
+ }
102
+
103
+ if (hasNonAscii) {
104
+ parts.push(prefix);
105
+ cursor = colonIndex + 1;
106
+ } else {
107
+ // No decorated prefix - emit < and re-include everything after it
108
+ parts.push("<");
109
+ cursor = ltIndex + 1;
110
+ }
111
+ }
112
+
113
+ return parts.join("");
114
+ }
115
+
116
+ function xmlEscape(value: unknown): string {
117
+ return String(value)
118
+ .replaceAll("&", "&amp;")
119
+ .replaceAll("<", "&lt;")
120
+ .replaceAll(">", "&gt;");
121
+ }
122
+
123
+ function decodeXmlEntities(value: string): string {
124
+ return value
125
+ .replaceAll("&lt;", "<")
126
+ .replaceAll("&gt;", ">")
127
+ .replaceAll("&quot;", '"')
128
+ .replaceAll("&#39;", "'")
129
+ .replaceAll("&amp;", "&");
130
+ }
131
+
132
+ function contentToText(content: unknown): string {
133
+ if (typeof content === "string") return content;
134
+ if (!Array.isArray(content)) return "";
135
+ return content
136
+ .map((part) => {
137
+ if (part?.type === "text" && typeof part.text === "string") {
138
+ return part.text;
139
+ }
140
+ if (part?.type === "image")
141
+ return `[image:${part.mimeType ?? "unknown"}]`;
142
+ return "";
143
+ })
144
+ .filter(Boolean)
145
+ .join("\n");
146
+ }
147
+
148
+ function toolResultToText(message: ToolResultMessage): string {
149
+ return message.content.map((part) => contentToText([part])).join("\n");
150
+ }
151
+
152
+ type ToolBridge = {
153
+ remoteName: string;
154
+ runtimeName: string;
155
+ description?: string;
156
+ parameters: string[];
157
+ toRuntimeArgs(args: Record<string, unknown>): Record<string, unknown>;
158
+ fromRuntimeArgs(args: Record<string, unknown>): Record<string, unknown>;
159
+ };
160
+
161
+ const CORE_CLINE_TOOL_NAMES = [
162
+ "read_file",
163
+ "write_to_file",
164
+ "replace_in_file",
165
+ "execute_command",
166
+ "list_files",
167
+ "search_files",
168
+ "list_code_definition_names",
169
+ ] as const;
170
+
171
+ function stringArg(args: Record<string, unknown>, key: string): string {
172
+ const value = args[key];
173
+ return typeof value === "string" ? value : value == null ? "" : String(value);
174
+ }
175
+
176
+ function booleanArg(args: Record<string, unknown>, key: string): boolean {
177
+ return String(args[key]).toLowerCase() === "true";
178
+ }
179
+
180
+ function shellQuote(value: string): string {
181
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
182
+ }
183
+
184
+ function buildListFilesCommand(args: Record<string, unknown>): string {
185
+ const path = shellQuote(stringArg(args, "path") || ".");
186
+ return booleanArg(args, "recursive")
187
+ ? `find ${path} | sort`
188
+ : `find ${path} -mindepth 1 -maxdepth 1 | sort`;
189
+ }
190
+
191
+ function buildSearchFilesCommand(args: Record<string, unknown>): string {
192
+ const path = shellQuote(stringArg(args, "path") || ".");
193
+ const regex = shellQuote(stringArg(args, "regex"));
194
+ const filePattern = stringArg(args, "file_pattern");
195
+ return [
196
+ "rg",
197
+ "-n",
198
+ "--no-heading",
199
+ "--color",
200
+ "never",
201
+ filePattern ? `-g ${shellQuote(filePattern)}` : "",
202
+ `-e ${regex}`,
203
+ path,
204
+ ]
205
+ .filter(Boolean)
206
+ .join(" ");
207
+ }
208
+
209
+ function buildSearchReplaceDiff(
210
+ edits: Array<{ oldText: string; newText: string }>,
211
+ ): string {
212
+ return edits
213
+ .map((edit) =>
214
+ [
215
+ "------- SEARCH",
216
+ edit.oldText,
217
+ "=======",
218
+ edit.newText,
219
+ "+++++++ REPLACE",
220
+ ].join("\n"),
221
+ )
222
+ .join("\n");
223
+ }
224
+
225
+ function parseSearchReplaceBlocks(
226
+ diff: string,
227
+ ): Array<{ oldText: string; newText: string }> {
228
+ const edits: Array<{ oldText: string; newText: string }> = [];
229
+ const normalized = diff.replaceAll("\r\n", "\n");
230
+ let cursor = 0;
231
+
232
+ while (cursor < normalized.length) {
233
+ const searchMarker = "------- SEARCH\n";
234
+ const replaceMarker = "\n=======\n";
235
+ const endMarker = "\n+++++++ REPLACE";
236
+ const searchStart = normalized.indexOf(searchMarker, cursor);
237
+ if (searchStart === -1) break;
238
+ const oldTextStart = searchStart + searchMarker.length;
239
+ const replaceStart = normalized.indexOf(replaceMarker, oldTextStart);
240
+ if (replaceStart === -1) break;
241
+ const newTextStart = replaceStart + replaceMarker.length;
242
+ const endStart = normalized.indexOf(endMarker, newTextStart);
243
+ if (endStart === -1) break;
244
+ edits.push({
245
+ oldText: normalized.slice(oldTextStart, replaceStart),
246
+ newText: normalized.slice(newTextStart, endStart),
247
+ });
248
+ cursor = endStart + endMarker.length;
249
+ }
250
+
251
+ return edits;
252
+ }
253
+
254
+ function buildListCodeDefinitionNamesCommand(
255
+ args: Record<string, unknown>,
256
+ ): string {
257
+ const path = shellQuote(stringArg(args, "path") || ".");
258
+ const globArgs = [
259
+ "-g '*.ts'",
260
+ "-g '*.tsx'",
261
+ "-g '*.js'",
262
+ "-g '*.jsx'",
263
+ "-g '*.mjs'",
264
+ "-g '*.cjs'",
265
+ "-g '*.py'",
266
+ "-g '*.go'",
267
+ "-g '*.rs'",
268
+ "-g '*.java'",
269
+ "-g '*.kt'",
270
+ "-g '*.swift'",
271
+ ].join(" ");
272
+ const regex =
273
+ "^(export\\s+)?(async\\s+function|function|class|interface|type|enum)\\s+[A-Za-z_][A-Za-z0-9_]*|^(export\\s+)?const\\s+[A-Za-z_][A-Za-z0-9_]*\\s*=\\s*(async\\s*)?\\(";
274
+ return [
275
+ "rg",
276
+ "-n",
277
+ "--no-heading",
278
+ "--color",
279
+ "never",
280
+ globArgs,
281
+ `-e ${shellQuote(regex)}`,
282
+ path,
283
+ ].join(" ");
284
+ }
285
+
286
+ function readFileBridge(tool?: Tool): ToolBridge {
287
+ return {
288
+ remoteName: "read_file",
289
+ runtimeName: tool?.name === "read_file" ? "read_file" : "read",
290
+ description: tool?.description ?? "Read a file from disk",
291
+ parameters: ["path"],
292
+ toRuntimeArgs: (args) => ({ path: stringArg(args, "path") }),
293
+ fromRuntimeArgs: (args) => ({ path: args.path }),
294
+ };
295
+ }
296
+
297
+ function writeToFileBridge(tool?: Tool): ToolBridge {
298
+ return {
299
+ remoteName: "write_to_file",
300
+ runtimeName: tool?.name === "write_to_file" ? "write_to_file" : "write",
301
+ description: tool?.description ?? "Write content to a file",
302
+ parameters: ["path", "content"],
303
+ toRuntimeArgs: (args) => ({
304
+ path: stringArg(args, "path"),
305
+ content: stringArg(args, "content"),
306
+ }),
307
+ fromRuntimeArgs: (args) => ({ path: args.path, content: args.content }),
308
+ };
309
+ }
310
+
311
+ function replaceInFileBridge(tool?: Tool): ToolBridge {
312
+ return {
313
+ remoteName: "replace_in_file",
314
+ runtimeName: tool?.name === "replace_in_file" ? "replace_in_file" : "edit",
315
+ description:
316
+ tool?.description ?? "Edit a file using Cline SEARCH/REPLACE blocks",
317
+ parameters: ["path", "diff"],
318
+ toRuntimeArgs: (args) => ({
319
+ path: stringArg(args, "path"),
320
+ edits: parseSearchReplaceBlocks(stringArg(args, "diff")),
321
+ }),
322
+ fromRuntimeArgs: (args) => {
323
+ const edits = Array.isArray(args.edits)
324
+ ? args.edits
325
+ .map((edit) => ({
326
+ oldText: stringArg(edit as Record<string, unknown>, "oldText"),
327
+ newText: stringArg(edit as Record<string, unknown>, "newText"),
328
+ }))
329
+ .filter((edit) => edit.oldText || edit.newText)
330
+ : [
331
+ {
332
+ oldText: stringArg(args, "oldText"),
333
+ newText: stringArg(args, "newText"),
334
+ },
335
+ ].filter((edit) => edit.oldText || edit.newText);
336
+ return {
337
+ path: args.path,
338
+ diff: buildSearchReplaceDiff(edits),
339
+ };
340
+ },
341
+ };
342
+ }
343
+
344
+ function executeCommandBridge(tool?: Tool): ToolBridge {
345
+ return {
346
+ remoteName: "execute_command",
347
+ runtimeName: tool?.name === "execute_command" ? "execute_command" : "bash",
348
+ description: tool?.description ?? "Execute a shell command",
349
+ parameters: ["command", "timeout"],
350
+ toRuntimeArgs: (args) => ({
351
+ command: stringArg(args, "command"),
352
+ ...(args.timeout !== undefined ? { timeout: Number(args.timeout) } : {}),
353
+ }),
354
+ fromRuntimeArgs: (args) => ({
355
+ command: args.command,
356
+ ...(args.timeout !== undefined ? { timeout: args.timeout } : {}),
357
+ }),
358
+ };
359
+ }
360
+
361
+ type HeredocWriteCommand = {
362
+ path: string;
363
+ content: string;
364
+ };
365
+
366
+ function shellSplitLine(line: string): string[] {
367
+ const tokens: string[] = [];
368
+ let current = "";
369
+ let quote: '"' | "'" | undefined;
370
+
371
+ for (let i = 0; i < line.length; i++) {
372
+ const char = line[i];
373
+ if (quote) {
374
+ if (char === quote) {
375
+ quote = undefined;
376
+ } else {
377
+ current += char;
378
+ }
379
+ continue;
380
+ }
381
+ if (char === '"' || char === "'") {
382
+ quote = char;
383
+ continue;
384
+ }
385
+ if (char === " " || char === "\t") {
386
+ if (current) {
387
+ tokens.push(current);
388
+ current = "";
389
+ }
390
+ continue;
391
+ }
392
+ current += char;
393
+ }
394
+
395
+ if (current) tokens.push(current);
396
+ return tokens;
397
+ }
398
+
399
+ function parseCatHeredocWriteCommand(
400
+ command: string,
401
+ ): HeredocWriteCommand | undefined {
402
+ const normalized = command.replaceAll("\r\n", "\n").trim();
403
+ const lines = normalized.split("\n");
404
+ if (lines.length < 3) return undefined;
405
+
406
+ const tokens = shellSplitLine(lines[0].trim());
407
+ if (tokens[0] !== "cat") return undefined;
408
+ const redirectIndex = tokens.indexOf(">");
409
+ if (redirectIndex === -1) return undefined;
410
+ const path = tokens[redirectIndex + 1];
411
+ if (!path) return undefined;
412
+
413
+ let delimiter = "";
414
+ for (let i = redirectIndex + 2; i < tokens.length; i++) {
415
+ const token = tokens[i];
416
+ if (token === "<<") {
417
+ delimiter = tokens[i + 1] ?? "";
418
+ break;
419
+ }
420
+ if (token.startsWith("<<")) {
421
+ delimiter = token.slice(2);
422
+ break;
423
+ }
424
+ }
425
+ if (!delimiter) return undefined;
426
+
427
+ let delimiterLine = -1;
428
+ for (let i = 1; i < lines.length; i++) {
429
+ if (lines[i].trim() === delimiter) {
430
+ delimiterLine = i;
431
+ break;
432
+ }
433
+ }
434
+ if (delimiterLine === -1) return undefined;
435
+
436
+ const trailing = lines
437
+ .slice(delimiterLine + 1)
438
+ .join("\n")
439
+ .trim();
440
+ if (trailing) {
441
+ const trailingLines = trailing.split("\n").filter((line) => line.trim());
442
+ if (trailingLines.length !== 1) return undefined;
443
+ const trailingTokens = shellSplitLine(trailingLines[0].trim());
444
+ if (trailingTokens.length !== 2 || trailingTokens[0] !== "cat") {
445
+ return undefined;
446
+ }
447
+ if (trailingTokens[1] !== path) return undefined;
448
+ }
449
+
450
+ return {
451
+ path,
452
+ content: lines.slice(1, delimiterLine).join("\n"),
453
+ };
454
+ }
455
+
456
+ function getWriteRuntimeToolName(
457
+ tools: Tool[] | undefined,
458
+ ): string | undefined {
459
+ if ((tools ?? []).some((tool) => tool.name === "write_to_file")) {
460
+ return "write_to_file";
461
+ }
462
+ if ((tools ?? []).some((tool) => tool.name === "write")) return "write";
463
+ return undefined;
464
+ }
465
+
466
+ function listFilesBridge(): ToolBridge {
467
+ return {
468
+ remoteName: "list_files",
469
+ runtimeName: "bash",
470
+ description: "List files in a directory",
471
+ parameters: ["path", "recursive"],
472
+ toRuntimeArgs: (args) => ({ command: buildListFilesCommand(args) }),
473
+ fromRuntimeArgs: (args) => ({ command: args.command }),
474
+ };
475
+ }
476
+
477
+ function searchFilesBridge(): ToolBridge {
478
+ return {
479
+ remoteName: "search_files",
480
+ runtimeName: "bash",
481
+ description: "Search files by regex",
482
+ parameters: ["path", "regex", "file_pattern"],
483
+ toRuntimeArgs: (args) => ({ command: buildSearchFilesCommand(args) }),
484
+ fromRuntimeArgs: (args) => ({ command: args.command }),
485
+ };
486
+ }
487
+
488
+ function listCodeDefinitionNamesBridge(): ToolBridge {
489
+ return {
490
+ remoteName: "list_code_definition_names",
491
+ runtimeName: "bash",
492
+ description: "List code definition names in source files",
493
+ parameters: ["path"],
494
+ toRuntimeArgs: (args) => ({
495
+ command: buildListCodeDefinitionNamesCommand(args),
496
+ }),
497
+ fromRuntimeArgs: (args) => ({ command: args.command }),
498
+ };
499
+ }
500
+
501
+ function getToolBridge(tool: Tool): ToolBridge {
502
+ if (tool.name === "read" || tool.name === "read_file") {
503
+ return readFileBridge(tool);
504
+ }
505
+ if (tool.name === "write" || tool.name === "write_to_file") {
506
+ return writeToFileBridge(tool);
507
+ }
508
+ if (tool.name === "edit" || tool.name === "replace_in_file") {
509
+ return replaceInFileBridge(tool);
510
+ }
511
+ if (tool.name === "bash" || tool.name === "execute_command") {
512
+ return executeCommandBridge(tool);
513
+ }
514
+ const parameters = schemaProperties(tool);
515
+ return {
516
+ remoteName: tool.name,
517
+ runtimeName: tool.name,
518
+ description: tool.description,
519
+ parameters,
520
+ toRuntimeArgs: (args) => args,
521
+ fromRuntimeArgs: (args) => args,
522
+ };
523
+ }
524
+
525
+ function getToolBridges(tools: Tool[] | undefined): ToolBridge[] {
526
+ const bridges: ToolBridge[] = [];
527
+ for (const tool of tools ?? []) {
528
+ bridges.push(getToolBridge(tool));
529
+ if (tool.name === "bash" || tool.name === "execute_command") {
530
+ bridges.push(
531
+ listFilesBridge(),
532
+ searchFilesBridge(),
533
+ listCodeDefinitionNamesBridge(),
534
+ );
535
+ }
536
+ }
537
+ return bridges;
538
+ }
539
+
540
+ function getParseToolBridges(tools: Tool[] | undefined): ToolBridge[] {
541
+ const bridges = getToolBridges(tools);
542
+ const remoteNames = new Set(bridges.map((bridge) => bridge.remoteName));
543
+ const toolsByName = new Map((tools ?? []).map((tool) => [tool.name, tool]));
544
+
545
+ for (const remoteName of CORE_CLINE_TOOL_NAMES) {
546
+ if (remoteNames.has(remoteName)) continue;
547
+ if (remoteName === "read_file") {
548
+ bridges.push(readFileBridge(toolsByName.get("read_file")));
549
+ }
550
+ if (remoteName === "write_to_file") {
551
+ bridges.push(writeToFileBridge(toolsByName.get("write_to_file")));
552
+ }
553
+ if (remoteName === "replace_in_file") {
554
+ bridges.push(replaceInFileBridge(toolsByName.get("replace_in_file")));
555
+ }
556
+ if (remoteName === "execute_command") {
557
+ bridges.push(executeCommandBridge(toolsByName.get("execute_command")));
558
+ }
559
+ if (remoteName === "list_files") {
560
+ bridges.push(listFilesBridge());
561
+ }
562
+ if (remoteName === "search_files") {
563
+ bridges.push(searchFilesBridge());
564
+ }
565
+ if (remoteName === "list_code_definition_names") {
566
+ bridges.push(listCodeDefinitionNamesBridge());
567
+ }
568
+ }
569
+
570
+ return bridges;
571
+ }
572
+
573
+ function serializeXmlToolCall(
574
+ name: string,
575
+ args: Record<string, unknown>,
576
+ ): string {
577
+ const parts = [`<${name}>`];
578
+ for (const [key, value] of Object.entries(args)) {
579
+ const text = typeof value === "string" ? value : JSON.stringify(value);
580
+ parts.push(`<${key}>${xmlEscape(text)}</${key}>`);
581
+ }
582
+ parts.push(`</${name}>`);
583
+ return parts.join("\n");
584
+ }
585
+
586
+ function assistantMessageToText(
587
+ message: Extract<Message, { role: "assistant" }>,
588
+ tools: Tool[] | undefined,
589
+ ): string {
590
+ const bridgeByRuntimeName = new Map<string, ToolBridge>();
591
+ for (const bridge of getToolBridges(tools)) {
592
+ if (!bridgeByRuntimeName.has(bridge.runtimeName)) {
593
+ bridgeByRuntimeName.set(bridge.runtimeName, bridge);
594
+ }
595
+ }
596
+ return message.content
597
+ .map((part) => {
598
+ if (part.type === "text") return part.text;
599
+ if (part.type === "thinking") {
600
+ return `<thinking>\n${xmlEscape(part.thinking)}\n</thinking>`;
601
+ }
602
+ if (part.type === "toolCall") {
603
+ const bridge = bridgeByRuntimeName.get(part.name);
604
+ return serializeXmlToolCall(
605
+ bridge?.remoteName ?? part.name,
606
+ bridge?.fromRuntimeArgs(part.arguments) ?? part.arguments,
607
+ );
608
+ }
609
+ return "";
610
+ })
611
+ .filter(Boolean)
612
+ .join("\n\n");
613
+ }
614
+
615
+ function schemaProperties(tool: Tool): string[] {
616
+ const parameters = tool.parameters as unknown as {
617
+ properties?: Record<string, unknown>;
618
+ };
619
+ return Object.keys(parameters.properties ?? {});
620
+ }
621
+
622
+ function buildToolInstructions(tools: Tool[] | undefined): string {
623
+ const bridges = getToolBridges(tools);
624
+ if (bridges.length === 0) return "";
625
+
626
+ const sections = bridges.map((bridge) => {
627
+ const params =
628
+ bridge.remoteName === "replace_in_file"
629
+ ? [
630
+ " <path>path/to/file</path>",
631
+ " <diff>",
632
+ "------- SEARCH",
633
+ "exact text to replace",
634
+ "=======",
635
+ "new text",
636
+ "+++++++ REPLACE",
637
+ " </diff>",
638
+ ].join("\n")
639
+ : bridge.parameters.length
640
+ ? bridge.parameters
641
+ .map((name) => ` <${name}>value</${name}>`)
642
+ .join("\n")
643
+ : " <arguments>{}</arguments>";
644
+ return [
645
+ `Tool: ${bridge.remoteName}`,
646
+ `Description: ${bridge.description ?? bridge.runtimeName}`,
647
+ "XML usage:",
648
+ `<${bridge.remoteName}>`,
649
+ params,
650
+ `</${bridge.remoteName}>`,
651
+ ].join("\n");
652
+ });
653
+
654
+ return [
655
+ "You have access to tools. Use XML tool calls instead of OpenAI function calling.",
656
+ "When you need a tool, output exactly one XML tool call using one of the tool names below.",
657
+ "Do not wrap XML tool calls in markdown fences. Do not invent tool names.",
658
+ "Available tools:",
659
+ sections.join("\n\n"),
660
+ ].join("\n\n");
661
+ }
662
+
663
+ function buildClineXmlMessages(context: Context): ClineXmlChatMessage[] {
664
+ const messages: ClineXmlChatMessage[] = [];
665
+ const systemParts = [
666
+ context.systemPrompt,
667
+ buildToolInstructions(context.tools),
668
+ ]
669
+ .filter(Boolean)
670
+ .join("\n\n");
671
+ if (systemParts) messages.push({ role: "system", content: systemParts });
672
+
673
+ let firstUser = true;
674
+ for (const message of context.messages) {
675
+ if (message.role === "user") {
676
+ const text = contentToText(message.content).trim();
677
+ if (!text) continue;
678
+ messages.push({
679
+ role: "user",
680
+ content: firstUser ? `<task>\n${text}\n</task>` : text,
681
+ });
682
+ firstUser = false;
683
+ continue;
684
+ }
685
+
686
+ if (message.role === "assistant") {
687
+ const text = assistantMessageToText(message, context.tools).trim();
688
+ if (text) messages.push({ role: "assistant", content: text });
689
+ continue;
690
+ }
691
+
692
+ if (message.role === "toolResult") {
693
+ const text = toolResultToText(message).trim();
694
+ const bridge = getToolBridges(context.tools).find(
695
+ (candidate) => candidate.runtimeName === message.toolName,
696
+ );
697
+ messages.push({
698
+ role: "user",
699
+ content: `Tool result for ${bridge?.remoteName ?? message.toolName}:\n${text || "(no output)"}`,
700
+ });
701
+ }
702
+ }
703
+
704
+ return messages;
705
+ }
706
+
707
+ function findNextToolStart(
708
+ text: string,
709
+ toolNames: Set<string>,
710
+ from: number,
711
+ ): { index: number; name: string; openTag: string } | null {
712
+ let best: { index: number; name: string; openTag: string } | null = null;
713
+ for (const name of toolNames) {
714
+ const openTag = `<${name}>`;
715
+ const index = text.indexOf(openTag, from);
716
+ if (index === -1) continue;
717
+ if (!best || index < best.index) best = { index, name, openTag };
718
+ }
719
+ return best;
720
+ }
721
+
722
+ function isFenceOnlyText(text: string): boolean {
723
+ const trimmed = text.trim().toLowerCase();
724
+ return trimmed === "```" || trimmed === "```xml";
725
+ }
726
+
727
+ function pushTextFragment(textParts: string[], fragment: string): void {
728
+ const trimmed = fragment.trim();
729
+ if (!trimmed || isFenceOnlyText(trimmed)) return;
730
+ textParts.push(trimmed);
731
+ }
732
+
733
+ type HiddenThoughtTag = {
734
+ open: string;
735
+ closes: string[];
736
+ };
737
+
738
+ const HIDDEN_THOUGHT_TAGS: HiddenThoughtTag[] = [
739
+ { open: "<thinking>", closes: ["</thinking>"] },
740
+ // Some DeepSeek/Cline variants open with <think> but close with </thinking>.
741
+ { open: "<think>", closes: ["</think>", "</thinking>"] },
742
+ // Compaction/summary artifacts can leak into Cline content as </summary>.
743
+ { open: "<summary>", closes: ["</summary>"] },
744
+ // Cline may emit persistent issue-checking as hidden deliberation.
745
+ {
746
+ open: "<persistent_issue_checking>",
747
+ closes: ["</persistent_issue_checking>"],
748
+ },
749
+ ];
750
+
751
+ const HIDDEN_THOUGHT_CLOSE_TAGS = Array.from(
752
+ new Set(HIDDEN_THOUGHT_TAGS.flatMap((tag) => tag.closes)),
753
+ );
754
+
755
+ function findNextHiddenOpenTag(
756
+ text: string,
757
+ from: number,
758
+ ): { index: number; tag: HiddenThoughtTag } | null {
759
+ let best: { index: number; tag: HiddenThoughtTag } | null = null;
760
+ for (const tag of HIDDEN_THOUGHT_TAGS) {
761
+ const index = text.indexOf(tag.open, from);
762
+ if (index === -1) continue;
763
+ if (!best || index < best.index) best = { index, tag };
764
+ }
765
+ return best;
766
+ }
767
+
768
+ function findNextCloseTag(
769
+ text: string,
770
+ from: number,
771
+ closeTags: string[],
772
+ ): { index: number; tag: string } | null {
773
+ let best: { index: number; tag: string } | null = null;
774
+ for (const tag of closeTags) {
775
+ const index = text.indexOf(tag, from);
776
+ if (index === -1) continue;
777
+ if (!best || index < best.index) best = { index, tag };
778
+ }
779
+ return best;
780
+ }
781
+
782
+ function extractThinkingXml(text: string): {
783
+ text: string;
784
+ thinking: string[];
785
+ } {
786
+ const thinking: string[] = [];
787
+ const parts: string[] = [];
788
+ let cursor = 0;
789
+
790
+ while (cursor < text.length) {
791
+ const nextOpen = findNextHiddenOpenTag(text, cursor);
792
+ const openStart = nextOpen?.index ?? -1;
793
+ const nextClose = findNextCloseTag(text, cursor, HIDDEN_THOUGHT_CLOSE_TAGS);
794
+ const closeStart = nextClose?.index ?? -1;
795
+
796
+ if (nextClose && (openStart === -1 || closeStart < openStart)) {
797
+ const danglingThinking = decodeXmlEntities(
798
+ text.slice(cursor, closeStart).trim(),
799
+ );
800
+ if (danglingThinking) thinking.push(danglingThinking);
801
+ cursor = closeStart + nextClose.tag.length;
802
+ continue;
803
+ }
804
+
805
+ if (openStart === -1 || !nextOpen) break;
806
+ parts.push(text.slice(cursor, openStart));
807
+ const valueStart = openStart + nextOpen.tag.open.length;
808
+ const nextValueClose = findNextCloseTag(
809
+ text,
810
+ valueStart,
811
+ nextOpen.tag.closes,
812
+ );
813
+ if (!nextValueClose) {
814
+ const value = decodeXmlEntities(text.slice(valueStart).trim());
815
+ if (value) thinking.push(value);
816
+ cursor = text.length;
817
+ break;
818
+ }
819
+
820
+ const value = decodeXmlEntities(
821
+ text.slice(valueStart, nextValueClose.index).trim(),
822
+ );
823
+ if (value) thinking.push(value);
824
+ cursor = nextValueClose.index + nextValueClose.tag.length;
825
+ }
826
+
827
+ if (cursor === 0) {
828
+ return { text, thinking };
829
+ }
830
+ parts.push(text.slice(cursor));
831
+ return { text: parts.join(""), thinking };
832
+ }
833
+
834
+ function extractTagContent(text: string, tag: string): string | undefined {
835
+ const open = `<${tag}>`;
836
+ const close = `</${tag}>`;
837
+ const start = text.indexOf(open);
838
+ if (start === -1) return undefined;
839
+ const valueStart = start + open.length;
840
+ const end =
841
+ tag === "content"
842
+ ? text.lastIndexOf(close)
843
+ : text.indexOf(close, valueStart);
844
+ if (end === -1 || end < valueStart) return undefined;
845
+ return decodeXmlEntities(text.slice(valueStart, end).trim());
846
+ }
847
+
848
+ function parseToolArguments(block: string): Record<string, unknown> {
849
+ const explicitArgs = extractTagContent(block, "arguments");
850
+ if (explicitArgs) {
851
+ try {
852
+ const parsed = JSON.parse(explicitArgs);
853
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
854
+ return parsed as Record<string, unknown>;
855
+ }
856
+ } catch {
857
+ return { arguments: explicitArgs };
858
+ }
859
+ }
860
+
861
+ const args: Record<string, unknown> = {};
862
+ let cursor = 0;
863
+ while (cursor < block.length) {
864
+ const openStart = block.indexOf("<", cursor);
865
+ if (openStart === -1) break;
866
+ const openEnd = block.indexOf(">", openStart + 1);
867
+ if (openEnd === -1) break;
868
+ const tag = block.slice(openStart + 1, openEnd).trim();
869
+ if (!tag || tag.startsWith("/") || tag.includes(" ")) {
870
+ cursor = openEnd + 1;
871
+ continue;
872
+ }
873
+ const close = `</${tag}>`;
874
+ const closeStart =
875
+ tag === "content" || tag === "diff"
876
+ ? block.lastIndexOf(close)
877
+ : block.indexOf(close, openEnd + 1);
878
+ if (closeStart === -1 || closeStart < openEnd) break;
879
+ const raw = decodeXmlEntities(block.slice(openEnd + 1, closeStart).trim());
880
+ // `content` and `diff` are explicitly string parameters (file bodies,
881
+ // SEARCH/REPLACE diffs). Parsing them as JSON corrupts JSON file content
882
+ // into "[object Object]".
883
+ const shouldParseJson = tag !== "content" && tag !== "diff";
884
+ if (shouldParseJson) {
885
+ try {
886
+ args[tag] = JSON.parse(raw);
887
+ } catch {
888
+ args[tag] = raw;
889
+ }
890
+ } else {
891
+ args[tag] = raw;
892
+ }
893
+ cursor = closeStart + close.length;
894
+ }
895
+ return args;
896
+ }
897
+
898
+ type ParsedToolCalls = {
899
+ text: string;
900
+ toolCalls: Array<{ name: string; arguments: Record<string, unknown> }>;
901
+ };
902
+
903
+ /**
904
+ * Some MiMo/Cline models emit Pi SDK `<function=name>` tool-call syntax
905
+ * instead of Cline XML `<toolName>` syntax:
906
+ *
907
+ * <function=read_file>
908
+ * <param name="path">README.md</param>
909
+ * </function>
910
+ *
911
+ * Parse these directly to Pi tool calls without going through Cline XML.
912
+ */
913
+ function extractFunctionTagToolCalls(
914
+ text: string,
915
+ bridgeByRemoteName: Map<string, ToolBridge>,
916
+ ): { text: string; toolCalls: ParsedToolCalls["toolCalls"] } {
917
+ const FUNCTION_TAG_RE = /<function=([a-zA-Z0-9_-]+)>([\s\S]*?)<\/function>/g;
918
+ const toolCalls: ParsedToolCalls["toolCalls"] = [];
919
+ const parts: string[] = [];
920
+ let cursor = 0;
921
+ let match: RegExpExecArray | null;
922
+
923
+ while ((match = FUNCTION_TAG_RE.exec(text)) !== null) {
924
+ const [fullMatch, toolName, body] = match;
925
+ pushTextFragment(parts, text.slice(cursor, match.index));
926
+
927
+ // Parse <param name="x">val</param> directly to arguments
928
+ const args: Record<string, unknown> = {};
929
+ const PARAM_RE = /<param\s+name="([^"]*)">([\s\S]*?)<\/param>/g;
930
+ let paramMatch: RegExpExecArray | null;
931
+ while ((paramMatch = PARAM_RE.exec(body)) !== null) {
932
+ args[paramMatch[1]] = paramMatch[2];
933
+ }
934
+
935
+ const bridge = bridgeByRemoteName.get(toolName);
936
+ toolCalls.push({
937
+ name: bridge?.runtimeName ?? toolName,
938
+ arguments: bridge?.toRuntimeArgs(args) ?? args,
939
+ });
940
+
941
+ cursor = match.index + fullMatch.length;
942
+ }
943
+
944
+ pushTextFragment(parts, text.slice(cursor));
945
+ return { text: parts.join("\n\n").trim(), toolCalls };
946
+ }
947
+
948
+ function parseXmlToolCalls(
949
+ rawText: string,
950
+ tools: Tool[] | undefined,
951
+ ): ParsedToolCalls {
952
+ const bridgeByRemoteName = new Map(
953
+ getParseToolBridges(tools).map((bridge) => [bridge.remoteName, bridge]),
954
+ );
955
+ const toolNames = new Set(bridgeByRemoteName.keys());
956
+
957
+ // Extract <function=name> Pi SDK tool calls directly (no Cline XML intermediate)
958
+ const fnResult = extractFunctionTagToolCalls(rawText, bridgeByRemoteName);
959
+ const textWithoutThinking = extractThinkingXml(fnResult.text).text;
960
+ if (toolNames.size === 0) {
961
+ return { text: textWithoutThinking.trim(), toolCalls: fnResult.toolCalls };
962
+ }
963
+
964
+ const sourceText = findNextToolStart(textWithoutThinking, toolNames, 0)
965
+ ? textWithoutThinking
966
+ : decodeXmlEntities(textWithoutThinking);
967
+ const textParts: string[] = [];
968
+ const toolCalls: Array<{ name: string; arguments: Record<string, unknown> }> =
969
+ [];
970
+ let cursor = 0;
971
+
972
+ while (cursor < sourceText.length) {
973
+ const next = findNextToolStart(sourceText, toolNames, cursor);
974
+ if (!next) break;
975
+ const closeTag = `</${next.name}>`;
976
+ const closeStart = sourceText.indexOf(
977
+ closeTag,
978
+ next.index + next.openTag.length,
979
+ );
980
+ pushTextFragment(textParts, sourceText.slice(cursor, next.index));
981
+ const blockEnd = closeStart === -1 ? sourceText.length : closeStart;
982
+ const block = sourceText.slice(next.index + next.openTag.length, blockEnd);
983
+ const bridge = bridgeByRemoteName.get(next.name);
984
+ const remoteArgs = parseToolArguments(block);
985
+ const writeRuntimeName = getWriteRuntimeToolName(tools);
986
+ const heredocWrite =
987
+ next.name === "execute_command" && writeRuntimeName
988
+ ? parseCatHeredocWriteCommand(stringArg(remoteArgs, "command"))
989
+ : undefined;
990
+ if (heredocWrite && writeRuntimeName) {
991
+ toolCalls.push({
992
+ name: writeRuntimeName,
993
+ arguments: { ...heredocWrite },
994
+ });
995
+ } else {
996
+ toolCalls.push({
997
+ name: bridge?.runtimeName ?? next.name,
998
+ arguments: bridge?.toRuntimeArgs(remoteArgs) ?? remoteArgs,
999
+ });
1000
+ }
1001
+ cursor =
1002
+ closeStart === -1 ? sourceText.length : closeStart + closeTag.length;
1003
+ }
1004
+
1005
+ pushTextFragment(textParts, sourceText.slice(cursor));
1006
+ return { text: textParts.join("\n\n").trim(), toolCalls: [...fnResult.toolCalls, ...toolCalls] };
1007
+ }
1008
+
1009
+ function parseReasoningHiddenToolCalls(
1010
+ thinkingParts: string[],
1011
+ tools: Tool[] | undefined,
1012
+ depth = 3,
1013
+ ): { thinking: string[]; toolCalls: ParsedToolCalls["toolCalls"] } {
1014
+ const thinking: string[] = [];
1015
+ const toolCalls: ParsedToolCalls["toolCalls"] = [];
1016
+ for (const part of thinkingParts) {
1017
+ const trimmed = part.trim();
1018
+ if (!trimmed) continue;
1019
+ if (depth <= 0) {
1020
+ thinking.push(trimmed);
1021
+ continue;
1022
+ }
1023
+ const extracted = extractThinkingXml(trimmed);
1024
+ const nested = parseReasoningHiddenToolCalls(
1025
+ extracted.thinking,
1026
+ tools,
1027
+ depth - 1,
1028
+ );
1029
+ const parsed = parseXmlToolCalls(extracted.text, tools);
1030
+ toolCalls.push(...parsed.toolCalls, ...nested.toolCalls);
1031
+ if (parsed.text) thinking.push(parsed.text);
1032
+ thinking.push(...nested.thinking);
1033
+ if (
1034
+ !parsed.text &&
1035
+ parsed.toolCalls.length === 0 &&
1036
+ nested.toolCalls.length === 0 &&
1037
+ nested.thinking.length === 0
1038
+ ) {
1039
+ thinking.push(trimmed);
1040
+ }
1041
+ }
1042
+ return { thinking, toolCalls };
1043
+ }
1044
+
1045
+ function parseReasoningToolCalls(
1046
+ reasoning: string,
1047
+ tools: Tool[] | undefined,
1048
+ ): { thinking: string[]; toolCalls: ParsedToolCalls["toolCalls"] } {
1049
+ if (!reasoning.trim()) return { thinking: [], toolCalls: [] };
1050
+
1051
+ const extracted = extractThinkingXml(reasoning);
1052
+ const hiddenParsed = parseReasoningHiddenToolCalls(extracted.thinking, tools);
1053
+ const parsed = parseXmlToolCalls(extracted.text, tools);
1054
+ const thinking = [...hiddenParsed.thinking];
1055
+ if (parsed.toolCalls.length > 0 && parsed.text) {
1056
+ thinking.push(parsed.text);
1057
+ } else if (
1058
+ parsed.toolCalls.length === 0 &&
1059
+ hiddenParsed.thinking.length === 0 &&
1060
+ extracted.thinking.length === 0
1061
+ ) {
1062
+ thinking.push(reasoning.trim());
1063
+ }
1064
+
1065
+ return {
1066
+ thinking,
1067
+ toolCalls: [...parsed.toolCalls, ...hiddenParsed.toolCalls],
1068
+ };
1069
+ }
1070
+
1071
+ const INTERNAL_ONLY_RESPONSE =
1072
+ "Cline returned internal reasoning only and did not produce a user-visible response. Please retry or ask it to continue.";
1073
+
1074
+ function prepareClineXmlOutput(
1075
+ parsedText: string,
1076
+ contentThinking: string[],
1077
+ reasoningThinking: string[],
1078
+ toolCalls: ParsedToolCalls["toolCalls"],
1079
+ ): {
1080
+ visibleText: string;
1081
+ thinkingText: string;
1082
+ toolCalls: ParsedToolCalls["toolCalls"];
1083
+ } {
1084
+ const thinkingParts = [...reasoningThinking, ...contentThinking].filter(
1085
+ Boolean,
1086
+ );
1087
+ const thinkingText = thinkingParts.join("\n\n");
1088
+ if (!parsedText && toolCalls.length === 0 && thinkingText) {
1089
+ // Never return a blank stop, but also do not surface hidden reasoning as
1090
+ // user-visible answer text. If Cline sends only hidden/reasoning content,
1091
+ // show a stable visible fallback and keep the raw content in thinking.
1092
+ return {
1093
+ visibleText: INTERNAL_ONLY_RESPONSE,
1094
+ thinkingText,
1095
+ toolCalls,
1096
+ };
1097
+ }
1098
+
1099
+ return {
1100
+ visibleText: parsedText,
1101
+ thinkingText,
1102
+ toolCalls,
1103
+ };
1104
+ }
1105
+
1106
+ function usageFromChunkUsage(usage: ClineXmlChunk["usage"] | undefined): Usage {
1107
+ const input = usage?.prompt_tokens ?? 0;
1108
+ const output = usage?.completion_tokens ?? 0;
1109
+ const totalTokens = usage?.total_tokens ?? input + output;
1110
+ return {
1111
+ ...DEFAULT_USAGE,
1112
+ input,
1113
+ output,
1114
+ totalTokens,
1115
+ };
1116
+ }
1117
+
1118
+ async function* parseSse(response: Response): AsyncGenerator<ClineXmlChunk> {
1119
+ const reader = response.body?.getReader();
1120
+ if (!reader) return;
1121
+ const decoder = new TextDecoder();
1122
+ let buffer = "";
1123
+
1124
+ while (true) {
1125
+ const { done, value } = await reader.read();
1126
+ if (done) break;
1127
+ buffer += decoder.decode(value, { stream: true });
1128
+ const lines = buffer.split(/\r?\n/);
1129
+ buffer = lines.pop() ?? "";
1130
+ for (const line of lines) {
1131
+ const trimmed = line.trim();
1132
+ if (!trimmed.startsWith("data:")) continue;
1133
+ const data = trimmed.slice("data:".length).trim();
1134
+ if (!data || data === "[DONE]") continue;
1135
+ yield JSON.parse(data) as ClineXmlChunk;
1136
+ }
1137
+ }
1138
+ }
1139
+
1140
+ type ClineXmlResponseData = {
1141
+ rawText: string;
1142
+ thinking: string;
1143
+ finishReason: string | null | undefined;
1144
+ usage: ClineXmlChunk["usage"] | undefined;
1145
+ };
1146
+
1147
+ function isRetryableClineReasoningStreamError(error: unknown): boolean {
1148
+ if (!(error instanceof Error)) return false;
1149
+ const message = error.message.toLowerCase();
1150
+ return message.includes("stream error occurred");
1151
+ }
1152
+
1153
+ async function readClineXmlResponse(
1154
+ response: Response,
1155
+ ): Promise<ClineXmlResponseData> {
1156
+ let rawText = "";
1157
+ let thinking = "";
1158
+ let finishReason: string | null | undefined;
1159
+ let usage: ClineXmlChunk["usage"] | undefined;
1160
+
1161
+ for await (const chunk of parseSse(response)) {
1162
+ if (chunk.error) {
1163
+ throw new Error(
1164
+ `${chunk.error.code ?? "cline_error"}: ${chunk.error.message ?? "Unknown Cline error"}`,
1165
+ );
1166
+ }
1167
+ if (chunk.usage) usage = chunk.usage;
1168
+ const choice = chunk.choices?.[0];
1169
+ if (!choice) continue;
1170
+ if (choice.error) {
1171
+ throw new Error(
1172
+ `${choice.error.code ?? "cline_error"}: ${choice.error.message ?? "Unknown Cline error"}`,
1173
+ );
1174
+ }
1175
+ if (choice.finish_reason) finishReason = choice.finish_reason;
1176
+ rawText += choice.delta?.content ?? "";
1177
+ thinking += choice.delta?.reasoning ?? "";
1178
+ }
1179
+
1180
+ if (!rawText.trim() && !thinking.trim()) {
1181
+ throw new Error("Cline returned empty response");
1182
+ }
1183
+
1184
+ // Some MiMo/Cline models wrap XML tags in Unicode math-italic characters
1185
+ // forming "anthml:" prefixes (e.g. <𝑎𝑛𝑡𝑚𝑙:thinking>, <𝑎𝑛𝑡𝑚𝑙:read_file>).
1186
+ // Strip these so the rest of the parser sees standard ASCII XML tags.
1187
+ return {
1188
+ rawText: normalizeDecoratedXmlTags(rawText),
1189
+ thinking: normalizeDecoratedXmlTags(thinking),
1190
+ finishReason,
1191
+ usage,
1192
+ };
1193
+ }
1194
+
1195
+ async function fetchClineXmlResponse(
1196
+ model: Model<string>,
1197
+ context: Context,
1198
+ options: SimpleStreamOptions,
1199
+ headers: Record<string, string>,
1200
+ includeReasoning: boolean,
1201
+ ): Promise<ClineXmlResponseData> {
1202
+ const response = await fetch(`${BASE_URL_CLINE}/chat/completions`, {
1203
+ method: "POST",
1204
+ headers: {
1205
+ ...headers,
1206
+ Authorization: `Bearer ${options.apiKey}`,
1207
+ "Content-Type": "application/json",
1208
+ },
1209
+ body: JSON.stringify({
1210
+ model: normalizeApiModelId(model.id),
1211
+ temperature: 0,
1212
+ messages: buildClineXmlMessages(context),
1213
+ stream: true,
1214
+ stream_options: { include_usage: true },
1215
+ ...(includeReasoning ? { include_reasoning: true } : {}),
1216
+ }),
1217
+ signal: options.signal,
1218
+ });
1219
+ await options.onResponse?.(
1220
+ {
1221
+ status: response.status,
1222
+ headers: Object.fromEntries(response.headers.entries()),
1223
+ },
1224
+ model,
1225
+ );
1226
+
1227
+ if (!response.ok) {
1228
+ throw new Error(
1229
+ `Cline API error ${response.status}: ${await response.text()}`,
1230
+ );
1231
+ }
1232
+
1233
+ return readClineXmlResponse(response);
1234
+ }
1235
+
1236
+ async function fetchClineXmlResponseWithReasoningFallback(
1237
+ model: Model<string>,
1238
+ context: Context,
1239
+ options: SimpleStreamOptions,
1240
+ headers: Record<string, string>,
1241
+ ): Promise<ClineXmlResponseData> {
1242
+ try {
1243
+ return await fetchClineXmlResponse(model, context, options, headers, true);
1244
+ } catch (error) {
1245
+ if (
1246
+ options.signal?.aborted ||
1247
+ !isRetryableClineReasoningStreamError(error)
1248
+ ) {
1249
+ throw error;
1250
+ }
1251
+ return fetchClineXmlResponse(model, context, options, headers, false);
1252
+ }
1253
+ }
1254
+
1255
+ function createAssistant(model: Model<string>): AssistantMessage {
1256
+ return {
1257
+ role: "assistant",
1258
+ content: [],
1259
+ api: model.api,
1260
+ provider: model.provider,
1261
+ model: model.id,
1262
+ usage: DEFAULT_USAGE,
1263
+ stopReason: "stop",
1264
+ timestamp: Date.now(),
1265
+ };
1266
+ }
1267
+
1268
+ function pushText(
1269
+ message: AssistantMessage,
1270
+ text: string,
1271
+ stream: ReturnType<typeof createAssistantMessageEventStream>,
1272
+ ): void {
1273
+ if (!text) return;
1274
+ const index = message.content.length;
1275
+ message.content.push({ type: "text", text: "" });
1276
+ stream.push({ type: "text_start", contentIndex: index, partial: message });
1277
+ (message.content[index] as { type: "text"; text: string }).text = text;
1278
+ stream.push({
1279
+ type: "text_delta",
1280
+ contentIndex: index,
1281
+ delta: text,
1282
+ partial: message,
1283
+ });
1284
+ stream.push({
1285
+ type: "text_end",
1286
+ contentIndex: index,
1287
+ content: text,
1288
+ partial: message,
1289
+ });
1290
+ }
1291
+
1292
+ function pushThinking(
1293
+ message: AssistantMessage,
1294
+ thinking: string,
1295
+ stream: ReturnType<typeof createAssistantMessageEventStream>,
1296
+ ): void {
1297
+ if (!thinking) return;
1298
+ const index = message.content.length;
1299
+ message.content.push({ type: "thinking", thinking: "" });
1300
+ stream.push({
1301
+ type: "thinking_start",
1302
+ contentIndex: index,
1303
+ partial: message,
1304
+ });
1305
+ (message.content[index] as { type: "thinking"; thinking: string }).thinking =
1306
+ thinking;
1307
+ stream.push({
1308
+ type: "thinking_delta",
1309
+ contentIndex: index,
1310
+ delta: thinking,
1311
+ partial: message,
1312
+ });
1313
+ stream.push({
1314
+ type: "thinking_end",
1315
+ contentIndex: index,
1316
+ content: thinking,
1317
+ partial: message,
1318
+ });
1319
+ }
1320
+
1321
+ function pushToolCall(
1322
+ message: AssistantMessage,
1323
+ toolCall: { name: string; arguments: Record<string, unknown> },
1324
+ stream: ReturnType<typeof createAssistantMessageEventStream>,
1325
+ ): void {
1326
+ const index = message.content.length;
1327
+ const id = `cline_xml_${Date.now()}_${index}`;
1328
+ const block: ToolCall = {
1329
+ type: "toolCall",
1330
+ id,
1331
+ name: toolCall.name,
1332
+ arguments: {},
1333
+ };
1334
+ message.content.push(block);
1335
+ stream.push({
1336
+ type: "toolcall_start",
1337
+ contentIndex: index,
1338
+ partial: message,
1339
+ });
1340
+ const delta = JSON.stringify(toolCall.arguments);
1341
+ stream.push({
1342
+ type: "toolcall_delta",
1343
+ contentIndex: index,
1344
+ delta,
1345
+ partial: message,
1346
+ });
1347
+ block.arguments = toolCall.arguments;
1348
+ stream.push({
1349
+ type: "toolcall_end",
1350
+ contentIndex: index,
1351
+ toolCall: block,
1352
+ partial: message,
1353
+ });
1354
+ }
1355
+
1356
+ export function streamClineXml(
1357
+ model: Model<string>,
1358
+ context: Context,
1359
+ options: SimpleStreamOptions | undefined,
1360
+ headers: Record<string, string>,
1361
+ ) {
1362
+ const stream = createAssistantMessageEventStream();
1363
+
1364
+ void (async () => {
1365
+ const assistant = createAssistant(model);
1366
+ stream.push({ type: "start", partial: assistant });
1367
+ try {
1368
+ if (!options?.apiKey) {
1369
+ throw new Error("No Cline access token found. Run /login cline first.");
1370
+ }
1371
+
1372
+ let output: ReturnType<typeof prepareClineXmlOutput>;
1373
+ let rawText: string;
1374
+ let thinking: string;
1375
+ let finishReason: string | null | undefined;
1376
+ let usage: ClineXmlChunk["usage"] | undefined;
1377
+ let currentContext = context;
1378
+
1379
+ for (let attempt = 0; attempt < 2; attempt++) {
1380
+ const data = await fetchClineXmlResponseWithReasoningFallback(
1381
+ model,
1382
+ currentContext,
1383
+ options,
1384
+ headers,
1385
+ );
1386
+ rawText = data.rawText;
1387
+ thinking = data.thinking;
1388
+ finishReason = data.finishReason;
1389
+ usage = data.usage;
1390
+
1391
+ const extractedThinking = extractThinkingXml(rawText);
1392
+ const parsedReasoning = parseReasoningToolCalls(
1393
+ thinking,
1394
+ currentContext.tools,
1395
+ );
1396
+ const parsed = parseXmlToolCalls(extractedThinking.text, currentContext.tools);
1397
+ output = prepareClineXmlOutput(
1398
+ parsed.text,
1399
+ extractedThinking.thinking,
1400
+ parsedReasoning.thinking,
1401
+ [...parsed.toolCalls, ...parsedReasoning.toolCalls],
1402
+ );
1403
+
1404
+ // Reasoning-only response: MiMo stopped without producing visible
1405
+ // text or tool calls. Auto-retry once with a "continue" nudge
1406
+ // instead of showing a dead-end error to the user.
1407
+ if (
1408
+ output.visibleText === INTERNAL_ONLY_RESPONSE &&
1409
+ attempt === 0
1410
+ ) {
1411
+ currentContext = {
1412
+ ...context,
1413
+ messages: [
1414
+ ...context.messages,
1415
+ {
1416
+ role: "user" as const,
1417
+ content: [{ type: "text" as const, text: "Please continue." }],
1418
+ timestamp: Date.now(),
1419
+ },
1420
+ ],
1421
+ };
1422
+ continue;
1423
+ }
1424
+ break;
1425
+ }
1426
+
1427
+ assistant.usage = usageFromChunkUsage(usage!);
1428
+ pushThinking(assistant, output!.thinkingText, stream);
1429
+ pushText(assistant, output!.visibleText, stream);
1430
+ const toolCalls = output!.toolCalls;
1431
+ for (const toolCall of toolCalls) {
1432
+ pushToolCall(assistant, toolCall, stream);
1433
+ }
1434
+
1435
+ assistant.stopReason =
1436
+ toolCalls.length > 0
1437
+ ? "toolUse"
1438
+ : finishReason === "length"
1439
+ ? "length"
1440
+ : "stop";
1441
+ stream.push({
1442
+ type: "done",
1443
+ reason: assistant.stopReason as "stop" | "length" | "toolUse",
1444
+ message: assistant,
1445
+ });
1446
+ } catch (error) {
1447
+ assistant.stopReason = options?.signal?.aborted ? "aborted" : "error";
1448
+ assistant.errorMessage =
1449
+ error instanceof Error ? error.message : String(error);
1450
+ stream.push({
1451
+ type: "error",
1452
+ reason: assistant.stopReason,
1453
+ error: assistant,
1454
+ });
1455
+ }
1456
+ })();
1457
+
1458
+ return stream;
1459
+ }
1460
+
1461
+ export const __test__ = {
1462
+ buildClineXmlMessages,
1463
+ extractFunctionTagToolCalls,
1464
+ isRetryableClineReasoningStreamError,
1465
+ normalizeDecoratedXmlTags,
1466
+ parseReasoningHiddenToolCalls,
1467
+ parseReasoningToolCalls,
1468
+ parseXmlToolCalls,
1469
+ prepareClineXmlOutput,
1470
+ serializeXmlToolCall,
1471
+ };