osai-agent 4.0.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.
Files changed (86) hide show
  1. package/LICENSE +7 -0
  2. package/package.json +72 -0
  3. package/src/agent/context.js +141 -0
  4. package/src/agent/loop/context-summary.js +196 -0
  5. package/src/agent/loop/directory-utils.js +102 -0
  6. package/src/agent/loop/local.js +196 -0
  7. package/src/agent/loop/loop-detection.js +288 -0
  8. package/src/agent/loop/stream-parser.js +515 -0
  9. package/src/agent/loop/tool-executor.js +470 -0
  10. package/src/agent/loop/verification.js +263 -0
  11. package/src/agent/loop/websocket.js +80 -0
  12. package/src/agent/prompt.js +259 -0
  13. package/src/agent/react-loop.js +697 -0
  14. package/src/agent/subagent.js +263 -0
  15. package/src/commands/config.js +53 -0
  16. package/src/commands/connect.js +190 -0
  17. package/src/commands/devices.js +121 -0
  18. package/src/commands/login.js +77 -0
  19. package/src/commands/logout.js +31 -0
  20. package/src/commands/mcp.js +258 -0
  21. package/src/commands/provider.js +633 -0
  22. package/src/commands/register.js +74 -0
  23. package/src/commands/run.js +150 -0
  24. package/src/commands/search.js +64 -0
  25. package/src/commands/session.js +57 -0
  26. package/src/commands/skills.js +54 -0
  27. package/src/commands/stop-subagent.js +58 -0
  28. package/src/index.js +208 -0
  29. package/src/llm/direct.js +317 -0
  30. package/src/memory/store.js +215 -0
  31. package/src/mock-readline.js +27 -0
  32. package/src/parser/dependencies.js +71 -0
  33. package/src/parser/markdown.js +505 -0
  34. package/src/parser/stream.js +96 -0
  35. package/src/prompts/modes/CODING.js +160 -0
  36. package/src/prompts/modes/GENERAL.js +105 -0
  37. package/src/prompts/modes/NETWORK.js +69 -0
  38. package/src/prompts/modes/SSH.js +53 -0
  39. package/src/prompts/systemPrompt.js +85 -0
  40. package/src/safety/check.js +210 -0
  41. package/src/services/crypto.js +78 -0
  42. package/src/services/executor.js +68 -0
  43. package/src/services/history.js +58 -0
  44. package/src/services/server-url.js +11 -0
  45. package/src/services/session.js +194 -0
  46. package/src/services/ssh.js +176 -0
  47. package/src/services/websocket.js +112 -0
  48. package/src/skills/loader.js +231 -0
  49. package/src/tools/browser.js +434 -0
  50. package/src/tools/local.js +1254 -0
  51. package/src/tools/mcp-client.js +209 -0
  52. package/src/tools/registry.js +132 -0
  53. package/src/tools/search-providers.js +237 -0
  54. package/src/tools/ssh.js +74 -0
  55. package/src/ui/App.js +2031 -0
  56. package/src/ui/animation.js +47 -0
  57. package/src/ui/components/AskUserDialog.js +33 -0
  58. package/src/ui/components/ConfirmationDialog.js +45 -0
  59. package/src/ui/components/DiffView.js +201 -0
  60. package/src/ui/components/Header.js +157 -0
  61. package/src/ui/components/HistoryPicker.js +130 -0
  62. package/src/ui/components/InputShell.js +22 -0
  63. package/src/ui/components/MessageHistory.js +1200 -0
  64. package/src/ui/components/ModalPanel.js +40 -0
  65. package/src/ui/components/ModePicker.js +161 -0
  66. package/src/ui/components/PlanDialog.js +48 -0
  67. package/src/ui/components/ProviderMenu.js +1095 -0
  68. package/src/ui/components/SavePicker.js +106 -0
  69. package/src/ui/components/SelectMenu.js +194 -0
  70. package/src/ui/components/SlashMenu.js +168 -0
  71. package/src/ui/components/SubagentPanel.js +138 -0
  72. package/src/ui/components/TextInputSafe.js +117 -0
  73. package/src/ui/components/TodoPanel.js +54 -0
  74. package/src/ui/components/ToolExecution.js +261 -0
  75. package/src/ui/components/TranscriptViewport.js +99 -0
  76. package/src/ui/diff.js +249 -0
  77. package/src/ui/h.js +7 -0
  78. package/src/ui/mouse-scroll.js +63 -0
  79. package/src/ui/slash-picker.js +58 -0
  80. package/src/ui/terminal.js +41 -0
  81. package/src/ui/theme.js +5 -0
  82. package/src/ui/welcome.js +12 -0
  83. package/src/utils/constants.js +231 -0
  84. package/src/utils/helpers.js +154 -0
  85. package/src/utils/logger.js +81 -0
  86. package/src/utils/sound.js +33 -0
