pi-free 2.1.0 → 2.2.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.
@@ -46,6 +46,73 @@ function normalizeApiModelId(modelId: string): string {
46
46
  : modelId;
47
47
  }
48
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
+
49
116
  function xmlEscape(value: unknown): string {
50
117
  return String(value)
51
118
  .replaceAll("&", "&amp;")
@@ -248,10 +315,25 @@ function replaceInFileBridge(tool?: Tool): ToolBridge {
248
315
  description:
249
316
  tool?.description ?? "Edit a file using Cline SEARCH/REPLACE blocks",
250
317
  parameters: ["path", "diff"],
251
- toRuntimeArgs: (args) => ({
252
- path: stringArg(args, "path"),
253
- edits: parseSearchReplaceBlocks(stringArg(args, "diff")),
254
- }),
318
+ toRuntimeArgs: (args) => {
319
+ // Pi native <edit> form sends <edits>[{oldText,newText},...]</edits>
320
+ // as JSON. Cline <replace_in_file> form uses SEARCH/REPLACE <diff>.
321
+ if (Array.isArray(args.edits)) {
322
+ return {
323
+ path: stringArg(args, "path"),
324
+ edits: 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
+ return {
333
+ path: stringArg(args, "path"),
334
+ edits: parseSearchReplaceBlocks(stringArg(args, "diff")),
335
+ };
336
+ },
255
337
  fromRuntimeArgs: (args) => {
256
338
  const edits = Array.isArray(args.edits)
257
339
  ? args.edits
@@ -291,6 +373,111 @@ function executeCommandBridge(tool?: Tool): ToolBridge {
291
373
  };
292
374
  }
293
375
 
376
+ type HeredocWriteCommand = {
377
+ path: string;
378
+ content: string;
379
+ };
380
+
381
+ function shellSplitLine(line: string): string[] {
382
+ const tokens: string[] = [];
383
+ let current = "";
384
+ let quote: '"' | "'" | undefined;
385
+
386
+ for (let i = 0; i < line.length; i++) {
387
+ const char = line[i];
388
+ if (quote) {
389
+ if (char === quote) {
390
+ quote = undefined;
391
+ } else {
392
+ current += char;
393
+ }
394
+ continue;
395
+ }
396
+ if (char === '"' || char === "'") {
397
+ quote = char;
398
+ continue;
399
+ }
400
+ if (char === " " || char === "\t") {
401
+ if (current) {
402
+ tokens.push(current);
403
+ current = "";
404
+ }
405
+ continue;
406
+ }
407
+ current += char;
408
+ }
409
+
410
+ if (current) tokens.push(current);
411
+ return tokens;
412
+ }
413
+
414
+ function parseCatHeredocWriteCommand(
415
+ command: string,
416
+ ): HeredocWriteCommand | undefined {
417
+ const normalized = command.replaceAll("\r\n", "\n").trim();
418
+ const lines = normalized.split("\n");
419
+ if (lines.length < 3) return undefined;
420
+
421
+ const tokens = shellSplitLine(lines[0].trim());
422
+ if (tokens[0] !== "cat") return undefined;
423
+ const redirectIndex = tokens.indexOf(">");
424
+ if (redirectIndex === -1) return undefined;
425
+ const path = tokens[redirectIndex + 1];
426
+ if (!path) return undefined;
427
+
428
+ let delimiter = "";
429
+ for (let i = redirectIndex + 2; i < tokens.length; i++) {
430
+ const token = tokens[i];
431
+ if (token === "<<") {
432
+ delimiter = tokens[i + 1] ?? "";
433
+ break;
434
+ }
435
+ if (token.startsWith("<<")) {
436
+ delimiter = token.slice(2);
437
+ break;
438
+ }
439
+ }
440
+ if (!delimiter) return undefined;
441
+
442
+ let delimiterLine = -1;
443
+ for (let i = 1; i < lines.length; i++) {
444
+ if (lines[i].trim() === delimiter) {
445
+ delimiterLine = i;
446
+ break;
447
+ }
448
+ }
449
+ if (delimiterLine === -1) return undefined;
450
+
451
+ const trailing = lines
452
+ .slice(delimiterLine + 1)
453
+ .join("\n")
454
+ .trim();
455
+ if (trailing) {
456
+ const trailingLines = trailing.split("\n").filter((line) => line.trim());
457
+ if (trailingLines.length !== 1) return undefined;
458
+ const trailingTokens = shellSplitLine(trailingLines[0].trim());
459
+ if (trailingTokens.length !== 2 || trailingTokens[0] !== "cat") {
460
+ return undefined;
461
+ }
462
+ if (trailingTokens[1] !== path) return undefined;
463
+ }
464
+
465
+ return {
466
+ path,
467
+ content: lines.slice(1, delimiterLine).join("\n"),
468
+ };
469
+ }
470
+
471
+ function getWriteRuntimeToolName(
472
+ tools: Tool[] | undefined,
473
+ ): string | undefined {
474
+ if ((tools ?? []).some((tool) => tool.name === "write_to_file")) {
475
+ return "write_to_file";
476
+ }
477
+ if ((tools ?? []).some((tool) => tool.name === "write")) return "write";
478
+ return undefined;
479
+ }
480
+
294
481
  function listFilesBridge(): ToolBridge {
295
482
  return {
296
483
  remoteName: "list_files",
@@ -558,54 +745,98 @@ function pushTextFragment(textParts: string[], fragment: string): void {
558
745
  textParts.push(trimmed);
559
746
  }
560
747
 
748
+ type HiddenThoughtTag = {
749
+ open: string;
750
+ closes: string[];
751
+ };
752
+
753
+ const HIDDEN_THOUGHT_TAGS: HiddenThoughtTag[] = [
754
+ { open: "<thinking>", closes: ["</thinking>"] },
755
+ // Some DeepSeek/Cline variants open with <think> but close with </thinking>.
756
+ { open: "<think>", closes: ["</think>", "</thinking>"] },
757
+ // Compaction/summary artifacts can leak into Cline content as </summary>.
758
+ { open: "<summary>", closes: ["</summary>"] },
759
+ // Cline may emit persistent issue-checking as hidden deliberation.
760
+ {
761
+ open: "<persistent_issue_checking>",
762
+ closes: ["</persistent_issue_checking>"],
763
+ },
764
+ ];
765
+
766
+ const HIDDEN_THOUGHT_CLOSE_TAGS = Array.from(
767
+ new Set(HIDDEN_THOUGHT_TAGS.flatMap((tag) => tag.closes)),
768
+ );
769
+
770
+ function findNextHiddenOpenTag(
771
+ text: string,
772
+ from: number,
773
+ ): { index: number; tag: HiddenThoughtTag } | null {
774
+ let best: { index: number; tag: HiddenThoughtTag } | null = null;
775
+ for (const tag of HIDDEN_THOUGHT_TAGS) {
776
+ const index = text.indexOf(tag.open, from);
777
+ if (index === -1) continue;
778
+ if (!best || index < best.index) best = { index, tag };
779
+ }
780
+ return best;
781
+ }
782
+
783
+ function findNextCloseTag(
784
+ text: string,
785
+ from: number,
786
+ closeTags: string[],
787
+ ): { index: number; tag: string } | null {
788
+ let best: { index: number; tag: string } | null = null;
789
+ for (const tag of closeTags) {
790
+ const index = text.indexOf(tag, from);
791
+ if (index === -1) continue;
792
+ if (!best || index < best.index) best = { index, tag };
793
+ }
794
+ return best;
795
+ }
796
+
561
797
  function extractThinkingXml(text: string): {
562
798
  text: string;
563
799
  thinking: string[];
564
800
  } {
565
801
  const thinking: string[] = [];
566
802
  const parts: string[] = [];
567
- const openTags = ["<thinking>", "<think>"];
568
- const closeTag = "</thinking>";
569
803
  let cursor = 0;
570
804
 
571
- function findNextOpenTag(from: number): { index: number; tag: string } | null {
572
- let best: { index: number; tag: string } | null = null;
573
- for (const tag of openTags) {
574
- const index = text.indexOf(tag, from);
575
- if (index === -1) continue;
576
- if (!best || index < best.index) best = { index, tag };
577
- }
578
- return best;
579
- }
580
-
581
805
  while (cursor < text.length) {
582
- const nextOpen = findNextOpenTag(cursor);
806
+ const nextOpen = findNextHiddenOpenTag(text, cursor);
583
807
  const openStart = nextOpen?.index ?? -1;
584
- const closeStart = text.indexOf(closeTag, cursor);
808
+ const nextClose = findNextCloseTag(text, cursor, HIDDEN_THOUGHT_CLOSE_TAGS);
809
+ const closeStart = nextClose?.index ?? -1;
585
810
 
586
- if (closeStart !== -1 && (openStart === -1 || closeStart < openStart)) {
811
+ if (nextClose && (openStart === -1 || closeStart < openStart)) {
587
812
  const danglingThinking = decodeXmlEntities(
588
813
  text.slice(cursor, closeStart).trim(),
589
814
  );
590
815
  if (danglingThinking) thinking.push(danglingThinking);
591
- cursor = closeStart + closeTag.length;
816
+ cursor = closeStart + nextClose.tag.length;
592
817
  continue;
593
818
  }
594
819
 
595
820
  if (openStart === -1 || !nextOpen) break;
596
821
  parts.push(text.slice(cursor, openStart));
597
- const valueStart = openStart + nextOpen.tag.length;
598
- const valueEnd = text.indexOf(closeTag, valueStart);
599
- if (valueEnd === -1) {
822
+ const valueStart = openStart + nextOpen.tag.open.length;
823
+ const nextValueClose = findNextCloseTag(
824
+ text,
825
+ valueStart,
826
+ nextOpen.tag.closes,
827
+ );
828
+ if (!nextValueClose) {
600
829
  const value = decodeXmlEntities(text.slice(valueStart).trim());
601
830
  if (value) thinking.push(value);
602
831
  cursor = text.length;
603
832
  break;
604
833
  }
605
834
 
606
- const value = decodeXmlEntities(text.slice(valueStart, valueEnd).trim());
835
+ const value = decodeXmlEntities(
836
+ text.slice(valueStart, nextValueClose.index).trim(),
837
+ );
607
838
  if (value) thinking.push(value);
608
- cursor = valueEnd + closeTag.length;
839
+ cursor = nextValueClose.index + nextValueClose.tag.length;
609
840
  }
610
841
 
611
842
  if (cursor === 0) {
@@ -661,9 +892,17 @@ function parseToolArguments(block: string): Record<string, unknown> {
661
892
  : block.indexOf(close, openEnd + 1);
662
893
  if (closeStart === -1 || closeStart < openEnd) break;
663
894
  const raw = decodeXmlEntities(block.slice(openEnd + 1, closeStart).trim());
664
- try {
665
- args[tag] = JSON.parse(raw);
666
- } catch {
895
+ // `content` and `diff` are explicitly string parameters (file bodies,
896
+ // SEARCH/REPLACE diffs). Parsing them as JSON corrupts JSON file content
897
+ // into "[object Object]".
898
+ const shouldParseJson = tag !== "content" && tag !== "diff";
899
+ if (shouldParseJson) {
900
+ try {
901
+ args[tag] = JSON.parse(raw);
902
+ } catch {
903
+ args[tag] = raw;
904
+ }
905
+ } else {
667
906
  args[tag] = raw;
668
907
  }
669
908
  cursor = closeStart + close.length;
@@ -671,20 +910,80 @@ function parseToolArguments(block: string): Record<string, unknown> {
671
910
  return args;
672
911
  }
673
912
 
913
+ type ParsedToolCalls = {
914
+ text: string;
915
+ toolCalls: Array<{ name: string; arguments: Record<string, unknown> }>;
916
+ };
917
+
918
+ /**
919
+ * Some MiMo/Cline models emit Pi SDK `<function=name>` tool-call syntax
920
+ * instead of Cline XML `<toolName>` syntax:
921
+ *
922
+ * <function=read_file>
923
+ * <param name="path">README.md</param>
924
+ * </function>
925
+ *
926
+ * Parse these directly to Pi tool calls without going through Cline XML.
927
+ */
928
+ function extractFunctionTagToolCalls(
929
+ text: string,
930
+ bridgeByRemoteName: Map<string, ToolBridge>,
931
+ ): { text: string; toolCalls: ParsedToolCalls["toolCalls"] } {
932
+ const FUNCTION_TAG_RE = /<function=([a-zA-Z0-9_-]+)>([\s\S]*?)<\/function>/g;
933
+ const toolCalls: ParsedToolCalls["toolCalls"] = [];
934
+ const parts: string[] = [];
935
+ let cursor = 0;
936
+ let match: RegExpExecArray | null;
937
+
938
+ while ((match = FUNCTION_TAG_RE.exec(text)) !== null) {
939
+ const [fullMatch, toolName, body] = match;
940
+ pushTextFragment(parts, text.slice(cursor, match.index));
941
+
942
+ // Parse <param name="x">val</param> directly to arguments
943
+ const args: Record<string, unknown> = {};
944
+ const PARAM_RE = /<param\s+name="([^"]*)">([\s\S]*?)<\/param>/g;
945
+ let paramMatch: RegExpExecArray | null;
946
+ while ((paramMatch = PARAM_RE.exec(body)) !== null) {
947
+ args[paramMatch[1]] = paramMatch[2];
948
+ }
949
+
950
+ const bridge = bridgeByRemoteName.get(toolName);
951
+ toolCalls.push({
952
+ name: bridge?.runtimeName ?? toolName,
953
+ arguments: bridge?.toRuntimeArgs(args) ?? args,
954
+ });
955
+
956
+ cursor = match.index + fullMatch.length;
957
+ }
958
+
959
+ pushTextFragment(parts, text.slice(cursor));
960
+ return { text: parts.join("\n\n").trim(), toolCalls };
961
+ }
962
+
674
963
  function parseXmlToolCalls(
675
964
  rawText: string,
676
965
  tools: Tool[] | undefined,
677
- ): {
678
- text: string;
679
- toolCalls: Array<{ name: string; arguments: Record<string, unknown> }>;
680
- } {
966
+ ): ParsedToolCalls {
967
+ const bridges = getParseToolBridges(tools);
681
968
  const bridgeByRemoteName = new Map(
682
- getParseToolBridges(tools).map((bridge) => [bridge.remoteName, bridge]),
969
+ bridges.map((bridge) => [bridge.remoteName, bridge]),
683
970
  );
684
- const toolNames = new Set(bridgeByRemoteName.keys());
685
- const textWithoutThinking = extractThinkingXml(rawText).text;
971
+ // Some Cline/MiMo variants use the Pi runtime tool name (e.g. <edit>,
972
+ // <write>) instead of the Cline XML name (<replace_in_file>, <write_to_file>).
973
+ // Register runtime names as aliases so both forms are recognised.
974
+ const bridgeByName = new Map(
975
+ bridges.flatMap((bridge) => [
976
+ [bridge.remoteName, bridge],
977
+ [bridge.runtimeName, bridge],
978
+ ]),
979
+ );
980
+ const toolNames = new Set(bridgeByName.keys());
981
+
982
+ // Extract <function=name> Pi SDK tool calls directly (no Cline XML intermediate)
983
+ const fnResult = extractFunctionTagToolCalls(rawText, bridgeByRemoteName);
984
+ const textWithoutThinking = extractThinkingXml(fnResult.text).text;
686
985
  if (toolNames.size === 0) {
687
- return { text: textWithoutThinking.trim(), toolCalls: [] };
986
+ return { text: textWithoutThinking.trim(), toolCalls: fnResult.toolCalls };
688
987
  }
689
988
 
690
989
  const sourceText = findNextToolStart(textWithoutThinking, toolNames, 0)
@@ -698,7 +997,9 @@ function parseXmlToolCalls(
698
997
  while (cursor < sourceText.length) {
699
998
  const next = findNextToolStart(sourceText, toolNames, cursor);
700
999
  if (!next) break;
701
- const closeTag = `</${next.name}>`;
1000
+ const bridge = bridgeByName.get(next.name);
1001
+ const remoteName = bridge?.remoteName ?? next.name;
1002
+ const closeTag = `</${remoteName}>`;
702
1003
  const closeStart = sourceText.indexOf(
703
1004
  closeTag,
704
1005
  next.index + next.openTag.length,
@@ -706,18 +1007,129 @@ function parseXmlToolCalls(
706
1007
  pushTextFragment(textParts, sourceText.slice(cursor, next.index));
707
1008
  const blockEnd = closeStart === -1 ? sourceText.length : closeStart;
708
1009
  const block = sourceText.slice(next.index + next.openTag.length, blockEnd);
709
- const bridge = bridgeByRemoteName.get(next.name);
710
1010
  const remoteArgs = parseToolArguments(block);
711
- toolCalls.push({
712
- name: bridge?.runtimeName ?? next.name,
713
- arguments: bridge?.toRuntimeArgs(remoteArgs) ?? remoteArgs,
714
- });
1011
+ const writeRuntimeName = getWriteRuntimeToolName(tools);
1012
+ const heredocWrite =
1013
+ remoteName === "execute_command" && writeRuntimeName
1014
+ ? parseCatHeredocWriteCommand(stringArg(remoteArgs, "command"))
1015
+ : undefined;
1016
+ if (heredocWrite && writeRuntimeName) {
1017
+ toolCalls.push({
1018
+ name: writeRuntimeName,
1019
+ arguments: { ...heredocWrite },
1020
+ });
1021
+ } else {
1022
+ toolCalls.push({
1023
+ name: bridge?.runtimeName ?? next.name,
1024
+ arguments: bridge?.toRuntimeArgs(remoteArgs) ?? remoteArgs,
1025
+ });
1026
+ }
715
1027
  cursor =
716
1028
  closeStart === -1 ? sourceText.length : closeStart + closeTag.length;
717
1029
  }
718
1030
 
719
1031
  pushTextFragment(textParts, sourceText.slice(cursor));
720
- return { text: textParts.join("\n\n").trim(), toolCalls };
1032
+ return {
1033
+ text: textParts.join("\n\n").trim(),
1034
+ toolCalls: [...fnResult.toolCalls, ...toolCalls],
1035
+ };
1036
+ }
1037
+
1038
+ function parseReasoningHiddenToolCalls(
1039
+ thinkingParts: string[],
1040
+ tools: Tool[] | undefined,
1041
+ depth = 3,
1042
+ ): { thinking: string[]; toolCalls: ParsedToolCalls["toolCalls"] } {
1043
+ const thinking: string[] = [];
1044
+ const toolCalls: ParsedToolCalls["toolCalls"] = [];
1045
+ for (const part of thinkingParts) {
1046
+ const trimmed = part.trim();
1047
+ if (!trimmed) continue;
1048
+ if (depth <= 0) {
1049
+ thinking.push(trimmed);
1050
+ continue;
1051
+ }
1052
+ const extracted = extractThinkingXml(trimmed);
1053
+ const nested = parseReasoningHiddenToolCalls(
1054
+ extracted.thinking,
1055
+ tools,
1056
+ depth - 1,
1057
+ );
1058
+ const parsed = parseXmlToolCalls(extracted.text, tools);
1059
+ toolCalls.push(...parsed.toolCalls, ...nested.toolCalls);
1060
+ if (parsed.text) thinking.push(parsed.text);
1061
+ thinking.push(...nested.thinking);
1062
+ if (
1063
+ !parsed.text &&
1064
+ parsed.toolCalls.length === 0 &&
1065
+ nested.toolCalls.length === 0 &&
1066
+ nested.thinking.length === 0
1067
+ ) {
1068
+ thinking.push(trimmed);
1069
+ }
1070
+ }
1071
+ return { thinking, toolCalls };
1072
+ }
1073
+
1074
+ function parseReasoningToolCalls(
1075
+ reasoning: string,
1076
+ tools: Tool[] | undefined,
1077
+ ): { thinking: string[]; toolCalls: ParsedToolCalls["toolCalls"] } {
1078
+ if (!reasoning.trim()) return { thinking: [], toolCalls: [] };
1079
+
1080
+ const extracted = extractThinkingXml(reasoning);
1081
+ const hiddenParsed = parseReasoningHiddenToolCalls(extracted.thinking, tools);
1082
+ const parsed = parseXmlToolCalls(extracted.text, tools);
1083
+ const thinking = [...hiddenParsed.thinking];
1084
+ if (parsed.toolCalls.length > 0 && parsed.text) {
1085
+ thinking.push(parsed.text);
1086
+ } else if (
1087
+ parsed.toolCalls.length === 0 &&
1088
+ hiddenParsed.thinking.length === 0 &&
1089
+ extracted.thinking.length === 0
1090
+ ) {
1091
+ thinking.push(reasoning.trim());
1092
+ }
1093
+
1094
+ return {
1095
+ thinking,
1096
+ toolCalls: [...parsed.toolCalls, ...hiddenParsed.toolCalls],
1097
+ };
1098
+ }
1099
+
1100
+ const INTERNAL_ONLY_RESPONSE =
1101
+ "Cline returned internal reasoning only and did not produce a user-visible response. Please retry or ask it to continue.";
1102
+
1103
+ function prepareClineXmlOutput(
1104
+ parsedText: string,
1105
+ contentThinking: string[],
1106
+ reasoningThinking: string[],
1107
+ toolCalls: ParsedToolCalls["toolCalls"],
1108
+ ): {
1109
+ visibleText: string;
1110
+ thinkingText: string;
1111
+ toolCalls: ParsedToolCalls["toolCalls"];
1112
+ } {
1113
+ const thinkingParts = [...reasoningThinking, ...contentThinking].filter(
1114
+ Boolean,
1115
+ );
1116
+ const thinkingText = thinkingParts.join("\n\n");
1117
+ if (!parsedText && toolCalls.length === 0 && thinkingText) {
1118
+ // Never return a blank stop, but also do not surface hidden reasoning as
1119
+ // user-visible answer text. If Cline sends only hidden/reasoning content,
1120
+ // show a stable visible fallback and keep the raw content in thinking.
1121
+ return {
1122
+ visibleText: INTERNAL_ONLY_RESPONSE,
1123
+ thinkingText,
1124
+ toolCalls,
1125
+ };
1126
+ }
1127
+
1128
+ return {
1129
+ visibleText: parsedText,
1130
+ thinkingText,
1131
+ toolCalls,
1132
+ };
721
1133
  }
722
1134
 
723
1135
  function usageFromChunkUsage(usage: ClineXmlChunk["usage"] | undefined): Usage {
@@ -754,6 +1166,121 @@ async function* parseSse(response: Response): AsyncGenerator<ClineXmlChunk> {
754
1166
  }
755
1167
  }
756
1168
 
1169
+ type ClineXmlResponseData = {
1170
+ rawText: string;
1171
+ thinking: string;
1172
+ finishReason: string | null | undefined;
1173
+ usage: ClineXmlChunk["usage"] | undefined;
1174
+ };
1175
+
1176
+ function isRetryableClineReasoningStreamError(error: unknown): boolean {
1177
+ if (!(error instanceof Error)) return false;
1178
+ const message = error.message.toLowerCase();
1179
+ return message.includes("stream error occurred");
1180
+ }
1181
+
1182
+ async function readClineXmlResponse(
1183
+ response: Response,
1184
+ ): Promise<ClineXmlResponseData> {
1185
+ let rawText = "";
1186
+ let thinking = "";
1187
+ let finishReason: string | null | undefined;
1188
+ let usage: ClineXmlChunk["usage"] | undefined;
1189
+
1190
+ for await (const chunk of parseSse(response)) {
1191
+ if (chunk.error) {
1192
+ throw new Error(
1193
+ `${chunk.error.code ?? "cline_error"}: ${chunk.error.message ?? "Unknown Cline error"}`,
1194
+ );
1195
+ }
1196
+ if (chunk.usage) usage = chunk.usage;
1197
+ const choice = chunk.choices?.[0];
1198
+ if (!choice) continue;
1199
+ if (choice.error) {
1200
+ throw new Error(
1201
+ `${choice.error.code ?? "cline_error"}: ${choice.error.message ?? "Unknown Cline error"}`,
1202
+ );
1203
+ }
1204
+ if (choice.finish_reason) finishReason = choice.finish_reason;
1205
+ rawText += choice.delta?.content ?? "";
1206
+ thinking += choice.delta?.reasoning ?? "";
1207
+ }
1208
+
1209
+ if (!rawText.trim() && !thinking.trim()) {
1210
+ throw new Error("Cline returned empty response");
1211
+ }
1212
+
1213
+ // Some MiMo/Cline models wrap XML tags in Unicode math-italic characters
1214
+ // forming "anthml:" prefixes (e.g. <𝑎𝑛𝑡𝑚𝑙:thinking>, <𝑎𝑛𝑡𝑚𝑙:read_file>).
1215
+ // Strip these so the rest of the parser sees standard ASCII XML tags.
1216
+ return {
1217
+ rawText: normalizeDecoratedXmlTags(rawText),
1218
+ thinking: normalizeDecoratedXmlTags(thinking),
1219
+ finishReason,
1220
+ usage,
1221
+ };
1222
+ }
1223
+
1224
+ async function fetchClineXmlResponse(
1225
+ model: Model<string>,
1226
+ context: Context,
1227
+ options: SimpleStreamOptions,
1228
+ headers: Record<string, string>,
1229
+ includeReasoning: boolean,
1230
+ ): Promise<ClineXmlResponseData> {
1231
+ const response = await fetch(`${BASE_URL_CLINE}/chat/completions`, {
1232
+ method: "POST",
1233
+ headers: {
1234
+ ...headers,
1235
+ Authorization: `Bearer ${options.apiKey}`,
1236
+ "Content-Type": "application/json",
1237
+ },
1238
+ body: JSON.stringify({
1239
+ model: normalizeApiModelId(model.id),
1240
+ temperature: 0,
1241
+ messages: buildClineXmlMessages(context),
1242
+ stream: true,
1243
+ stream_options: { include_usage: true },
1244
+ ...(includeReasoning ? { include_reasoning: true } : {}),
1245
+ }),
1246
+ signal: options.signal,
1247
+ });
1248
+ await options.onResponse?.(
1249
+ {
1250
+ status: response.status,
1251
+ headers: Object.fromEntries(response.headers.entries()),
1252
+ },
1253
+ model,
1254
+ );
1255
+
1256
+ if (!response.ok) {
1257
+ throw new Error(
1258
+ `Cline API error ${response.status}: ${await response.text()}`,
1259
+ );
1260
+ }
1261
+
1262
+ return readClineXmlResponse(response);
1263
+ }
1264
+
1265
+ async function fetchClineXmlResponseWithReasoningFallback(
1266
+ model: Model<string>,
1267
+ context: Context,
1268
+ options: SimpleStreamOptions,
1269
+ headers: Record<string, string>,
1270
+ ): Promise<ClineXmlResponseData> {
1271
+ try {
1272
+ return await fetchClineXmlResponse(model, context, options, headers, true);
1273
+ } catch (error) {
1274
+ if (
1275
+ options.signal?.aborted ||
1276
+ !isRetryableClineReasoningStreamError(error)
1277
+ ) {
1278
+ throw error;
1279
+ }
1280
+ return fetchClineXmlResponse(model, context, options, headers, false);
1281
+ }
1282
+ }
1283
+
757
1284
  function createAssistant(model: Model<string>): AssistantMessage {
758
1285
  return {
759
1286
  role: "assistant",
@@ -871,78 +1398,71 @@ export function streamClineXml(
871
1398
  throw new Error("No Cline access token found. Run /login cline first.");
872
1399
  }
873
1400
 
874
- const response = await fetch(`${BASE_URL_CLINE}/chat/completions`, {
875
- method: "POST",
876
- headers: {
877
- ...headers,
878
- Authorization: `Bearer ${options.apiKey}`,
879
- "Content-Type": "application/json",
880
- },
881
- body: JSON.stringify({
882
- model: normalizeApiModelId(model.id),
883
- temperature: 0,
884
- messages: buildClineXmlMessages(context),
885
- stream: true,
886
- stream_options: { include_usage: true },
887
- include_reasoning: true,
888
- }),
889
- signal: options.signal,
890
- });
891
- await options.onResponse?.(
892
- {
893
- status: response.status,
894
- headers: Object.fromEntries(response.headers.entries()),
895
- },
896
- model,
897
- );
898
-
899
- if (!response.ok) {
900
- throw new Error(
901
- `Cline API error ${response.status}: ${await response.text()}`,
902
- );
903
- }
904
-
905
- let rawText = "";
906
- let thinking = "";
1401
+ let output: ReturnType<typeof prepareClineXmlOutput>;
1402
+ let rawText: string;
1403
+ let thinking: string;
907
1404
  let finishReason: string | null | undefined;
908
1405
  let usage: ClineXmlChunk["usage"] | undefined;
1406
+ let currentContext = context;
1407
+
1408
+ for (let attempt = 0; attempt < 2; attempt++) {
1409
+ const data = await fetchClineXmlResponseWithReasoningFallback(
1410
+ model,
1411
+ currentContext,
1412
+ options,
1413
+ headers,
1414
+ );
1415
+ rawText = data.rawText;
1416
+ thinking = data.thinking;
1417
+ finishReason = data.finishReason;
1418
+ usage = data.usage;
1419
+
1420
+ const extractedThinking = extractThinkingXml(rawText);
1421
+ const parsedReasoning = parseReasoningToolCalls(
1422
+ thinking,
1423
+ currentContext.tools,
1424
+ );
1425
+ const parsed = parseXmlToolCalls(
1426
+ extractedThinking.text,
1427
+ currentContext.tools,
1428
+ );
1429
+ output = prepareClineXmlOutput(
1430
+ parsed.text,
1431
+ extractedThinking.thinking,
1432
+ parsedReasoning.thinking,
1433
+ [...parsed.toolCalls, ...parsedReasoning.toolCalls],
1434
+ );
909
1435
 
910
- for await (const chunk of parseSse(response)) {
911
- if (chunk.error) {
912
- throw new Error(
913
- `${chunk.error.code ?? "cline_error"}: ${chunk.error.message ?? "Unknown Cline error"}`,
914
- );
915
- }
916
- if (chunk.usage) usage = chunk.usage;
917
- const choice = chunk.choices?.[0];
918
- if (!choice) continue;
919
- if (choice.error) {
920
- throw new Error(
921
- `${choice.error.code ?? "cline_error"}: ${choice.error.message ?? "Unknown Cline error"}`,
922
- );
1436
+ // Reasoning-only response: MiMo stopped without producing visible
1437
+ // text or tool calls. Auto-retry once with a "continue" nudge
1438
+ // instead of showing a dead-end error to the user.
1439
+ if (output.visibleText === INTERNAL_ONLY_RESPONSE && attempt === 0) {
1440
+ currentContext = {
1441
+ ...context,
1442
+ messages: [
1443
+ ...context.messages,
1444
+ {
1445
+ role: "user" as const,
1446
+ content: [{ type: "text" as const, text: "Please continue." }],
1447
+ timestamp: Date.now(),
1448
+ },
1449
+ ],
1450
+ };
1451
+ continue;
923
1452
  }
924
- if (choice.finish_reason) finishReason = choice.finish_reason;
925
- rawText += choice.delta?.content ?? "";
926
- thinking += choice.delta?.reasoning ?? "";
1453
+ break;
927
1454
  }
928
1455
 
929
- assistant.usage = usageFromChunkUsage(usage);
930
- const extractedThinking = extractThinkingXml(rawText);
931
- pushThinking(
932
- assistant,
933
- [thinking.trim(), ...extractedThinking.thinking]
934
- .filter(Boolean)
935
- .join("\n\n"),
936
- stream,
937
- );
938
- const parsed = parseXmlToolCalls(extractedThinking.text, context.tools);
939
- pushText(assistant, parsed.text, stream);
940
- for (const toolCall of parsed.toolCalls) {
1456
+ assistant.usage = usageFromChunkUsage(usage!);
1457
+ pushThinking(assistant, output!.thinkingText, stream);
1458
+ pushText(assistant, output!.visibleText, stream);
1459
+ const toolCalls = output!.toolCalls;
1460
+ for (const toolCall of toolCalls) {
941
1461
  pushToolCall(assistant, toolCall, stream);
942
1462
  }
943
1463
 
944
1464
  assistant.stopReason =
945
- parsed.toolCalls.length > 0
1465
+ toolCalls.length > 0
946
1466
  ? "toolUse"
947
1467
  : finishReason === "length"
948
1468
  ? "length"
@@ -969,6 +1489,12 @@ export function streamClineXml(
969
1489
 
970
1490
  export const __test__ = {
971
1491
  buildClineXmlMessages,
1492
+ extractFunctionTagToolCalls,
1493
+ isRetryableClineReasoningStreamError,
1494
+ normalizeDecoratedXmlTags,
1495
+ parseReasoningHiddenToolCalls,
1496
+ parseReasoningToolCalls,
972
1497
  parseXmlToolCalls,
1498
+ prepareClineXmlOutput,
973
1499
  serializeXmlToolCall,
974
1500
  };