@@ -0,0 +1,515 @@
1
+ import { TOOLS, COMPLETION_SIGNALS, MODES } from '../../utils/constants.js';
2
+ import { logger } from '../../utils/logger.js';
3
+
4
+ export default {
5
+ async _parseResponse(response) {
6
+ if (!response || typeof response !== 'object' || !response.body) {
7
+ if (response && response.toolCalls) return response;
8
+ return { toolCalls: [], fullResponse: '' };
9
+ }
10
+
11
+ const decoder = new TextDecoder();
12
+ let lineBuffer = '';
13
+ let fullResponse = '';
14
+ let completionSignal = null;
15
+ let badgeDisplayed = false;
16
+ let narrativeBuffer = '';
17
+ let jsonAccumulator = '';
18
+ let inJsonMode = false;
19
+ let inToolXmlMode = false;
20
+ let toolXmlAccumulator = '';
21
+ let inCodePreview = false;
22
+ let braceDepth = 0;
23
+ let pendingToolJsonFence = false;
24
+ let inThinkBlock = false;
25
+ let thinkContentAccumulator = '';
26
+
27
+ const reader = response.body.getReader();
28
+
29
+ // Yield rapide via setImmediate pour ne pas bloquer l'event loop
30
+ const quickYield = () => new Promise(r => setImmediate(r));
31
+ // Yield de rendu via setTimeout pour laisser Ink re-render
32
+ // en passant par la phase Timers du event loop
33
+ const renderYield = () => new Promise(r => setTimeout(r, 0));
34
+
35
+ const processLines = async (lines) => {
36
+ let shouldBreak = false;
37
+ let lineCount = 0;
38
+ for (const line of lines) {
39
+ lineCount++;
40
+ // Yield de rendu toutes les 25 lignes pour laisser Ink re-render
41
+ if (lineCount % 25 === 0) {
42
+ await renderYield();
43
+ if (this._cancelled) break;
44
+ // Yield rapide toutes les 5 lignes pour éviter la starvation
45
+ } else if (lineCount % 5 === 0) {
46
+ await quickYield();
47
+ if (this._cancelled) break;
48
+ }
49
+ if (!line.startsWith('data: ')) continue;
50
+ const data = line.slice(6).trim();
51
+ if (!data) continue;
52
+ if (data === '[DONE]') { shouldBreak = true; continue; }
53
+
54
+ try {
55
+ const event = JSON.parse(data);
56
+ if (event.type === 'thinking' && event.content) {
57
+ if (!this._isThinking) {
58
+ this._isThinking = true;
59
+ this.onThinkingStart();
60
+ }
61
+ this.onThought(event.content);
62
+ } else if (event.type === 'chunk' && event.content) {
63
+ if (event.provider) this.currentProvider = event.provider;
64
+ if (event.model) this.currentModel = event.model;
65
+ if (this._isThinking) {
66
+ const trimmed = event.content.trim();
67
+ const isToolJson = trimmed.startsWith('{"tool"') || trimmed.startsWith('{\\"tool\\"');
68
+ if (!isToolJson) {
69
+ this._isThinking = false;
70
+ this.onThinkingEnd();
71
+ }
72
+ }
73
+ const content = event.content;
74
+ fullResponse += content;
75
+
76
+ const trimmedChunk = content.trim();
77
+ const hasToolJsonStart = content.includes('{"tool"') || content.includes('{\\"tool\\"');
78
+ const hasToolLikeJsonKeys = /"(?:tool|server|mcpTool|params|path|cmd|url|query|find|replace|content|description|startLine|endLine|filePattern|pattern|source|destination)"\s*:/i.test(content)
79
+ || /\\"(?:tool|server|mcpTool|params|path|cmd|url|query|find|replace|content|description|startLine|endLine|filePattern|pattern|source|destination)\\"\s*:/i.test(content);
80
+ const trimmedStart = content.trimStart();
81
+ const startsLikeJsonObject = trimmedStart.startsWith('{') || trimmedStart.startsWith('{\\"') || trimmedStart.startsWith('{"');
82
+ const looksLikeSplitToolJsonStart = this.mode === MODES.CODING && startsLikeJsonObject && !content.includes('\n\n');
83
+ const isStandaloneJsonFence = /^```json\s*$/i.test(trimmedChunk);
84
+ const looksLikeNonJsonToolCall = /^[A-Z][A-Z_0-9]+\s+\w+\s*=/.test(trimmedChunk);
85
+ const hasToolXmlStart = content.includes('<tool') && (content.includes('>') || content.includes('/>'));
86
+
87
+ // Detect <think> blocks and route to thought handler
88
+ // Must run before tool XML/JSON mode checks so think content
89
+ // is not consumed by tool accumulators.
90
+ const thinkOpenIdx = content.indexOf('<think>');
91
+ const thinkCloseIdx = content.indexOf('</think>');
92
+ if (inThinkBlock || thinkOpenIdx !== -1) {
93
+ if (inThinkBlock) {
94
+ // Already inside a think block
95
+ if (thinkCloseIdx !== -1) {
96
+ const beforeClose = content.slice(0, thinkCloseIdx);
97
+ if (beforeClose.trim()) {
98
+ thinkContentAccumulator += beforeClose;
99
+ this.onThought(beforeClose);
100
+ }
101
+ inThinkBlock = false;
102
+ thinkContentAccumulator = '';
103
+ this.onThinkingEnd();
104
+ content = content.slice(thinkCloseIdx + 8);
105
+ } else {
106
+ thinkContentAccumulator += content;
107
+ this.onThought(content);
108
+ content = '';
109
+ }
110
+ } else {
111
+ // Think block starts in this chunk
112
+ const beforeThink = content.slice(0, thinkOpenIdx);
113
+ const afterThink = content.slice(thinkOpenIdx + 7);
114
+ inThinkBlock = true;
115
+ thinkContentAccumulator = '';
116
+ this.onThinkingStart();
117
+ const closeInRemainder = afterThink.indexOf('</think>');
118
+ if (closeInRemainder !== -1) {
119
+ const thinkContent = afterThink.slice(0, closeInRemainder);
120
+ if (thinkContent.trim()) {
121
+ thinkContentAccumulator = thinkContent;
122
+ this.onThought(thinkContent);
123
+ }
124
+ inThinkBlock = false;
125
+ thinkContentAccumulator = '';
126
+ this.onThinkingEnd();
127
+ content = (beforeThink || '') + afterThink.slice(closeInRemainder + 8);
128
+ } else {
129
+ if (afterThink.trim()) {
130
+ thinkContentAccumulator = afterThink;
131
+ this.onThought(afterThink);
132
+ }
133
+ content = beforeThink || '';
134
+ }
135
+ }
136
+ }
137
+
138
+ if (!inJsonMode && looksLikeNonJsonToolCall && narrativeBuffer.trim()) {
139
+ this.onMarkdown(narrativeBuffer.trim());
140
+ narrativeBuffer = '';
141
+ shouldBreak = true;
142
+ break;
143
+ }
144
+
145
+ if (!inJsonMode && isStandaloneJsonFence) {
146
+ pendingToolJsonFence = true;
147
+ continue;
148
+ }
149
+ if (!inJsonMode && pendingToolJsonFence && !(hasToolJsonStart || hasToolLikeJsonKeys || looksLikeSplitToolJsonStart)) {
150
+ narrativeBuffer += '```json\n';
151
+ pendingToolJsonFence = false;
152
+ }
153
+ if (!inJsonMode && (hasToolJsonStart || hasToolLikeJsonKeys || looksLikeSplitToolJsonStart)) {
154
+ pendingToolJsonFence = false;
155
+ inJsonMode = true;
156
+ if (narrativeBuffer) { this.onMarkdown(narrativeBuffer); narrativeBuffer = ''; }
157
+ const rawIdx = content.indexOf('{"tool"');
158
+ const escapedIdx = content.indexOf('{\\"tool\\"');
159
+ const braceIdx = content.indexOf('{');
160
+ let toolStart = rawIdx >= 0 && escapedIdx >= 0 ? Math.min(rawIdx, escapedIdx) : Math.max(rawIdx, escapedIdx);
161
+ if (toolStart < 0) toolStart = braceIdx >= 0 ? braceIdx : 0;
162
+ jsonAccumulator = content.slice(toolStart);
163
+ braceDepth = (jsonAccumulator.match(/{/g) || []).length - (jsonAccumulator.match(/}/g) || []).length;
164
+ if (braceDepth <= 0) {
165
+ inJsonMode = false;
166
+ shouldBreak = true;
167
+ break;
168
+ }
169
+ } else if (inJsonMode) {
170
+ jsonAccumulator += content;
171
+ braceDepth += (content.match(/{/g) || []).length - (content.match(/}/g) || []).length;
172
+ if (braceDepth <= 0) {
173
+ inJsonMode = false;
174
+ shouldBreak = true;
175
+ break;
176
+ }
177
+ } else if (inToolXmlMode) {
178
+ toolXmlAccumulator += content;
179
+ if (content.includes('/>') || content.includes('</tool>')) {
180
+ inToolXmlMode = false;
181
+ shouldBreak = true;
182
+ break;
183
+ }
184
+ } else if (!inJsonMode && !inToolXmlMode && hasToolXmlStart) {
185
+ if (narrativeBuffer) { this.onMarkdown(narrativeBuffer); narrativeBuffer = ''; }
186
+ inToolXmlMode = true;
187
+ toolXmlAccumulator = content;
188
+ if (content.includes('/>') || content.includes('</tool>')) {
189
+ inToolXmlMode = false;
190
+ shouldBreak = true;
191
+ break;
192
+ }
193
+ } else {
194
+ if (content.includes('[DONE]')) completionSignal = COMPLETION_SIGNALS.DONE;
195
+ if (content.includes('[INCOMPLETE]')) completionSignal = COMPLETION_SIGNALS.INCOMPLETE;
196
+ if (content.includes('[BLOCKED]')) completionSignal = COMPLETION_SIGNALS.BLOCKED;
197
+
198
+ let cleanContent = content
199
+ .replace(/\[DONE\]/gi, '').replace(/\[INCOMPLETE\]/gi, '').replace(/\[BLOCKED\]/gi, '')
200
+ .replace(/\[TOOL_CALL\]\s*/g, '')
201
+ .replace(/THE TOOL\s*CALL[\s\S]*?(?=\n\n|$)/gi, '');
202
+
203
+ const trailingJsonFence = cleanContent.match(/(?:^|\n)\s*```json\s*$/i);
204
+ if (trailingJsonFence && trailingJsonFence.index != null) {
205
+ cleanContent = cleanContent.slice(0, trailingJsonFence.index);
206
+ pendingToolJsonFence = true;
207
+ }
208
+
209
+ if (!inCodePreview && cleanContent) {
210
+ const fileContentScore = [
211
+ /;\s*\n/.test(cleanContent),
212
+ /\{\s*\n/.test(cleanContent),
213
+ /background-color\s*:/.test(cleanContent),
214
+ /display\s*:\s*(flex|grid|block)/.test(cleanContent),
215
+ /font-size\s*:/.test(cleanContent),
216
+ /margin|padding/.test(cleanContent) && /:\s*\d/.test(cleanContent),
217
+ /<\/(div|html|body|head|script|style|button)>/.test(cleanContent),
218
+ /\bfunction\s+\w+\s*\(/.test(cleanContent),
219
+ /document\.getElementById/.test(cleanContent),
220
+ /\\n\s{2,}/.test(cleanContent),
221
+ ].filter(Boolean).length;
222
+
223
+ if (fileContentScore >= 3) {
224
+ const sentences = cleanContent.split(/(?<=[.!?])\s+/);
225
+ let safeNarrative = '';
226
+ for (const s of sentences) {
227
+ if ([/;\s*\n/, /background-color/, /display\s*:/, /<\/(div|html)>/, /\\n\s{2,}/]
228
+ .some(re => re.test(s))) break;
229
+ safeNarrative += s + ' ';
230
+ }
231
+ if (safeNarrative.trim()) { narrativeBuffer += safeNarrative.trim() + ' '; }
232
+ inCodePreview = true;
233
+ cleanContent = '';
234
+ setImmediate(() => { inCodePreview = false; });
235
+ }
236
+ }
237
+
238
+ if (cleanContent.includes('{"tool"') || cleanContent.includes('{\\"tool\\"') || /^\s*\{\s*$/.test(cleanContent) || /{"\s*$/.test(cleanContent)) {
239
+ const rawToolIdx = cleanContent.indexOf('{"tool"');
240
+ const escapedToolIdx = cleanContent.indexOf('{\\"tool\\"');
241
+ const braceIdx = cleanContent.indexOf('{');
242
+ let toolIdx = rawToolIdx >= 0 && escapedToolIdx >= 0 ? Math.min(rawToolIdx, escapedToolIdx) : Math.max(rawToolIdx, escapedToolIdx);
243
+ if (toolIdx < 0) toolIdx = braceIdx >= 0 ? braceIdx : 0;
244
+ const before = cleanContent.slice(0, toolIdx).trim();
245
+ if (before) { narrativeBuffer += before; }
246
+ cleanContent = '';
247
+ }
248
+ if (this.mode === MODES.CODING && cleanContent) {
249
+ const pseudoToolIdx = cleanContent.search(/\b(?:LOCAL_CMD|SSH_CMD|READ_FILE|WRITE_FILE|EDIT_FILE|APPEND_FILE|DELETE_FILE|LIST_DIR|SEARCH_FILE|CREATE_DIR|TREE_VIEW|RUN_SCRIPT|MOVE_FILE|COPY_FILE|FILE_INFO|FETCH_URL|WEB_SEARCH|TODO_ADD|TODO_COMPLETE|TODO_UPDATE|TODO_LIST|TODO_CLEAR|POWERSHELL|GLOB|GREP|GIT|DIAG_POST_EDIT|ASK_USER|PLAN_MODE|SKILL_LIST|LOAD_SKILL|CREATE_SKILL|TASK)\b\s*\n\s*\{\s*"?(?:path|cmd|url|query|find|replace|content|description|startLine|endLine|filePattern|pattern|source|destination|name|skill|prompt)"?\s*:/i);
250
+ if (pseudoToolIdx >= 0) {
251
+ const before = cleanContent.slice(0, pseudoToolIdx).trim();
252
+ if (before) narrativeBuffer += before + ' ';
253
+ cleanContent = '';
254
+ }
255
+ const leakIdx = cleanContent.search(/"(?:tool|server|mcpTool|params|path|find|replace|content|description|cmd|url|query|startLine|endLine|filePattern|pattern|source|destination)"\s*:/i);
256
+ if (leakIdx >= 0) {
257
+ const before = cleanContent.slice(0, leakIdx).trim();
258
+ if (before) narrativeBuffer += before + ' ';
259
+ cleanContent = '';
260
+ }
261
+ }
262
+
263
+ if (cleanContent) {
264
+ narrativeBuffer += cleanContent;
265
+ // Flush moins fr\u00E9quent (1000 car au lieu de 300) pour r\u00E9duire le nombre
266
+ // de re-rendus Ink pendant le streaming \u2192 terminal plus r\u00E9actif
267
+ const shouldFlush = /[.!?]\s+[A-Z\u00C0-\u024F]/.test(narrativeBuffer) || narrativeBuffer.includes('\n\n') || narrativeBuffer.length > 1000;
268
+ if (shouldFlush) {
269
+ this.onMarkdown(narrativeBuffer);
270
+ narrativeBuffer = '';
271
+ // Yield de rendu après flush pour laisser Ink afficher
272
+ await renderYield();
273
+ }
274
+ }
275
+ }
276
+ } else if (event.type === 'done') {
277
+ if (this._isThinking) {
278
+ this._isThinking = false;
279
+ this.onThinkingEnd();
280
+ }
281
+ if (inThinkBlock) {
282
+ inThinkBlock = false;
283
+ thinkContentAccumulator = '';
284
+ this.onThinkingEnd();
285
+ }
286
+ if (narrativeBuffer.trim()) { this.onMarkdown(narrativeBuffer.trim()); narrativeBuffer = ''; }
287
+ if (!badgeDisplayed) { this._showCompletionBadge(completionSignal); badgeDisplayed = true; }
288
+ shouldBreak = true;
289
+ } else if (event.type === 'error') {
290
+ if (this._cancelled) {
291
+ shouldBreak = true;
292
+ continue;
293
+ }
294
+ const streamErr = new Error(event.message || 'Worker stream error');
295
+ streamErr.name = 'WorkerStreamError';
296
+ throw streamErr;
297
+ }
298
+ } catch (e) {
299
+ if (e.name === 'WorkerStreamError') throw e;
300
+ continue;
301
+ }
302
+ }
303
+ return shouldBreak;
304
+ };
305
+
306
+ try {
307
+ while (true) {
308
+ if (this._cancelled) break;
309
+ const { done, value } = await reader.read();
310
+ if (done) break;
311
+ const chunk = decoder.decode(value, { stream: true });
312
+ lineBuffer += chunk;
313
+ const lines = lineBuffer.split('\n');
314
+ lineBuffer = lines.pop() || '';
315
+
316
+ if (await processLines(lines)) {
317
+ reader.cancel().catch(() => {});
318
+ break;
319
+ }
320
+
321
+ // Yield de rendu entre les chunks pour laisser Ink re-render
322
+ await renderYield();
323
+ }
324
+ if (lineBuffer.trim()) {
325
+ await processLines([lineBuffer]);
326
+ }
327
+ } catch (err) {
328
+ if (err.name === 'WorkerStreamError') throw err;
329
+ logger.error('Stream parsing error', { error: err.message });
330
+ }
331
+
332
+ // Clean up any unclosed think block
333
+ if (inThinkBlock) {
334
+ inThinkBlock = false;
335
+ thinkContentAccumulator = '';
336
+ this.onThinkingEnd();
337
+ }
338
+
339
+ // Libérer l'event loop avant le cleanup final synchrone
340
+ await renderYield();
341
+
342
+ if (narrativeBuffer.trim()) {
343
+ const safe = narrativeBuffer
344
+ .replace(/\{"tool"[\s\S]*$/, '')
345
+ .replace(/\{\\"tool\\"[\s\S]*$/, '')
346
+ .replace(/\{"tool"[^}]*\}/g, '')
347
+ .replace(/\{\\"tool\\"[^}]*\}/g, '')
348
+ .replace(/<tool\b[^>]*>[\s\S]*?<\/tool\s*>|<tool\b[^>]*\/>/g, '')
349
+ .replace(
350
+ /(?:^|\n)\s*(?:LOCAL_CMD|SSH_CMD|READ_FILE|WRITE_FILE|EDIT_FILE|APPEND_FILE|DELETE_FILE|LIST_DIR|SEARCH_FILE|CREATE_DIR|TREE_VIEW|RUN_SCRIPT|MOVE_FILE|COPY_FILE|FILE_INFO|FETCH_URL|WEB_SEARCH|TODO_ADD|TODO_COMPLETE|TODO_UPDATE|TODO_LIST|TODO_CLEAR|POWERSHELL|GLOB|GREP|GIT|DIAG_POST_EDIT|ASK_USER|PLAN_MODE|SKILL_LIST|LOAD_SKILL|CREATE_SKILL|TASK)\s*\n\s*\{[\s\S]*?(?=\n\s*\n|$)/g,
351
+ '\n',
352
+ )
353
+ .replace(/(?:^|\n)\s*```json\s*(?=\n|$)/gi, '\n')
354
+ .replace(/"content"\s*:\s*"[\s\S]{200,}?"(?=[,}])/g, '')
355
+ .replace(/"(?:tool|server|mcpTool|params|path|find|replace|content|description|cmd|url|query|startLine|endLine|filePattern|pattern|source|destination)"\s*:\s*".*$/i, '')
356
+ .trim();
357
+ if (safe) this.onMarkdown(safe);
358
+ }
359
+ if (!badgeDisplayed) this._showCompletionBadge(completionSignal);
360
+ this._lastCompletionSignal = completionSignal;
361
+
362
+ const cleanResponse = fullResponse
363
+ .replace(/\[DONE\]/gi, '').replace(/\[INCOMPLETE\]/gi, '').replace(/\[BLOCKED\]/gi, '')
364
+ .replace(/^\s*\{(?:\\")?tool(?:\\")?\s*:\s*.*$/gim, '')
365
+ .replace(/<tool\b[^>]*>[\s\S]*?<\/tool\s*>|<tool\b[^>]*\/>/gim, '')
366
+ .trim();
367
+ if (cleanResponse) this._appendConversationMessage('assistant', cleanResponse);
368
+
369
+ // Libérer l'event loop avant le scan char-par-char _extractToolCalls
370
+ await renderYield();
371
+
372
+ const extractedToolCalls = this._extractToolCalls(fullResponse);
373
+ if (extractedToolCalls.length === 0 && jsonAccumulator.trim()) {
374
+ try {
375
+ const parsed = JSON.parse(jsonAccumulator.trim());
376
+ if (parsed && parsed.tool) {
377
+ if (parsed.tool === TOOLS.POWERSHELL) parsed.tool = TOOLS.LOCAL_CMD;
378
+ extractedToolCalls.push(parsed);
379
+ }
380
+ } catch {}
381
+ }
382
+ if (extractedToolCalls.length === 0 && toolXmlAccumulator.trim()) {
383
+ const xmlResult = this._extractToolCalls(toolXmlAccumulator);
384
+ extractedToolCalls.push(...xmlResult);
385
+ }
386
+ return { toolCalls: extractedToolCalls, fullResponse: cleanResponse };
387
+ },
388
+
389
+ _detectCompletionSignal(text) {
390
+ if (text.includes('[DONE]')) this._lastCompletionSignal = COMPLETION_SIGNALS.DONE;
391
+ else if (text.includes('[INCOMPLETE]')) this._lastCompletionSignal = COMPLETION_SIGNALS.INCOMPLETE;
392
+ else if (text.includes('[BLOCKED]')) this._lastCompletionSignal = COMPLETION_SIGNALS.BLOCKED;
393
+ },
394
+
395
+ _showCompletionBadge(signal) {
396
+ if (!signal) return;
397
+ this.onBadge({ signal, provider: this.currentProvider, model: this.currentModel });
398
+ },
399
+
400
+ _extractToolCalls(text) {
401
+ const toolCalls = [];
402
+ const toolNames = Object.values(TOOLS);
403
+ const toolSet = new Set(toolNames);
404
+
405
+ // 1. Parse JSON tool calls: {"tool":"NAME","key":"value"}
406
+ let i = 0;
407
+ const len = text.length;
408
+
409
+ while (i < len) {
410
+ const openBrace = text.indexOf('{', i);
411
+ if (openBrace === -1) break;
412
+
413
+ const ahead = text.slice(openBrace, openBrace + 300);
414
+ if (!ahead.includes('"tool"')) {
415
+ i = openBrace + 1;
416
+ continue;
417
+ }
418
+
419
+ let depth = 0;
420
+ let inString = false;
421
+ let escaped = false;
422
+ let closeIdx = -1;
423
+
424
+ for (let j = openBrace; j < len; j++) {
425
+ const ch = text[j];
426
+
427
+ if (escaped) { escaped = false; continue; }
428
+ if (ch === '\\' && inString) { escaped = true; continue; }
429
+ if (ch === '"' && !escaped) { inString = !inString; continue; }
430
+
431
+ if (!inString) {
432
+ if (ch === '{') depth++;
433
+ else if (ch === '}') {
434
+ depth--;
435
+ if (depth === 0) { closeIdx = j + 1; break; }
436
+ }
437
+ }
438
+ }
439
+
440
+ if (closeIdx !== -1) {
441
+ const candidate = text.slice(openBrace, closeIdx);
442
+ try {
443
+ const parsed = JSON.parse(candidate);
444
+ if (parsed && parsed.tool && toolSet.has(parsed.tool)) {
445
+ if (parsed.tool === TOOLS.POWERSHELL) parsed.tool = TOOLS.LOCAL_CMD;
446
+ toolCalls.push(parsed);
447
+ }
448
+ } catch {}
449
+ i = closeIdx;
450
+ } else {
451
+ i = openBrace + 1;
452
+ }
453
+ }
454
+
455
+ // 2. Parse XML-style tool calls: <tool>NAME key="value"</tool> or <tool name="NAME" key="value" />
456
+ if (toolCalls.length === 0) {
457
+ const xmlPattern = /<tool\b([^>]*)>([\s\S]*?)<\/tool\s*>|<tool\b([^>]*)\/>/gi;
458
+ let xmlMatch;
459
+ while ((xmlMatch = xmlPattern.exec(text)) !== null) {
460
+ const outerAttrs = xmlMatch[1] || xmlMatch[3] || '';
461
+ const innerContent = xmlMatch[2] || '';
462
+ let fullArgs = outerAttrs;
463
+ if (innerContent) fullArgs += ' ' + innerContent.trim();
464
+
465
+ const toolNameMatch = fullArgs.match(/^(?:name\s*=\s*["'])?([A-Z][A-Z_0-9]+)(?:["'])?\s*/);
466
+ if (!toolNameMatch) continue;
467
+ const toolName = toolNameMatch[1];
468
+ if (!toolSet.has(toolName)) continue;
469
+
470
+ const toolCall = { tool: toolName };
471
+ const argsStr = fullArgs.slice(toolNameMatch[0].length).trim();
472
+ const argRegex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g;
473
+ let argMatch;
474
+ while ((argMatch = argRegex.exec(argsStr)) !== null) {
475
+ const value = argMatch[2] !== undefined ? argMatch[2]
476
+ : argMatch[3] !== undefined ? argMatch[3]
477
+ : argMatch[4];
478
+ if (value !== undefined) {
479
+ if (toolName === 'POWERSHELL') toolCall.tool = TOOLS.LOCAL_CMD;
480
+ toolCall[argMatch[1]] = value;
481
+ }
482
+ }
483
+ if (Object.keys(toolCall).length > 1) {
484
+ toolCalls.push(toolCall);
485
+ }
486
+ }
487
+ }
488
+
489
+ // 3. Legacy non-JSON fallback: NAME key="value" (no <tool> wrapper)
490
+ if (toolCalls.length === 0) {
491
+ const toolNamePattern = new RegExp(
492
+ `(?:^|\\n)\\s*(${toolNames.join('|')})\\s+([\\s\\S]*?)$`, 'im'
493
+ );
494
+ const toolMatch = text.match(toolNamePattern);
495
+ if (toolMatch) {
496
+ const toolName = toolMatch[1].toUpperCase();
497
+ if (toolSet.has(toolName)) {
498
+ const argsStr = toolMatch[2].trim();
499
+ const toolCall = { tool: toolName };
500
+ const argRegex = /(\w+)\s*=\s*(?:"([^"]*)"|(\S+))/g;
501
+ let argMatch;
502
+ while ((argMatch = argRegex.exec(argsStr)) !== null) {
503
+ const value = argMatch[2] !== undefined ? argMatch[2] : argMatch[3];
504
+ toolCall[argMatch[1]] = value;
505
+ }
506
+ if (Object.keys(toolCall).length > 1) {
507
+ toolCalls.push(toolCall);
508
+ }
509
+ }
510
+ }
511
+ }
512
+
513
+ return toolCalls;
514
+ },
515
+ };