@zds-ai/cli 0.1.5 → 0.1.6

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 (75) hide show
  1. package/dist/agent/grok-agent.d.ts +5 -1
  2. package/dist/agent/grok-agent.js +243 -30
  3. package/dist/agent/grok-agent.js.map +1 -1
  4. package/dist/agent/llm-agent.d.ts +276 -0
  5. package/dist/agent/llm-agent.js +2839 -0
  6. package/dist/agent/llm-agent.js.map +1 -0
  7. package/dist/agent/prompt-variables.d.ts +33 -5
  8. package/dist/agent/prompt-variables.js +162 -28
  9. package/dist/agent/prompt-variables.js.map +1 -1
  10. package/dist/bin/fastcaption.sh +3 -2
  11. package/dist/bin/generate_image_sd.sh +1 -1
  12. package/dist/grok/client.d.ts +9 -9
  13. package/dist/grok/client.js +3 -4
  14. package/dist/grok/client.js.map +1 -1
  15. package/dist/grok/tools.d.ts +5 -5
  16. package/dist/grok/tools.js +12 -12
  17. package/dist/grok/tools.js.map +1 -1
  18. package/dist/hooks/use-input-handler.d.ts +3 -3
  19. package/dist/hooks/use-input-handler.js +19 -12
  20. package/dist/hooks/use-input-handler.js.map +1 -1
  21. package/dist/index.js +17 -10
  22. package/dist/index.js.map +1 -1
  23. package/dist/mcp/client.js +10 -2
  24. package/dist/mcp/client.js.map +1 -1
  25. package/dist/tools/character-tool.js +1 -1
  26. package/dist/tools/character-tool.js.map +1 -1
  27. package/dist/tools/clear-cache-tool.js +1 -1
  28. package/dist/tools/clear-cache-tool.js.map +1 -1
  29. package/dist/tools/file-conversion-tool.js +1 -1
  30. package/dist/tools/file-conversion-tool.js.map +1 -1
  31. package/dist/tools/image-tool.d.ts +2 -2
  32. package/dist/tools/image-tool.js +5 -3
  33. package/dist/tools/image-tool.js.map +1 -1
  34. package/dist/tools/internet-tool.js +1 -1
  35. package/dist/tools/internet-tool.js.map +1 -1
  36. package/dist/tools/introspect-tool.js +7 -12
  37. package/dist/tools/introspect-tool.js.map +1 -1
  38. package/dist/tools/task-tool.js +1 -1
  39. package/dist/tools/task-tool.js.map +1 -1
  40. package/dist/tools/text-editor.js +1 -1
  41. package/dist/tools/text-editor.js.map +1 -1
  42. package/dist/tools/zsh.js +1 -1
  43. package/dist/tools/zsh.js.map +1 -1
  44. package/dist/ui/components/active-task-status.d.ts +2 -2
  45. package/dist/ui/components/api-key-input.d.ts +2 -2
  46. package/dist/ui/components/api-key-input.js +2 -2
  47. package/dist/ui/components/api-key-input.js.map +1 -1
  48. package/dist/ui/components/backend-status.d.ts +2 -2
  49. package/dist/ui/components/chat-history.d.ts +1 -1
  50. package/dist/ui/components/chat-history.js +1 -1
  51. package/dist/ui/components/chat-history.js.map +1 -1
  52. package/dist/ui/components/chat-interface.d.ts +2 -2
  53. package/dist/ui/components/chat-interface.js +4 -0
  54. package/dist/ui/components/chat-interface.js.map +1 -1
  55. package/dist/ui/components/context-status.d.ts +2 -2
  56. package/dist/ui/components/model-selection.js +1 -1
  57. package/dist/ui/components/model-selection.js.map +1 -1
  58. package/dist/ui/components/mood-status.d.ts +2 -2
  59. package/dist/ui/components/persona-status.d.ts +2 -2
  60. package/dist/utils/chat-history-manager.d.ts +1 -1
  61. package/dist/utils/hook-executor.d.ts +8 -2
  62. package/dist/utils/hook-executor.js +138 -13
  63. package/dist/utils/hook-executor.js.map +1 -1
  64. package/dist/utils/rephrase-handler.d.ts +2 -2
  65. package/dist/utils/rephrase-handler.js.map +1 -1
  66. package/dist/utils/settings-manager.d.ts +5 -0
  67. package/dist/utils/settings-manager.js +6 -0
  68. package/dist/utils/settings-manager.js.map +1 -1
  69. package/dist/utils/slash-commands.d.ts +7 -3
  70. package/dist/utils/slash-commands.js +17 -17
  71. package/dist/utils/slash-commands.js.map +1 -1
  72. package/dist/utils/startup-hook.d.ts +3 -3
  73. package/dist/utils/startup-hook.js +9 -4
  74. package/dist/utils/startup-hook.js.map +1 -1
  75. package/package.json +1 -1
@@ -0,0 +1,2839 @@
1
+ import { LLMClient } from "../grok/client.js";
2
+ import { getAllLLMTools, getMCPManager, initializeMCPServers, } from "../grok/tools.js";
3
+ import { loadMCPConfig } from "../mcp/config.js";
4
+ import { ChatHistoryManager } from "../utils/chat-history-manager.js";
5
+ import { logApiError } from "../utils/error-logger.js";
6
+ import { parseImagesFromMessage, hasImageReferences } from "../utils/image-encoder.js";
7
+ import { getTextContent } from "../utils/content-utils.js";
8
+ import { Variable } from "./prompt-variables.js";
9
+ import fs from "fs";
10
+ import { TextEditorTool, MorphEditorTool, ZshTool, ConfirmationTool, SearchTool, EnvTool, IntrospectTool, ClearCacheTool, CharacterTool, TaskTool, InternetTool, ImageTool, FileConversionTool, RestartTool } from "../tools/index.js";
11
+ import { EventEmitter } from "events";
12
+ import { createTokenCounter } from "../utils/token-counter.js";
13
+ import { getSettingsManager } from "../utils/settings-manager.js";
14
+ import { executeOperationHook, executeToolApprovalHook, applyHookCommands } from "../utils/hook-executor.js";
15
+ // Interval (ms) between token count updates when streaming
16
+ const TOKEN_UPDATE_INTERVAL_MS = 250;
17
+ // Minimum delay (in ms) applied when stopping a task to ensure smooth UI/UX.
18
+ const MINIMUM_STOP_TASK_DELAY_MS = 3000;
19
+ // Maximum number of attempts to parse nested JSON strings in executeTool
20
+ const MAX_JSON_PARSE_ATTEMPTS = 5;
21
+ /**
22
+ * Threshold used to determine whether an AI response is "substantial" (in characters).
23
+ */
24
+ const SUBSTANTIAL_RESPONSE_THRESHOLD = 50;
25
+ /**
26
+ * Extracts the first complete JSON object from a string.
27
+ * Handles duplicate/concatenated JSON objects (LLM bug) like: {"key":"val"}{"key":"val"}
28
+ * @param jsonString The string potentially containing concatenated JSON objects
29
+ * @returns The first complete JSON object, or the original string if no duplicates found
30
+ */
31
+ function extractFirstJsonObject(jsonString) {
32
+ if (!jsonString.includes('}{'))
33
+ return jsonString;
34
+ try {
35
+ // Find the end of the first complete JSON object
36
+ let depth = 0;
37
+ let firstObjEnd = -1;
38
+ for (let i = 0; i < jsonString.length; i++) {
39
+ if (jsonString[i] === "{")
40
+ depth++;
41
+ if (jsonString[i] === "}") {
42
+ depth--;
43
+ if (depth === 0) {
44
+ firstObjEnd = i + 1;
45
+ break;
46
+ }
47
+ }
48
+ }
49
+ if (firstObjEnd > 0 && firstObjEnd < jsonString.length) {
50
+ // Extract and validate first object
51
+ const firstObj = jsonString.substring(0, firstObjEnd);
52
+ JSON.parse(firstObj); // Validate it's valid JSON
53
+ return firstObj;
54
+ }
55
+ }
56
+ catch {
57
+ // If extraction fails, return the original string
58
+ }
59
+ return jsonString;
60
+ }
61
+ /**
62
+ * Cleans up LLM-generated JSON argument strings for tool calls.
63
+ * Removes duplicate/concatenated JSON objects and trims.
64
+ * @param args The raw arguments string from the tool call
65
+ * @returns Cleaned and sanitized argument string
66
+ */
67
+ function sanitizeToolArguments(args) {
68
+ let argsString = args?.trim() || "{}";
69
+ // Handle duplicate/concatenated JSON objects (LLM bug)
70
+ const extractedArgsString = extractFirstJsonObject(argsString);
71
+ if (extractedArgsString !== argsString) {
72
+ argsString = extractedArgsString;
73
+ }
74
+ return argsString;
75
+ }
76
+ export class LLMAgent extends EventEmitter {
77
+ llmClient;
78
+ textEditor;
79
+ morphEditor;
80
+ zsh;
81
+ confirmationTool;
82
+ search;
83
+ env;
84
+ introspect;
85
+ clearCacheTool;
86
+ characterTool;
87
+ taskTool;
88
+ internetTool;
89
+ imageTool;
90
+ fileConversionTool;
91
+ restartTool;
92
+ chatHistory = [];
93
+ messages = [];
94
+ tokenCounter;
95
+ abortController = null;
96
+ mcpInitialized = false;
97
+ maxToolRounds;
98
+ temperature;
99
+ maxTokens;
100
+ firstMessageProcessed = false;
101
+ contextWarningAt80 = false;
102
+ contextWarningAt90 = false;
103
+ persona = "";
104
+ personaColor = "white";
105
+ mood = "";
106
+ moodColor = "white";
107
+ activeTask = "";
108
+ activeTaskAction = "";
109
+ activeTaskColor = "white";
110
+ apiKeyEnvVar = "GROK_API_KEY";
111
+ pendingContextEditSession = null;
112
+ rephraseState = null;
113
+ hookPrefillText = null;
114
+ constructor(apiKey, baseURL, model, maxToolRounds, debugLogFile, startupHookOutput, temperature, maxTokens) {
115
+ super();
116
+ const manager = getSettingsManager();
117
+ const savedModel = manager.getCurrentModel();
118
+ const modelToUse = model || savedModel || "grok-code-fast-1";
119
+ this.maxToolRounds = maxToolRounds || 400;
120
+ this.temperature = temperature ?? manager.getTemperature();
121
+ this.maxTokens = maxTokens ?? manager.getMaxTokens();
122
+ // Get display name from environment (set by zai/helpers)
123
+ const displayName = process.env.GROK_BACKEND_DISPLAY_NAME;
124
+ this.llmClient = new LLMClient(apiKey, modelToUse, baseURL, displayName);
125
+ // Set apiKeyEnvVar based on backend name
126
+ const backendName = this.llmClient.getBackendName().toUpperCase();
127
+ this.apiKeyEnvVar = `${backendName}_API_KEY`;
128
+ this.textEditor = new TextEditorTool();
129
+ this.morphEditor = process.env.MORPH_API_KEY ? new MorphEditorTool() : null;
130
+ this.zsh = new ZshTool();
131
+ this.confirmationTool = new ConfirmationTool();
132
+ this.search = new SearchTool();
133
+ this.env = new EnvTool();
134
+ this.introspect = new IntrospectTool();
135
+ this.clearCacheTool = new ClearCacheTool();
136
+ this.restartTool = new RestartTool();
137
+ this.characterTool = new CharacterTool();
138
+ this.taskTool = new TaskTool();
139
+ this.internetTool = new InternetTool();
140
+ this.imageTool = new ImageTool();
141
+ this.fileConversionTool = new FileConversionTool();
142
+ this.textEditor.setAgent(this); // Give text editor access to agent for context awareness
143
+ this.introspect.setAgent(this); // Give introspect access to agent for tool class info
144
+ this.clearCacheTool.setAgent(this); // Give clearCache access to agent
145
+ this.characterTool.setAgent(this); // Give character tool access to agent
146
+ this.taskTool.setAgent(this); // Give task tool access to agent
147
+ this.internetTool.setAgent(this); // Give internet tool access to agent
148
+ this.imageTool.setAgent(this); // Give image tool access to agent
149
+ this.zsh.setAgent(this); // Give zsh tool access to agent for CWD tracking
150
+ this.tokenCounter = createTokenCounter(modelToUse);
151
+ // Initialize MCP servers if configured
152
+ this.initializeMCP(debugLogFile);
153
+ // System message will be set after async initialization
154
+ this.messages.push({
155
+ role: "system",
156
+ content: "Initializing...", // Temporary, will be replaced in initialize()
157
+ });
158
+ // Note: THE system prompt is NOT added to chatHistory
159
+ // Only conversational system messages go in chatHistory
160
+ // Store startup hook output for later use
161
+ this.startupHookOutput = startupHookOutput;
162
+ }
163
+ startupHookOutput;
164
+ systemPrompt = "Initializing..."; // THE system prompt (always at messages[0])
165
+ hasRunInstanceHook = false;
166
+ /**
167
+ * Initialize the agent with dynamic system prompt
168
+ * Must be called after construction
169
+ */
170
+ async initialize() {
171
+ // Build system message
172
+ await this.buildSystemMessage();
173
+ }
174
+ /**
175
+ * Build/rebuild the system message with current tool availability
176
+ * Updates this.systemPrompt which is always used for messages[0]
177
+ */
178
+ async buildSystemMessage() {
179
+ // Generate dynamic tool list using introspect tool
180
+ const toolsResult = await this.introspect.introspect("tools");
181
+ const toolsSection = toolsResult.success ? toolsResult.output : "Tools: Unknown";
182
+ // Set APP:TOOLS variable
183
+ Variable.set("APP:TOOLS", toolsSection);
184
+ // Build THE system prompt
185
+ this.systemPrompt = Variable.renderFull('SYSTEM');
186
+ // Update messages[0] with the system prompt
187
+ this.messages[0] = {
188
+ role: "system",
189
+ content: this.systemPrompt,
190
+ };
191
+ // Note: chatHistory no longer contains THE system prompt
192
+ // Only conversational system messages (persona, mood, etc.) go in chatHistory
193
+ }
194
+ async loadInitialHistory(history, systemPrompt) {
195
+ // Load chatHistory (no system messages in new architecture)
196
+ this.chatHistory = history;
197
+ // Set system prompt if provided, otherwise generate one
198
+ if (systemPrompt) {
199
+ this.setSystemPrompt(systemPrompt);
200
+ }
201
+ else {
202
+ await this.buildSystemMessage();
203
+ }
204
+ // Instance hook now runs in initialize() for both fresh and existing sessions
205
+ // Convert history to messages format for API calls
206
+ const historyMessages = [];
207
+ // Track which tool_call_ids we've seen in assistant messages
208
+ const seenToolCallIds = new Set();
209
+ // First pass: collect all tool_call_ids from assistant messages
210
+ for (const entry of history) {
211
+ if (entry.type === "assistant" && entry.tool_calls) {
212
+ entry.tool_calls.forEach(tc => seenToolCallIds.add(tc.id));
213
+ }
214
+ }
215
+ // Second pass: build history messages, only including tool_results that have matching tool_calls
216
+ const toolResultMessages = [];
217
+ const toolCallIdToMessage = new Map();
218
+ for (const entry of history) {
219
+ switch (entry.type) {
220
+ case "system":
221
+ // All system messages from chatHistory go into conversation (persona, mood, etc.)
222
+ // System messages must always be strings
223
+ historyMessages.push({
224
+ role: "system",
225
+ content: getTextContent(entry.content),
226
+ });
227
+ break;
228
+ case "user":
229
+ // User messages can have images (content arrays)
230
+ historyMessages.push({
231
+ role: "user",
232
+ content: entry.content || "",
233
+ });
234
+ break;
235
+ case "assistant":
236
+ // Assistant messages are always text (no images in responses)
237
+ const assistantMessage = {
238
+ role: "assistant",
239
+ content: getTextContent(entry.content) || "", // Ensure content is never null/undefined
240
+ };
241
+ if (entry.tool_calls && entry.tool_calls.length > 0) {
242
+ // For assistant messages with tool calls, collect the tool results that correspond to them
243
+ const correspondingToolResults = [];
244
+ const toolCallsWithResults = [];
245
+ entry.tool_calls.forEach(tc => {
246
+ // Find the tool_result entry for this tool_call
247
+ const toolResultEntry = history.find(h => h.type === "tool_result" && h.toolCall?.id === tc.id);
248
+ if (toolResultEntry) {
249
+ // Only include this tool_call if we have its result
250
+ toolCallsWithResults.push(tc);
251
+ correspondingToolResults.push({
252
+ role: "tool",
253
+ content: toolResultEntry.toolResult?.output || toolResultEntry.toolResult?.error || "",
254
+ tool_call_id: tc.id,
255
+ });
256
+ }
257
+ });
258
+ // Only add tool_calls if we have at least one with a result
259
+ if (toolCallsWithResults.length > 0) {
260
+ assistantMessage.tool_calls = toolCallsWithResults;
261
+ // Add assistant message
262
+ historyMessages.push(assistantMessage);
263
+ // Add corresponding tool results immediately after
264
+ historyMessages.push(...correspondingToolResults);
265
+ }
266
+ else {
267
+ // No tool results found, just add the assistant message without tool_calls
268
+ historyMessages.push(assistantMessage);
269
+ }
270
+ }
271
+ else {
272
+ historyMessages.push(assistantMessage);
273
+ }
274
+ break;
275
+ case "tool_result":
276
+ // Skip tool_result entries here - they're handled when processing assistant messages with tool_calls
277
+ break;
278
+ // Skip tool_call entries as they are included with assistant
279
+ }
280
+ }
281
+ // Insert history messages after the system message
282
+ this.messages.splice(1, 0, ...historyMessages);
283
+ // Update token count in system message
284
+ const currentTokens = this.tokenCounter.countTokens(this.messages.map(m => typeof m.content === 'string' ? m.content : '').join(''));
285
+ if (this.messages.length > 0 && this.messages[0].role === 'system' && typeof this.messages[0].content === 'string') {
286
+ this.messages[0].content = this.messages[0].content.replace(/Current conversation token usage: .*/, `Current conversation token usage: ${currentTokens}`);
287
+ }
288
+ }
289
+ async initializeMCP(debugLogFile) {
290
+ // Initialize MCP in the background without blocking
291
+ Promise.resolve().then(async () => {
292
+ try {
293
+ const config = loadMCPConfig();
294
+ if (config.servers.length > 0) {
295
+ await initializeMCPServers(debugLogFile);
296
+ }
297
+ }
298
+ catch (error) {
299
+ console.warn("MCP initialization failed:", error);
300
+ }
301
+ finally {
302
+ this.mcpInitialized = true;
303
+ }
304
+ });
305
+ }
306
+ isGrokModel() {
307
+ const currentModel = this.llmClient.getCurrentModel();
308
+ return currentModel.toLowerCase().includes("grok");
309
+ }
310
+ // Heuristic: enable web search only when likely needed
311
+ shouldUseSearchFor(message) {
312
+ const q = message.toLowerCase();
313
+ const keywords = [
314
+ "today",
315
+ "latest",
316
+ "news",
317
+ "trending",
318
+ "breaking",
319
+ "current",
320
+ "now",
321
+ "recent",
322
+ "x.com",
323
+ "twitter",
324
+ "tweet",
325
+ "what happened",
326
+ "as of",
327
+ "update on",
328
+ "release notes",
329
+ "changelog",
330
+ "price",
331
+ ];
332
+ if (keywords.some((k) => q.includes(k)))
333
+ return true;
334
+ // crude date pattern (e.g., 2024/2025) may imply recency
335
+ if (/(20\d{2})/.test(q))
336
+ return true;
337
+ return false;
338
+ }
339
+ async processUserMessage(message) {
340
+ // Detect rephrase commands
341
+ let isRephraseCommand = false;
342
+ let isSystemRephrase = false;
343
+ let messageToSend = message;
344
+ let messageType = "user";
345
+ let prefillText;
346
+ if (message.startsWith("/system rephrase")) {
347
+ isRephraseCommand = true;
348
+ isSystemRephrase = true;
349
+ messageToSend = message.substring(8).trim(); // Strip "/system " (8 chars including space)
350
+ messageType = "system";
351
+ // Extract prefill text after "/system rephrase "
352
+ const prefillMatch = message.match(/^\/system rephrase\s+(.+)$/);
353
+ if (prefillMatch) {
354
+ prefillText = prefillMatch[1];
355
+ }
356
+ }
357
+ else if (message.startsWith("/rephrase")) {
358
+ isRephraseCommand = true;
359
+ messageToSend = message; // Keep full text including "/rephrase"
360
+ messageType = "user";
361
+ // Extract prefill text after "/rephrase "
362
+ const prefillMatch = message.match(/^\/rephrase\s+(.+)$/);
363
+ if (prefillMatch) {
364
+ prefillText = prefillMatch[1];
365
+ }
366
+ }
367
+ // If this is a rephrase command, find the last assistant message
368
+ if (isRephraseCommand) {
369
+ // Find index of last assistant message in chatHistory
370
+ let lastAssistantIndex = -1;
371
+ for (let i = this.chatHistory.length - 1; i >= 0; i--) {
372
+ if (this.chatHistory[i].type === "assistant") {
373
+ lastAssistantIndex = i;
374
+ break;
375
+ }
376
+ }
377
+ if (lastAssistantIndex === -1) {
378
+ throw new Error("No previous assistant message to rephrase");
379
+ }
380
+ // Store rephrase state (will be updated with newResponseIndex after response)
381
+ // For now, just mark that we're in rephrase mode
382
+ this.setRephraseState(lastAssistantIndex, this.chatHistory.length, -1, messageType, prefillText);
383
+ }
384
+ // Before adding the new user message, check if there are incomplete tool calls
385
+ // from a previous interrupted turn. This prevents malformed message sequences
386
+ // that cause Ollama 500 errors.
387
+ const lastMessage = this.messages[this.messages.length - 1];
388
+ if (lastMessage?.role === "assistant" && lastMessage.tool_calls) {
389
+ // Find tool_call_ids that don't have corresponding tool result messages
390
+ const toolCallIds = new Set(lastMessage.tool_calls.map((tc) => tc.id));
391
+ const completedToolCallIds = new Set();
392
+ // Check which tool calls have results
393
+ for (let i = this.messages.length - 1; i >= 0; i--) {
394
+ const msg = this.messages[i];
395
+ if (msg.role === "tool" && msg.tool_call_id) {
396
+ completedToolCallIds.add(msg.tool_call_id);
397
+ }
398
+ // Stop when we hit the assistant message with tool_calls
399
+ if (this.messages[i] === lastMessage)
400
+ break;
401
+ }
402
+ // Add cancelled results for any incomplete tool calls
403
+ for (const toolCallId of toolCallIds) {
404
+ if (!completedToolCallIds.has(toolCallId)) {
405
+ console.error(`Adding cancelled result for incomplete tool call: ${toolCallId}`);
406
+ this.messages.push({
407
+ role: "tool",
408
+ content: "[Cancelled by user]",
409
+ tool_call_id: toolCallId,
410
+ });
411
+ }
412
+ }
413
+ }
414
+ // Clear one-shot variables
415
+ Variable.clearOneShot();
416
+ // Execute instance hook once per session (after first clearOneShot)
417
+ if (!this.hasRunInstanceHook) {
418
+ this.hasRunInstanceHook = true;
419
+ const settings = getSettingsManager();
420
+ const instanceHookPath = settings.getInstanceHook();
421
+ if (instanceHookPath) {
422
+ const hookResult = await executeOperationHook(instanceHookPath, "instance", {}, 30000, false, // Instance hook is not mandatory
423
+ this.getCurrentTokenCount(), this.getMaxContextSize());
424
+ if (hookResult.approved && hookResult.commands && hookResult.commands.length > 0) {
425
+ // Apply hook commands (ENV, TOOL_RESULT, MODEL, SYSTEM, SET*)
426
+ const results = applyHookCommands(hookResult.commands);
427
+ // Apply prompt variables from SET* commands
428
+ for (const [varName, value] of results.promptVars.entries()) {
429
+ Variable.set(varName, value);
430
+ }
431
+ // Process other hook commands (MODEL, BACKEND, ENV)
432
+ await this.processHookCommands(results);
433
+ // Add SYSTEM message to messages array if present
434
+ if (results.system) {
435
+ this.messages.push({
436
+ role: 'system',
437
+ content: results.system
438
+ });
439
+ }
440
+ // Store prefill text from hook if present
441
+ if (results.prefill) {
442
+ this.hookPrefillText = results.prefill;
443
+ }
444
+ }
445
+ }
446
+ }
447
+ // Parse images once if present (for both text extraction and later assembly)
448
+ const parsed = hasImageReferences(messageToSend)
449
+ ? parseImagesFromMessage(messageToSend)
450
+ : { text: messageToSend, images: [] };
451
+ // Set USER:PROMPT variable (text only, images stripped)
452
+ Variable.set("USER:PROMPT", parsed.text);
453
+ // Execute prePrompt hook if configured
454
+ const hookPath = getSettingsManager().getPrePromptHook();
455
+ if (hookPath) {
456
+ const hookResult = await executeOperationHook(hookPath, "prePrompt", { USER_MESSAGE: parsed.text }, 30000, false, // prePrompt hook is never mandatory
457
+ this.getCurrentTokenCount(), this.getMaxContextSize());
458
+ if (hookResult.approved && hookResult.commands) {
459
+ const results = applyHookCommands(hookResult.commands);
460
+ // Set prompt variables from hook output (SET, SET_FILE, SET_TEMP_FILE)
461
+ for (const [varName, value] of results.promptVars.entries()) {
462
+ Variable.set(varName, value);
463
+ }
464
+ // Process other hook commands (MODEL, BACKEND, SYSTEM, etc.)
465
+ await this.processHookCommands(results);
466
+ // Store prefill text from hook if present
467
+ if (results.prefill) {
468
+ this.hookPrefillText = results.prefill;
469
+ }
470
+ }
471
+ }
472
+ // Assemble final message from variables
473
+ const assembledMessage = Variable.renderFull("USER");
474
+ // Add user/system message to conversation
475
+ // Note: System messages can only have string content, so images are only supported for user messages
476
+ const supportsVision = this.llmClient.getSupportsVision();
477
+ let messageContent = assembledMessage;
478
+ if (messageType === "user" && parsed.images.length > 0 && supportsVision) {
479
+ // Construct content array with assembled text and images
480
+ messageContent = [
481
+ { type: "text", text: assembledMessage },
482
+ ...parsed.images
483
+ ];
484
+ }
485
+ const userEntry = {
486
+ type: messageType,
487
+ content: messageContent,
488
+ originalContent: messageType === "user" ? (parsed.images.length > 0 && supportsVision
489
+ ? [{ type: "text", text: parsed.text }, ...parsed.images]
490
+ : parsed.text) : undefined,
491
+ timestamp: new Date(),
492
+ };
493
+ this.chatHistory.push(userEntry);
494
+ // Push to messages array with proper typing based on role
495
+ if (messageType === "user") {
496
+ this.messages.push({ role: "user", content: messageContent });
497
+ }
498
+ else {
499
+ // System messages must have string content only
500
+ this.messages.push({ role: "system", content: typeof messageContent === "string" ? messageContent : messageToSend });
501
+ }
502
+ await this.emitContextChange();
503
+ const newEntries = [userEntry];
504
+ const maxToolRounds = this.maxToolRounds; // Prevent infinite loops
505
+ let toolRounds = 0;
506
+ let consecutiveNonToolResponses = 0;
507
+ try {
508
+ // If this is a rephrase with prefill text, add the assistant message now
509
+ if (this.rephraseState?.prefillText) {
510
+ this.messages.push({
511
+ role: "assistant",
512
+ content: this.rephraseState.prefillText
513
+ });
514
+ }
515
+ // If a hook returned prefill text, add the assistant message now
516
+ if (this.hookPrefillText) {
517
+ this.messages.push({
518
+ role: "assistant",
519
+ content: this.hookPrefillText
520
+ });
521
+ }
522
+ // Always fetch tools fresh - getAllLLMTools() handles lazy refresh internally
523
+ const supportsTools = this.llmClient.getSupportsTools();
524
+ let currentResponse = await this.llmClient.chat(this.messages, supportsTools ? await getAllLLMTools() : [], undefined, this.isGrokModel() && this.shouldUseSearchFor(message)
525
+ ? { search_parameters: { mode: "auto" } }
526
+ : { search_parameters: { mode: "off" } }, this.temperature, this.abortController?.signal, this.maxTokens);
527
+ // Parse XML tool calls from response if present
528
+ if (currentResponse.choices?.[0]?.message) {
529
+ currentResponse.choices[0].message = this.parseXMLToolCalls(currentResponse.choices[0].message);
530
+ }
531
+ // Agent loop - continue until no more tool calls or max rounds reached
532
+ while (toolRounds < maxToolRounds) {
533
+ const assistantMessage = currentResponse.choices?.[0]?.message;
534
+ if (!assistantMessage) {
535
+ throw new Error("No response from LLM");
536
+ }
537
+ // Handle tool calls
538
+ if (assistantMessage.tool_calls &&
539
+ assistantMessage.tool_calls.length > 0) {
540
+ toolRounds++;
541
+ consecutiveNonToolResponses = 0; // Reset counter when AI makes tool calls
542
+ // Clean up tool call arguments before adding to conversation history
543
+ // This prevents Ollama from rejecting malformed tool calls on subsequent API calls
544
+ const cleanedToolCalls = assistantMessage.tool_calls.map(toolCall => {
545
+ let argsString = sanitizeToolArguments(toolCall.function.arguments);
546
+ return {
547
+ ...toolCall,
548
+ function: {
549
+ ...toolCall.function,
550
+ arguments: argsString
551
+ }
552
+ };
553
+ });
554
+ // Add assistant message to conversation
555
+ this.messages.push({
556
+ role: "assistant",
557
+ content: assistantMessage.content || "(Calling tools to perform this request)",
558
+ tool_calls: cleanedToolCalls,
559
+ });
560
+ // Add assistant message to chat history
561
+ const assistantToolCallEntry = {
562
+ type: "assistant",
563
+ content: assistantMessage.content || "(Calling tools to perform this request)",
564
+ timestamp: new Date(),
565
+ tool_calls: assistantMessage.tool_calls,
566
+ };
567
+ this.chatHistory.push(assistantToolCallEntry);
568
+ newEntries.push(assistantToolCallEntry);
569
+ await this.emitContextChange();
570
+ // Create initial tool call entries to show tools are being executed
571
+ // Use cleanedToolCalls to preserve arguments in chatHistory
572
+ cleanedToolCalls.forEach((toolCall) => {
573
+ const toolCallEntry = {
574
+ type: "tool_call",
575
+ content: "Executing...",
576
+ timestamp: new Date(),
577
+ toolCall: toolCall,
578
+ };
579
+ this.chatHistory.push(toolCallEntry);
580
+ newEntries.push(toolCallEntry);
581
+ });
582
+ // Execute tool calls and update the entries
583
+ let toolIndex = 0;
584
+ const completedToolCallIds = new Set();
585
+ try {
586
+ for (const toolCall of cleanedToolCalls) {
587
+ // Check for cancellation before executing each tool
588
+ if (this.abortController?.signal.aborted) {
589
+ console.error(`Tool execution cancelled after ${toolIndex}/${cleanedToolCalls.length} tools`);
590
+ // Add cancelled responses for remaining uncompleted tools
591
+ for (let i = toolIndex; i < cleanedToolCalls.length; i++) {
592
+ const remainingToolCall = cleanedToolCalls[i];
593
+ this.messages.push({
594
+ role: "tool",
595
+ content: "[Cancelled by user]",
596
+ tool_call_id: remainingToolCall.id,
597
+ });
598
+ completedToolCallIds.add(remainingToolCall.id);
599
+ }
600
+ throw new Error("Operation cancelled by user");
601
+ }
602
+ const result = await this.executeTool(toolCall);
603
+ // Update the existing tool_call entry with the result
604
+ const entryIndex = this.chatHistory.findIndex((entry) => entry.type === "tool_call" && entry.toolCall?.id === toolCall.id);
605
+ if (entryIndex !== -1) {
606
+ const updatedEntry = {
607
+ ...this.chatHistory[entryIndex],
608
+ type: "tool_result",
609
+ content: result.success
610
+ ? result.output || "Success"
611
+ : result.error || "Error occurred",
612
+ toolResult: result,
613
+ };
614
+ this.chatHistory[entryIndex] = updatedEntry;
615
+ // Also update in newEntries for return value
616
+ const newEntryIndex = newEntries.findIndex((entry) => entry.type === "tool_call" &&
617
+ entry.toolCall?.id === toolCall.id);
618
+ if (newEntryIndex !== -1) {
619
+ newEntries[newEntryIndex] = updatedEntry;
620
+ }
621
+ }
622
+ // Add tool result to messages with proper format (needed for AI context)
623
+ this.messages.push({
624
+ role: "tool",
625
+ content: result.success
626
+ ? result.output || "Success"
627
+ : result.error || "Error",
628
+ tool_call_id: toolCall.id,
629
+ });
630
+ completedToolCallIds.add(toolCall.id);
631
+ await this.emitContextChange();
632
+ toolIndex++;
633
+ }
634
+ }
635
+ finally {
636
+ // Ensure ALL tool calls in this.messages have results, even if we crashed/errored
637
+ for (const toolCall of cleanedToolCalls) {
638
+ if (!completedToolCallIds.has(toolCall.id)) {
639
+ this.messages.push({
640
+ role: "tool",
641
+ content: "[Error: Tool execution interrupted]",
642
+ tool_call_id: toolCall.id,
643
+ });
644
+ }
645
+ }
646
+ }
647
+ // After all tool results are added, add any system messages from this tool round
648
+ // System messages are added to chatHistory during tool execution (for display)
649
+ // Now we add them to this.messages in the same order (after all tool results)
650
+ // Find the most recent assistant message with tool_calls in chatHistory (search backwards)
651
+ let assistantIndex = -1;
652
+ for (let i = this.chatHistory.length - 1; i >= 0; i--) {
653
+ const entry = this.chatHistory[i];
654
+ if (entry.type === "assistant" && entry.tool_calls && entry.tool_calls.length > 0) {
655
+ assistantIndex = i;
656
+ break;
657
+ }
658
+ }
659
+ if (assistantIndex !== -1) {
660
+ // Collect system messages that appeared after this assistant message
661
+ for (let i = assistantIndex + 1; i < this.chatHistory.length; i++) {
662
+ const entry = this.chatHistory[i];
663
+ const content = getTextContent(entry.content);
664
+ if (entry.type === 'system' && content && content.trim()) {
665
+ this.messages.push({
666
+ role: 'system',
667
+ content: content
668
+ });
669
+ }
670
+ // Stop if we hit another assistant or user message (next turn)
671
+ if (entry.type === 'assistant' || entry.type === 'user') {
672
+ break;
673
+ }
674
+ }
675
+ }
676
+ // Get next response - this might contain more tool calls
677
+ // Debug logging to diagnose tool_call/tool_result mismatch
678
+ const debugLogPath = ChatHistoryManager.getDebugLogPath();
679
+ const timestamp = new Date().toISOString();
680
+ fs.appendFileSync(debugLogPath, `\n${timestamp} - [DEBUG] Messages before API call (${this.messages.length} messages):\n`);
681
+ this.messages.forEach((msg, idx) => {
682
+ const msgSummary = { idx, role: msg.role };
683
+ if (msg.tool_calls)
684
+ msgSummary.tool_calls = msg.tool_calls.map((tc) => tc.id);
685
+ if (msg.tool_call_id)
686
+ msgSummary.tool_call_id = msg.tool_call_id;
687
+ fs.appendFileSync(debugLogPath, ` ${JSON.stringify(msgSummary)}\n`);
688
+ });
689
+ currentResponse = await this.llmClient.chat(this.messages, supportsTools ? await getAllLLMTools() : [], undefined, this.isGrokModel() && this.shouldUseSearchFor(message)
690
+ ? { search_parameters: { mode: "auto" } }
691
+ : { search_parameters: { mode: "off" } }, this.temperature, this.abortController?.signal, this.maxTokens);
692
+ }
693
+ else {
694
+ // No tool calls in this response - only add it if there's actual content
695
+ let trimmedContent = assistantMessage.content?.trim();
696
+ // If this was a rephrase with prefill, prepend the prefill text to the response
697
+ if (trimmedContent && this.rephraseState?.prefillText) {
698
+ trimmedContent = this.rephraseState.prefillText + trimmedContent;
699
+ }
700
+ // If a hook provided prefill, prepend it to the response
701
+ if (trimmedContent && this.hookPrefillText) {
702
+ trimmedContent = this.hookPrefillText + trimmedContent;
703
+ this.hookPrefillText = null; // Clear after use
704
+ }
705
+ if (trimmedContent) {
706
+ const responseEntry = {
707
+ type: "assistant",
708
+ content: trimmedContent,
709
+ timestamp: new Date(),
710
+ };
711
+ this.chatHistory.push(responseEntry);
712
+ this.messages.push({
713
+ role: "assistant",
714
+ content: trimmedContent,
715
+ });
716
+ newEntries.push(responseEntry);
717
+ // Update rephrase state with the new response index
718
+ if (this.rephraseState && this.rephraseState.newResponseIndex === -1) {
719
+ const newResponseIndex = this.chatHistory.length - 1;
720
+ this.setRephraseState(this.rephraseState.originalAssistantMessageIndex, this.rephraseState.rephraseRequestIndex, newResponseIndex, this.rephraseState.messageType, this.rephraseState.prefillText);
721
+ }
722
+ }
723
+ // TODO: HACK - This is a temporary fix to prevent duplicate responses.
724
+ // We need a proper way for the bot to signal task completion, such as:
725
+ // - A special tool call like "taskComplete()"
726
+ // - A finish_reason indicator in the API response
727
+ // - A structured response format that explicitly marks completion
728
+ // For now, we break immediately after a substantial response to avoid
729
+ // the cascade of duplicate responses caused by "give it one more chance" logic.
730
+ // If the AI provided a substantial response (>SUBSTANTIAL_RESPONSE_THRESHOLD chars), task is complete
731
+ if (assistantMessage.content && assistantMessage.content.trim().length > SUBSTANTIAL_RESPONSE_THRESHOLD) {
732
+ break; // Task complete - bot gave a full response
733
+ }
734
+ // Short/empty response, give AI another chance
735
+ currentResponse = await this.llmClient.chat(this.messages, supportsTools ? await getAllLLMTools() : [], undefined, this.isGrokModel() && this.shouldUseSearchFor(message)
736
+ ? { search_parameters: { mode: "auto" } }
737
+ : { search_parameters: { mode: "off" } }, this.temperature, this.abortController?.signal, this.maxTokens);
738
+ // Parse XML tool calls from followup response if present
739
+ if (currentResponse.choices?.[0]?.message) {
740
+ currentResponse.choices[0].message = this.parseXMLToolCalls(currentResponse.choices[0].message);
741
+ }
742
+ const followupMessage = currentResponse.choices?.[0]?.message;
743
+ if (!followupMessage?.tool_calls || followupMessage.tool_calls.length === 0) {
744
+ break; // AI doesn't want to continue
745
+ }
746
+ }
747
+ }
748
+ if (toolRounds >= maxToolRounds) {
749
+ const warningEntry = {
750
+ type: "assistant",
751
+ content: "Maximum tool execution rounds reached. Stopping to prevent infinite loops.",
752
+ timestamp: new Date(),
753
+ };
754
+ this.chatHistory.push(warningEntry);
755
+ newEntries.push(warningEntry);
756
+ }
757
+ // Mark first message as processed so subsequent messages use cached tools
758
+ this.firstMessageProcessed = true;
759
+ // Check if tool support changed during first message processing
760
+ // If model doesn't support tools, regenerate system message without tool list
761
+ const supportsToolsAfter = this.llmClient.getSupportsTools();
762
+ if (!supportsToolsAfter && supportsTools) {
763
+ // Tool support was disabled during first message - regenerate system message
764
+ await this.buildSystemMessage();
765
+ }
766
+ return newEntries;
767
+ }
768
+ catch (error) {
769
+ // Check if context is too large (413 error when vision already disabled)
770
+ if (error.message && error.message.startsWith('CONTEXT_TOO_LARGE:')) {
771
+ const beforeCount = this.chatHistory.length;
772
+ this.compactContext(20);
773
+ const afterCount = this.chatHistory.length;
774
+ const removedCount = beforeCount - afterCount;
775
+ const compactEntry = {
776
+ type: "system",
777
+ content: `Context was too large for backend. Automatically compacted: removed ${removedCount} older messages, keeping last 20 messages. Please retry your request.`,
778
+ timestamp: new Date(),
779
+ };
780
+ this.chatHistory.push(compactEntry);
781
+ // Mark first message as processed
782
+ this.firstMessageProcessed = true;
783
+ return [userEntry, compactEntry];
784
+ }
785
+ const errorEntry = {
786
+ type: "assistant",
787
+ content: `Sorry, I encountered an error: ${error.message}`,
788
+ timestamp: new Date(),
789
+ };
790
+ this.chatHistory.push(errorEntry);
791
+ // Mark first message as processed even on error
792
+ this.firstMessageProcessed = true;
793
+ return [userEntry, errorEntry];
794
+ }
795
+ }
796
+ /**
797
+ * Parse XML-formatted tool calls from message content (x.ai format)
798
+ * Converts <xai:function_call> elements to standard LLMToolCall format
799
+ */
800
+ parseXMLToolCalls(message) {
801
+ if (!message.content || typeof message.content !== 'string') {
802
+ return message;
803
+ }
804
+ const content = message.content;
805
+ const xmlToolCallRegex = /<xai:function_call\s+name="([^"]+)">([\s\S]*?)<\/xai:function_call>/g;
806
+ const matches = Array.from(content.matchAll(xmlToolCallRegex));
807
+ if (matches.length === 0) {
808
+ return message;
809
+ }
810
+ // Parse each XML tool call
811
+ const toolCalls = [];
812
+ let cleanedContent = content;
813
+ for (const match of matches) {
814
+ const functionName = match[1];
815
+ const paramsXML = match[2];
816
+ // Parse parameters
817
+ const paramRegex = /<parameter\s+name="([^"]+)">([^<]*)<\/parameter>/g;
818
+ const paramMatches = Array.from(paramsXML.matchAll(paramRegex));
819
+ const args = {};
820
+ for (const paramMatch of paramMatches) {
821
+ args[paramMatch[1]] = paramMatch[2];
822
+ }
823
+ // Generate a unique ID for this tool call
824
+ const toolCallId = `call_xml_${Date.now()}_${Math.random().toString(36).substring(7)}`;
825
+ toolCalls.push({
826
+ id: toolCallId,
827
+ type: "function",
828
+ function: {
829
+ name: functionName,
830
+ arguments: JSON.stringify(args)
831
+ }
832
+ });
833
+ // Remove this XML block from content
834
+ cleanedContent = cleanedContent.replace(match[0], '');
835
+ }
836
+ // Trim any extra whitespace
837
+ cleanedContent = cleanedContent.trim();
838
+ // Return modified message with tool_calls and cleaned content
839
+ return {
840
+ ...message,
841
+ content: cleanedContent || null,
842
+ tool_calls: [...(message.tool_calls || []), ...toolCalls]
843
+ };
844
+ }
845
+ messageReducer(previous, item) {
846
+ const reduce = (acc, delta) => {
847
+ // Ensure acc is always an object before spreading (handles null/undefined)
848
+ acc = { ...(acc || {}) };
849
+ for (const [key, value] of Object.entries(delta)) {
850
+ // Skip null values in delta (Venice sends tool_calls: null which breaks Object.entries)
851
+ if (value === null)
852
+ continue;
853
+ if (acc[key] === undefined || acc[key] === null) {
854
+ acc[key] = value;
855
+ // Clean up index properties from tool calls
856
+ if (Array.isArray(acc[key])) {
857
+ for (const arr of acc[key]) {
858
+ delete arr.index;
859
+ }
860
+ }
861
+ }
862
+ else if (typeof acc[key] === "string" && typeof value === "string") {
863
+ // Don't concatenate certain properties that should remain separate
864
+ const nonConcatenableProps = ['id', 'type', 'name'];
865
+ if (nonConcatenableProps.includes(key)) {
866
+ // For non-concatenable properties, keep the new value
867
+ acc[key] = value;
868
+ }
869
+ else {
870
+ // For content, arguments, and other text properties, concatenate
871
+ acc[key] += value;
872
+ }
873
+ }
874
+ else if (Array.isArray(acc[key]) && Array.isArray(value)) {
875
+ const accArray = acc[key];
876
+ for (let i = 0; i < value.length; i++) {
877
+ if (!accArray[i])
878
+ accArray[i] = {};
879
+ accArray[i] = reduce(accArray[i], value[i]);
880
+ }
881
+ }
882
+ else if (typeof acc[key] === "object" && typeof value === "object") {
883
+ acc[key] = reduce(acc[key], value);
884
+ }
885
+ }
886
+ return acc;
887
+ };
888
+ return reduce(previous, item.choices?.[0]?.delta || {});
889
+ }
890
+ async *processUserMessageStream(message) {
891
+ // Create new abort controller for this request
892
+ this.abortController = new AbortController();
893
+ // Detect rephrase commands
894
+ let isRephraseCommand = false;
895
+ let isSystemRephrase = false;
896
+ let messageToSend = message;
897
+ let messageType = "user";
898
+ let prefillText;
899
+ if (message.startsWith("/system rephrase")) {
900
+ isRephraseCommand = true;
901
+ isSystemRephrase = true;
902
+ messageToSend = message.substring(8).trim(); // Strip "/system " (8 chars including space)
903
+ messageType = "system";
904
+ // Extract prefill text after "/system rephrase "
905
+ const prefillMatch = message.match(/^\/system rephrase\s+(.+)$/);
906
+ if (prefillMatch) {
907
+ prefillText = prefillMatch[1];
908
+ }
909
+ }
910
+ else if (message.startsWith("/rephrase")) {
911
+ isRephraseCommand = true;
912
+ messageToSend = message; // Keep full text including "/rephrase"
913
+ messageType = "user";
914
+ // Extract prefill text after "/rephrase "
915
+ const prefillMatch = message.match(/^\/rephrase\s+(.+)$/);
916
+ if (prefillMatch) {
917
+ prefillText = prefillMatch[1];
918
+ }
919
+ }
920
+ // If this is a rephrase command, find the last assistant message
921
+ if (isRephraseCommand) {
922
+ // Find index of last assistant message in chatHistory
923
+ let lastAssistantIndex = -1;
924
+ for (let i = this.chatHistory.length - 1; i >= 0; i--) {
925
+ if (this.chatHistory[i].type === "assistant") {
926
+ lastAssistantIndex = i;
927
+ break;
928
+ }
929
+ }
930
+ if (lastAssistantIndex === -1) {
931
+ throw new Error("No previous assistant message to rephrase");
932
+ }
933
+ // Store rephrase state (will be updated with newResponseIndex after response)
934
+ // For now, just mark that we're in rephrase mode
935
+ this.setRephraseState(lastAssistantIndex, this.chatHistory.length, -1, messageType, prefillText);
936
+ }
937
+ // Before adding the new user message, check if there are incomplete tool calls
938
+ // from a previous interrupted turn. This prevents malformed message sequences
939
+ // that cause Ollama 500 errors.
940
+ const lastMessage = this.messages[this.messages.length - 1];
941
+ if (lastMessage?.role === "assistant" && lastMessage.tool_calls) {
942
+ // Find tool_call_ids that don't have corresponding tool result messages
943
+ const toolCallIds = new Set(lastMessage.tool_calls.map((tc) => tc.id));
944
+ const completedToolCallIds = new Set();
945
+ // Check which tool calls have results
946
+ for (let i = this.messages.length - 1; i >= 0; i--) {
947
+ const msg = this.messages[i];
948
+ if (msg.role === "tool" && msg.tool_call_id) {
949
+ completedToolCallIds.add(msg.tool_call_id);
950
+ }
951
+ // Stop when we hit the assistant message with tool_calls
952
+ if (this.messages[i] === lastMessage)
953
+ break;
954
+ }
955
+ // Add cancelled results for any incomplete tool calls
956
+ for (const toolCallId of toolCallIds) {
957
+ if (!completedToolCallIds.has(toolCallId)) {
958
+ console.error(`Adding cancelled result for incomplete tool call: ${toolCallId}`);
959
+ this.messages.push({
960
+ role: "tool",
961
+ content: "[Cancelled by user]",
962
+ tool_call_id: toolCallId,
963
+ });
964
+ }
965
+ }
966
+ }
967
+ // Clear one-shot variables
968
+ Variable.clearOneShot();
969
+ // Execute instance hook once per session (after first clearOneShot)
970
+ if (!this.hasRunInstanceHook) {
971
+ this.hasRunInstanceHook = true;
972
+ const settings = getSettingsManager();
973
+ const instanceHookPath = settings.getInstanceHook();
974
+ if (instanceHookPath) {
975
+ const hookResult = await executeOperationHook(instanceHookPath, "instance", {}, 30000, false, // Instance hook is not mandatory
976
+ this.getCurrentTokenCount(), this.getMaxContextSize());
977
+ if (hookResult.approved && hookResult.commands && hookResult.commands.length > 0) {
978
+ // Apply hook commands (ENV, TOOL_RESULT, MODEL, SYSTEM, SET*)
979
+ const results = applyHookCommands(hookResult.commands);
980
+ // Apply prompt variables from SET* commands
981
+ for (const [varName, value] of results.promptVars.entries()) {
982
+ Variable.set(varName, value);
983
+ }
984
+ // Process other hook commands (MODEL, BACKEND, ENV)
985
+ await this.processHookCommands(results);
986
+ // Add SYSTEM message to messages array if present
987
+ if (results.system) {
988
+ this.messages.push({
989
+ role: 'system',
990
+ content: results.system
991
+ });
992
+ }
993
+ // Store prefill text from hook if present
994
+ if (results.prefill) {
995
+ this.hookPrefillText = results.prefill;
996
+ }
997
+ }
998
+ }
999
+ }
1000
+ // Parse images once if present (for both text extraction and later assembly)
1001
+ const parsed = hasImageReferences(messageToSend)
1002
+ ? parseImagesFromMessage(messageToSend)
1003
+ : { text: messageToSend, images: [] };
1004
+ // Set USER:PROMPT variable (text only, images stripped)
1005
+ Variable.set("USER:PROMPT", parsed.text);
1006
+ // Execute prePrompt hook if configured
1007
+ const hookPath = getSettingsManager().getPrePromptHook();
1008
+ if (hookPath) {
1009
+ const hookResult = await executeOperationHook(hookPath, "prePrompt", { USER_MESSAGE: parsed.text }, 30000, false, // prePrompt hook is never mandatory
1010
+ this.getCurrentTokenCount(), this.getMaxContextSize());
1011
+ if (hookResult.approved && hookResult.commands) {
1012
+ const results = applyHookCommands(hookResult.commands);
1013
+ // Set prompt variables from hook output (SET, SET_FILE, SET_TEMP_FILE)
1014
+ for (const [varName, value] of results.promptVars.entries()) {
1015
+ Variable.set(varName, value);
1016
+ }
1017
+ // Process other hook commands (MODEL, BACKEND, SYSTEM, etc.)
1018
+ await this.processHookCommands(results);
1019
+ // Store prefill text from hook if present
1020
+ if (results.prefill) {
1021
+ this.hookPrefillText = results.prefill;
1022
+ }
1023
+ }
1024
+ }
1025
+ // Assemble final message from variables
1026
+ const assembledMessage = Variable.renderFull("USER");
1027
+ // Add user/system message to both API conversation and chat history
1028
+ // Note: System messages can only have string content, so images are only supported for user messages
1029
+ const supportsVision = this.llmClient.getSupportsVision();
1030
+ let messageContent = assembledMessage;
1031
+ if (messageType === "user" && parsed.images.length > 0 && supportsVision) {
1032
+ // Construct content array with assembled text and images
1033
+ messageContent = [
1034
+ { type: "text", text: assembledMessage },
1035
+ ...parsed.images
1036
+ ];
1037
+ }
1038
+ const userEntry = {
1039
+ type: messageType,
1040
+ content: messageContent,
1041
+ originalContent: messageType === "user" ? (parsed.images.length > 0 && supportsVision
1042
+ ? [{ type: "text", text: parsed.text }, ...parsed.images]
1043
+ : parsed.text) : undefined,
1044
+ timestamp: new Date(),
1045
+ };
1046
+ this.chatHistory.push(userEntry);
1047
+ // Push to messages array with proper typing based on role
1048
+ if (messageType === "user") {
1049
+ this.messages.push({ role: "user", content: messageContent });
1050
+ }
1051
+ else {
1052
+ // System messages must have string content only
1053
+ this.messages.push({ role: "system", content: typeof messageContent === "string" ? messageContent : assembledMessage });
1054
+ }
1055
+ await this.emitContextChange();
1056
+ // Yield user message so UI can display it immediately
1057
+ yield {
1058
+ type: "user_message",
1059
+ userEntry: userEntry,
1060
+ };
1061
+ // If this is a rephrase with prefill text, add the assistant message now
1062
+ if (this.rephraseState?.prefillText) {
1063
+ this.messages.push({
1064
+ role: "assistant",
1065
+ content: this.rephraseState.prefillText
1066
+ });
1067
+ }
1068
+ // If a hook returned prefill text, add the assistant message now
1069
+ if (this.hookPrefillText) {
1070
+ this.messages.push({
1071
+ role: "assistant",
1072
+ content: this.hookPrefillText
1073
+ });
1074
+ }
1075
+ // Calculate input tokens
1076
+ let inputTokens = this.tokenCounter.countMessageTokens(this.messages);
1077
+ yield {
1078
+ type: "token_count",
1079
+ tokenCount: inputTokens,
1080
+ };
1081
+ const maxToolRounds = this.maxToolRounds; // Prevent infinite loops
1082
+ let toolRounds = 0;
1083
+ let totalOutputTokens = 0;
1084
+ let lastTokenUpdate = 0;
1085
+ let consecutiveNonToolResponses = 0;
1086
+ try {
1087
+ // Always fetch tools fresh - getAllLLMTools() handles lazy refresh internally
1088
+ const supportsTools = this.llmClient.getSupportsTools();
1089
+ // Agent loop - continue until no more tool calls or max rounds reached
1090
+ while (toolRounds < maxToolRounds) {
1091
+ // Check if operation was cancelled
1092
+ if (this.abortController?.signal.aborted) {
1093
+ yield {
1094
+ type: "content",
1095
+ content: "\n\n[Operation cancelled by user]",
1096
+ };
1097
+ yield { type: "done" };
1098
+ return;
1099
+ }
1100
+ // Update system message with current token count
1101
+ if (this.messages.length > 0 && this.messages[0].role === 'system' && typeof this.messages[0].content === 'string') {
1102
+ this.messages[0].content = this.messages[0].content.replace(/Current conversation token usage: .*/, `Current conversation token usage: ${inputTokens}`);
1103
+ }
1104
+ // Stream response and accumulate
1105
+ const stream = this.llmClient.chatStream(this.messages, supportsTools ? await getAllLLMTools() : [], undefined, this.isGrokModel() && this.shouldUseSearchFor(message)
1106
+ ? { search_parameters: { mode: "auto" } }
1107
+ : { search_parameters: { mode: "off" } }, this.temperature, this.abortController?.signal, this.maxTokens);
1108
+ let accumulatedMessage = {};
1109
+ let accumulatedContent = "";
1110
+ let tool_calls_yielded = false;
1111
+ let streamFinished = false;
1112
+ let insideThinkTag = false;
1113
+ // If this is a rephrase with prefill, yield the prefill text first and add to accumulated content
1114
+ if (this.rephraseState?.prefillText) {
1115
+ yield {
1116
+ type: "content",
1117
+ content: this.rephraseState.prefillText,
1118
+ };
1119
+ accumulatedContent = this.rephraseState.prefillText;
1120
+ }
1121
+ // If a hook provided prefill, yield it first and add to accumulated content
1122
+ if (this.hookPrefillText) {
1123
+ yield {
1124
+ type: "content",
1125
+ content: this.hookPrefillText,
1126
+ };
1127
+ accumulatedContent = this.hookPrefillText;
1128
+ this.hookPrefillText = null; // Clear after use
1129
+ }
1130
+ try {
1131
+ for await (const chunk of stream) {
1132
+ // Check for cancellation in the streaming loop
1133
+ if (this.abortController?.signal.aborted) {
1134
+ yield {
1135
+ type: "content",
1136
+ content: "\n\n[Operation cancelled by user]",
1137
+ };
1138
+ yield { type: "done" };
1139
+ return;
1140
+ }
1141
+ if (!chunk.choices?.[0])
1142
+ continue;
1143
+ // Check if stream is finished (Venice sends garbage after this)
1144
+ if (chunk.choices?.[0]?.finish_reason === "stop" || chunk.choices?.[0]?.finish_reason === "tool_calls") {
1145
+ streamFinished = true;
1146
+ }
1147
+ // Accumulate the message using reducer
1148
+ accumulatedMessage = this.messageReducer(accumulatedMessage, chunk);
1149
+ // Check for tool calls - yield when we have complete tool calls with function names
1150
+ if (!tool_calls_yielded && accumulatedMessage.tool_calls?.length > 0) {
1151
+ // Check if we have at least one complete tool call with a function name
1152
+ const hasCompleteTool = accumulatedMessage.tool_calls.some((tc) => tc.function?.name);
1153
+ if (hasCompleteTool) {
1154
+ yield {
1155
+ type: "tool_calls",
1156
+ tool_calls: accumulatedMessage.tool_calls,
1157
+ };
1158
+ tool_calls_yielded = true;
1159
+ }
1160
+ }
1161
+ // Stream content as it comes (but ignore content after stream is finished to avoid Venice garbage)
1162
+ if (chunk.choices[0].delta?.content && !streamFinished) {
1163
+ let deltaContent = chunk.choices[0].delta.content;
1164
+ // Handle thinking tags that may span multiple chunks
1165
+ // First, remove complete <think>...</think> blocks within this chunk
1166
+ deltaContent = deltaContent.replace(/<think>[\s\S]*?<\/think>/g, '');
1167
+ // Check for opening <think> tag
1168
+ if (deltaContent.includes('<think>')) {
1169
+ insideThinkTag = true;
1170
+ // Remove everything from <think> onwards in this chunk
1171
+ deltaContent = deltaContent.substring(0, deltaContent.indexOf('<think>'));
1172
+ }
1173
+ // If we're inside a think tag, remove everything up to and including </think>
1174
+ if (insideThinkTag) {
1175
+ if (deltaContent.includes('</think>')) {
1176
+ // Found closing tag - remove everything up to and including it
1177
+ const closeIndex = deltaContent.indexOf('</think>');
1178
+ deltaContent = deltaContent.substring(closeIndex + 8); // 8 = length of '</think>'
1179
+ insideThinkTag = false;
1180
+ }
1181
+ else {
1182
+ // Still inside think block - remove entire chunk
1183
+ deltaContent = '';
1184
+ }
1185
+ }
1186
+ // Skip completely empty chunks after filtering (but keep spaces!)
1187
+ if (deltaContent === '')
1188
+ continue;
1189
+ accumulatedContent += deltaContent;
1190
+ // Update token count in real-time including accumulated content and any tool calls
1191
+ const currentOutputTokens = this.tokenCounter.estimateStreamingTokens(accumulatedContent) +
1192
+ (accumulatedMessage.tool_calls
1193
+ ? this.tokenCounter.countTokens(JSON.stringify(accumulatedMessage.tool_calls))
1194
+ : 0);
1195
+ totalOutputTokens = currentOutputTokens;
1196
+ yield {
1197
+ type: "content",
1198
+ content: deltaContent,
1199
+ };
1200
+ // Emit token count update
1201
+ const now = Date.now();
1202
+ if (now - lastTokenUpdate > TOKEN_UPDATE_INTERVAL_MS) {
1203
+ lastTokenUpdate = now;
1204
+ yield {
1205
+ type: "token_count",
1206
+ tokenCount: inputTokens + totalOutputTokens,
1207
+ };
1208
+ }
1209
+ }
1210
+ }
1211
+ }
1212
+ catch (streamError) {
1213
+ // Check if stream was aborted
1214
+ if (this.abortController?.signal.aborted || streamError.name === 'AbortError' || streamError.code === 'ABORT_ERR') {
1215
+ yield {
1216
+ type: "content",
1217
+ content: "\n\n[Operation cancelled by user]",
1218
+ };
1219
+ yield { type: "done" };
1220
+ return;
1221
+ }
1222
+ // Re-throw other errors to be caught by outer catch
1223
+ throw streamError;
1224
+ }
1225
+ // Parse XML tool calls from accumulated message if present
1226
+ accumulatedMessage = this.parseXMLToolCalls(accumulatedMessage);
1227
+ // Clean up tool call arguments before adding to conversation history
1228
+ // This prevents Ollama from rejecting malformed tool calls on subsequent API calls
1229
+ const cleanedToolCalls = accumulatedMessage.tool_calls?.map(toolCall => {
1230
+ let argsString = sanitizeToolArguments(toolCall.function.arguments);
1231
+ return {
1232
+ ...toolCall,
1233
+ function: {
1234
+ ...toolCall.function,
1235
+ arguments: argsString
1236
+ }
1237
+ };
1238
+ });
1239
+ // Add accumulated message to conversation for API context
1240
+ this.messages.push({
1241
+ role: "assistant",
1242
+ content: accumulatedMessage.content || "(Calling tools to perform this request)",
1243
+ tool_calls: cleanedToolCalls,
1244
+ });
1245
+ // Add assistant message to chat history
1246
+ const assistantEntry = {
1247
+ type: "assistant",
1248
+ content: accumulatedMessage.content || "(Calling tools to perform this request)",
1249
+ timestamp: new Date(),
1250
+ tool_calls: accumulatedMessage.tool_calls,
1251
+ };
1252
+ this.chatHistory.push(assistantEntry);
1253
+ await this.emitContextChange();
1254
+ // Update rephrase state if this is a final response (no tool calls)
1255
+ if (this.rephraseState && this.rephraseState.newResponseIndex === -1 && (!accumulatedMessage.tool_calls || accumulatedMessage.tool_calls.length === 0)) {
1256
+ const newResponseIndex = this.chatHistory.length - 1;
1257
+ this.setRephraseState(this.rephraseState.originalAssistantMessageIndex, this.rephraseState.rephraseRequestIndex, newResponseIndex, this.rephraseState.messageType);
1258
+ }
1259
+ // Handle tool calls if present
1260
+ if (accumulatedMessage.tool_calls?.length > 0) {
1261
+ toolRounds++;
1262
+ // Only yield tool_calls if we haven't already yielded them during streaming
1263
+ if (!tool_calls_yielded) {
1264
+ yield {
1265
+ type: "tool_calls",
1266
+ tool_calls: accumulatedMessage.tool_calls,
1267
+ };
1268
+ }
1269
+ // Add tool_call entries to chatHistory so they persist through UI sync
1270
+ // Use cleanedToolCalls to preserve arguments in chatHistory
1271
+ cleanedToolCalls.forEach((toolCall) => {
1272
+ const toolCallEntry = {
1273
+ type: "tool_call",
1274
+ content: "Executing...",
1275
+ timestamp: new Date(),
1276
+ toolCall: toolCall,
1277
+ };
1278
+ this.chatHistory.push(toolCallEntry);
1279
+ });
1280
+ // Execute tools
1281
+ let toolIndex = 0;
1282
+ const completedToolCallIds = new Set();
1283
+ try {
1284
+ for (const toolCall of cleanedToolCalls) {
1285
+ // Check for cancellation before executing each tool
1286
+ if (this.abortController?.signal.aborted) {
1287
+ console.error(`Tool execution cancelled after ${toolIndex}/${cleanedToolCalls.length} tools`);
1288
+ // Add cancelled responses for remaining uncompleted tools
1289
+ for (let i = toolIndex; i < cleanedToolCalls.length; i++) {
1290
+ const remainingToolCall = cleanedToolCalls[i];
1291
+ this.messages.push({
1292
+ role: "tool",
1293
+ content: "[Cancelled by user]",
1294
+ tool_call_id: remainingToolCall.id,
1295
+ });
1296
+ completedToolCallIds.add(remainingToolCall.id);
1297
+ }
1298
+ yield {
1299
+ type: "content",
1300
+ content: "\n\n[Operation cancelled by user]",
1301
+ };
1302
+ yield { type: "done" };
1303
+ return;
1304
+ }
1305
+ // Capture chatHistory length before tool execution to detect new system messages
1306
+ const chatHistoryLengthBefore = this.chatHistory.length;
1307
+ const result = await this.executeTool(toolCall);
1308
+ // Collect any new system messages added during tool execution (from hooks)
1309
+ const newSystemMessages = [];
1310
+ for (let i = chatHistoryLengthBefore; i < this.chatHistory.length; i++) {
1311
+ if (this.chatHistory[i].type === "system") {
1312
+ newSystemMessages.push(this.chatHistory[i]);
1313
+ }
1314
+ }
1315
+ yield {
1316
+ type: "tool_result",
1317
+ toolCall,
1318
+ toolResult: result,
1319
+ systemMessages: newSystemMessages.length > 0 ? newSystemMessages : undefined,
1320
+ };
1321
+ // Update the tool_call entry in chatHistory to tool_result
1322
+ const entryIndex = this.chatHistory.findIndex((entry) => entry.type === "tool_call" && entry.toolCall?.id === toolCall.id);
1323
+ if (entryIndex !== -1) {
1324
+ this.chatHistory[entryIndex] = {
1325
+ ...this.chatHistory[entryIndex],
1326
+ type: "tool_result",
1327
+ content: result.success
1328
+ ? (result.output?.trim() || "Success")
1329
+ : (result.error?.trim() || "Error occurred"),
1330
+ toolResult: result,
1331
+ };
1332
+ }
1333
+ // Add tool result with proper format (needed for AI context)
1334
+ this.messages.push({
1335
+ role: "tool",
1336
+ content: result.success
1337
+ ? result.output || "Success"
1338
+ : result.error || "Error",
1339
+ tool_call_id: toolCall.id,
1340
+ });
1341
+ completedToolCallIds.add(toolCall.id);
1342
+ toolIndex++;
1343
+ }
1344
+ }
1345
+ finally {
1346
+ // Ensure ALL tool calls in this.messages have results, even if we crashed/errored
1347
+ for (const toolCall of cleanedToolCalls) {
1348
+ if (!completedToolCallIds.has(toolCall.id)) {
1349
+ this.messages.push({
1350
+ role: "tool",
1351
+ content: "[Error: Tool execution interrupted]",
1352
+ tool_call_id: toolCall.id,
1353
+ });
1354
+ }
1355
+ }
1356
+ }
1357
+ // After all tool results are added, add any system messages from this tool round
1358
+ // System messages are added to chatHistory during tool execution (for display)
1359
+ // Now we add them to this.messages in the same order (after all tool results)
1360
+ // Find the most recent assistant message with tool_calls in chatHistory (search backwards)
1361
+ let assistantIndex = -1;
1362
+ for (let i = this.chatHistory.length - 1; i >= 0; i--) {
1363
+ const entry = this.chatHistory[i];
1364
+ if (entry.type === "assistant" && entry.tool_calls && entry.tool_calls.length > 0) {
1365
+ assistantIndex = i;
1366
+ break;
1367
+ }
1368
+ }
1369
+ if (assistantIndex !== -1) {
1370
+ // Collect system messages that appeared after this assistant message
1371
+ for (let i = assistantIndex + 1; i < this.chatHistory.length; i++) {
1372
+ const entry = this.chatHistory[i];
1373
+ const content = getTextContent(entry.content);
1374
+ if (entry.type === 'system' && content && content.trim()) {
1375
+ this.messages.push({
1376
+ role: 'system',
1377
+ content: content
1378
+ });
1379
+ }
1380
+ // Stop if we hit another assistant or user message (next turn)
1381
+ if (entry.type === 'assistant' || entry.type === 'user') {
1382
+ break;
1383
+ }
1384
+ }
1385
+ }
1386
+ // Update token count after processing all tool calls to include tool results
1387
+ inputTokens = this.tokenCounter.countMessageTokens(this.messages);
1388
+ // Final token update after tools processed
1389
+ yield {
1390
+ type: "token_count",
1391
+ tokenCount: inputTokens + totalOutputTokens,
1392
+ };
1393
+ // Continue the loop to get the next response (which might have more tool calls)
1394
+ }
1395
+ else {
1396
+ // No tool calls, we're done
1397
+ break;
1398
+ }
1399
+ }
1400
+ if (toolRounds >= maxToolRounds) {
1401
+ yield {
1402
+ type: "content",
1403
+ content: "\n\nMaximum tool execution rounds reached. Stopping to prevent infinite loops.",
1404
+ };
1405
+ }
1406
+ // Mark first message as processed so subsequent messages use cached tools
1407
+ this.firstMessageProcessed = true;
1408
+ // Check if tool support changed during first message processing
1409
+ // If model doesn't support tools, regenerate system message without tool list
1410
+ const supportsToolsAfter = this.llmClient.getSupportsTools();
1411
+ if (!supportsToolsAfter && supportsTools) {
1412
+ // Tool support was disabled during first message - regenerate system message
1413
+ await this.buildSystemMessage();
1414
+ }
1415
+ yield { type: "done" };
1416
+ }
1417
+ catch (error) {
1418
+ // Check if this was a cancellation (check both abort signal and error name)
1419
+ if (this.abortController?.signal.aborted || error.name === 'AbortError' || error.code === 'ABORT_ERR') {
1420
+ yield {
1421
+ type: "content",
1422
+ content: "\n\n[Operation cancelled by user]",
1423
+ };
1424
+ yield { type: "done" };
1425
+ return;
1426
+ }
1427
+ // Check if context is too large (413 error when vision already disabled)
1428
+ if (error.message && error.message.startsWith('CONTEXT_TOO_LARGE:')) {
1429
+ const beforeCount = this.chatHistory.length;
1430
+ this.compactContext(20);
1431
+ const afterCount = this.chatHistory.length;
1432
+ const removedCount = beforeCount - afterCount;
1433
+ const compactEntry = {
1434
+ type: "system",
1435
+ content: `Context was too large for backend. Automatically compacted: removed ${removedCount} older messages, keeping last 20 messages. Please retry your request.`,
1436
+ timestamp: new Date(),
1437
+ };
1438
+ this.chatHistory.push(compactEntry);
1439
+ yield {
1440
+ type: "content",
1441
+ content: getTextContent(compactEntry.content),
1442
+ };
1443
+ yield { type: "done" };
1444
+ return;
1445
+ }
1446
+ const errorEntry = {
1447
+ type: "assistant",
1448
+ content: `Sorry, I encountered an error: ${error.message}`,
1449
+ timestamp: new Date(),
1450
+ };
1451
+ this.chatHistory.push(errorEntry);
1452
+ yield {
1453
+ type: "content",
1454
+ content: getTextContent(errorEntry.content),
1455
+ };
1456
+ // Mark first message as processed even on error
1457
+ this.firstMessageProcessed = true;
1458
+ yield { type: "done" };
1459
+ }
1460
+ finally {
1461
+ // Clean up abort controller
1462
+ this.abortController = null;
1463
+ }
1464
+ }
1465
+ /**
1466
+ * Apply default parameter values for tools
1467
+ * This ensures the approval hook sees the same parameters that will be used during execution
1468
+ */
1469
+ applyToolParameterDefaults(toolName, params) {
1470
+ // Handle null/undefined params (can happen if API sends "null" as arguments string)
1471
+ const result = { ...(params || {}) };
1472
+ switch (toolName) {
1473
+ case "listFiles":
1474
+ // dirname defaults to current directory
1475
+ if (!result.dirname) {
1476
+ result.dirname = ".";
1477
+ }
1478
+ break;
1479
+ // Add other tools with defaults here as needed
1480
+ }
1481
+ return result;
1482
+ }
1483
+ /**
1484
+ * Validate tool arguments against the tool's schema
1485
+ * Returns null if valid, or an error message if invalid
1486
+ */
1487
+ async validateToolArguments(toolName, args) {
1488
+ try {
1489
+ // Get all tools (including MCP tools)
1490
+ const supportsTools = this.llmClient.getSupportsTools();
1491
+ const allTools = supportsTools ? await getAllLLMTools() : [];
1492
+ // Find the tool schema
1493
+ const toolSchema = allTools.find(t => t.function.name === toolName);
1494
+ if (!toolSchema) {
1495
+ return `Unknown tool: ${toolName}`;
1496
+ }
1497
+ const schema = toolSchema.function.parameters;
1498
+ const properties = schema.properties || {};
1499
+ const required = schema.required || [];
1500
+ // Check if tool accepts no parameters
1501
+ const acceptsNoParams = Object.keys(properties).length === 0;
1502
+ const hasArgs = args && typeof args === 'object' && Object.keys(args).length > 0;
1503
+ if (acceptsNoParams && hasArgs) {
1504
+ return `Tool ${toolName} accepts no parameters, but received: ${JSON.stringify(args)}`;
1505
+ }
1506
+ // Check for unknown parameters
1507
+ for (const argKey of Object.keys(args || {})) {
1508
+ if (!properties[argKey]) {
1509
+ return `Tool ${toolName} does not accept parameter '${argKey}'. Valid parameters: ${Object.keys(properties).join(', ') || 'none'}`;
1510
+ }
1511
+ }
1512
+ // Check for missing required parameters
1513
+ for (const requiredParam of required) {
1514
+ if (!(requiredParam in (args || {}))) {
1515
+ return `Tool ${toolName} missing required parameter '${requiredParam}'`;
1516
+ }
1517
+ }
1518
+ return null; // Valid
1519
+ }
1520
+ catch (error) {
1521
+ console.error(`Error validating tool arguments for ${toolName}:`, error);
1522
+ return null; // Allow execution if validation itself fails
1523
+ }
1524
+ }
1525
+ async executeTool(toolCall) {
1526
+ try {
1527
+ // Parse arguments - handle empty string as empty object for parameter-less tools
1528
+ let argsString = toolCall.function.arguments?.trim() || "{}";
1529
+ // Handle duplicate/concatenated JSON objects (LLM bug)
1530
+ // Pattern: {"key":"val"}{"key":"val"}
1531
+ let hadDuplicateJson = false;
1532
+ const extractedArgsString = extractFirstJsonObject(argsString);
1533
+ if (extractedArgsString !== argsString) {
1534
+ hadDuplicateJson = true;
1535
+ argsString = extractedArgsString;
1536
+ }
1537
+ let args = JSON.parse(argsString);
1538
+ // Handle multiple layers of JSON encoding (API bug)
1539
+ // Keep parsing until we get an object, not a string
1540
+ let parseCount = 0;
1541
+ while (typeof args === 'string' && parseCount < MAX_JSON_PARSE_ATTEMPTS) {
1542
+ parseCount++;
1543
+ try {
1544
+ args = JSON.parse(args);
1545
+ }
1546
+ catch (e) {
1547
+ // If parse fails, the string isn't valid JSON - stop trying
1548
+ break;
1549
+ }
1550
+ }
1551
+ // Log if we had to fix encoding
1552
+ if (parseCount > 0) {
1553
+ const bugMsg = `[BUG] Tool ${toolCall.function.name} had ${parseCount} extra layer(s) of JSON encoding`;
1554
+ console.warn(bugMsg);
1555
+ const systemMsg = `Warning: Tool arguments for ${toolCall.function.name} had ${parseCount} extra encoding layer(s) - this is an API bug`;
1556
+ this.messages.push({
1557
+ role: 'system',
1558
+ content: systemMsg
1559
+ });
1560
+ this.chatHistory.push({
1561
+ type: 'system',
1562
+ content: systemMsg,
1563
+ timestamp: new Date()
1564
+ });
1565
+ }
1566
+ // Log if we had to fix duplicate JSON
1567
+ if (hadDuplicateJson) {
1568
+ const bugMsg = `[BUG] Tool ${toolCall.function.name} had duplicate/concatenated JSON objects`;
1569
+ console.warn(bugMsg);
1570
+ const systemMsg = `Warning: Tool arguments for ${toolCall.function.name} had duplicate JSON objects (used first object only) - this is an LLM bug`;
1571
+ this.messages.push({
1572
+ role: 'system',
1573
+ content: systemMsg
1574
+ });
1575
+ this.chatHistory.push({
1576
+ type: 'system',
1577
+ content: systemMsg,
1578
+ timestamp: new Date()
1579
+ });
1580
+ }
1581
+ // Ensure args is always an object (API might send null)
1582
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
1583
+ args = {};
1584
+ }
1585
+ // Apply parameter defaults before validation and execution
1586
+ args = this.applyToolParameterDefaults(toolCall.function.name, args);
1587
+ // Validate tool arguments against schema
1588
+ const validationError = await this.validateToolArguments(toolCall.function.name, args);
1589
+ if (validationError) {
1590
+ // Validation failed - return error
1591
+ const errorMsg = `Tool call validation failed: ${validationError}. Please try again with correct parameters.`;
1592
+ console.warn(`[VALIDATION ERROR] ${errorMsg}`);
1593
+ return {
1594
+ success: false,
1595
+ error: validationError
1596
+ };
1597
+ }
1598
+ // Task tools (startActiveTask, transitionActiveTaskStatus, stopActiveTask) have their own
1599
+ // dedicated task approval hook, so skip the general tool approval hook for them
1600
+ const isTaskTool = ['startActiveTask', 'transitionActiveTaskStatus', 'stopActiveTask'].includes(toolCall.function.name);
1601
+ // Check tool approval hook if configured (skip for task tools)
1602
+ const settings = getSettingsManager();
1603
+ const toolApprovalHook = settings.getToolApprovalHook();
1604
+ if (toolApprovalHook && !isTaskTool) {
1605
+ const approvalResult = await executeToolApprovalHook(toolApprovalHook, toolCall.function.name, args, 30000, // 30 second timeout
1606
+ this.getCurrentTokenCount(), this.getMaxContextSize());
1607
+ if (!approvalResult.approved) {
1608
+ const reason = approvalResult.reason || "Tool execution denied by approval hook";
1609
+ // Process rejection commands (MODEL, SYSTEM, BACKEND, etc.)
1610
+ await this.processHookResult(approvalResult);
1611
+ return {
1612
+ success: false,
1613
+ error: `Tool execution blocked: ${reason}`,
1614
+ };
1615
+ }
1616
+ if (approvalResult.timedOut) {
1617
+ // Log timeout for debugging (don't block)
1618
+ console.warn(`Tool approval hook timed out for ${toolCall.function.name} (auto-approved)`);
1619
+ }
1620
+ // Process hook commands (ENV, TOOL_RESULT, MODEL, SYSTEM, BACKEND, etc.)
1621
+ // TOOL_RESULT is for tool return values, not used by approval hook
1622
+ // ENV variables can affect tool behavior if tools read from process.env
1623
+ await this.processHookResult(approvalResult);
1624
+ }
1625
+ switch (toolCall.function.name) {
1626
+ case "viewFile":
1627
+ {
1628
+ let range;
1629
+ range = args.start_line && args.end_line
1630
+ ? [args.start_line, args.end_line]
1631
+ : undefined;
1632
+ return await this.textEditor.viewFile(args.filename, range);
1633
+ }
1634
+ case "createNewFile":
1635
+ return await this.textEditor.createNewFile(args.filename, args.content);
1636
+ case "strReplace":
1637
+ return await this.textEditor.strReplace(args.filename, args.old_str, args.new_str, args.replace_all);
1638
+ case "editFile":
1639
+ if (!this.morphEditor) {
1640
+ return {
1641
+ success: false,
1642
+ error: "Morph Fast Apply not available. Please set MORPH_API_KEY environment variable to use this feature.",
1643
+ };
1644
+ }
1645
+ return await this.morphEditor.editFile(args.filename, args.instructions, args.code_edit);
1646
+ case "execute":
1647
+ return await this.zsh.execute(args.command);
1648
+ case "listFiles":
1649
+ return await this.zsh.listFiles(args.dirname);
1650
+ case "universalSearch":
1651
+ return await this.search.universalSearch(args.query, {
1652
+ searchType: args.search_type,
1653
+ includePattern: args.include_pattern,
1654
+ excludePattern: args.exclude_pattern,
1655
+ caseSensitive: args.case_sensitive,
1656
+ wholeWord: args.whole_word,
1657
+ regex: args.regex,
1658
+ maxResults: args.max_results,
1659
+ fileTypes: args.file_types,
1660
+ includeHidden: args.include_hidden,
1661
+ });
1662
+ case "getEnv":
1663
+ return await this.env.getEnv(args.variable);
1664
+ case "getAllEnv":
1665
+ return await this.env.getAllEnv();
1666
+ case "searchEnv":
1667
+ return await this.env.searchEnv(args.pattern);
1668
+ case "introspect":
1669
+ return await this.introspect.introspect(args.target);
1670
+ case "clearCache":
1671
+ return await this.clearCacheTool.clearCache(args.confirmationCode);
1672
+ case "restart":
1673
+ return await this.restartTool.restart();
1674
+ case "setPersona":
1675
+ return await this.characterTool.setPersona(args.persona, args.color);
1676
+ case "setMood":
1677
+ return await this.characterTool.setMood(args.mood, args.color);
1678
+ case "getPersona":
1679
+ return await this.characterTool.getPersona();
1680
+ case "getMood":
1681
+ return await this.characterTool.getMood();
1682
+ case "getAvailablePersonas":
1683
+ return await this.characterTool.getAvailablePersonas();
1684
+ case "startActiveTask":
1685
+ return await this.taskTool.startActiveTask(args.activeTask, args.action, args.color);
1686
+ case "transitionActiveTaskStatus":
1687
+ return await this.taskTool.transitionActiveTaskStatus(args.action, args.color);
1688
+ case "stopActiveTask":
1689
+ return await this.taskTool.stopActiveTask(args.reason, args.documentationFile, args.color);
1690
+ case "insertLines":
1691
+ return await this.textEditor.insertLines(args.filename, args.insert_line, args.new_str);
1692
+ case "replaceLines":
1693
+ return await this.textEditor.replaceLines(args.filename, args.start_line, args.end_line, args.new_str);
1694
+ case "undoEdit":
1695
+ return await this.textEditor.undoEdit();
1696
+ case "chdir":
1697
+ return this.zsh.chdir(args.dirname);
1698
+ case "pwdir":
1699
+ return this.zsh.pwdir();
1700
+ case "downloadFile":
1701
+ return await this.internetTool.downloadFile(args.url);
1702
+ case "generateImage":
1703
+ return await this.imageTool.generateImage(args.prompt, args.negativePrompt, args.width, args.height, args.model, args.sampler, args.configScale, args.numSteps, args.nsfw, args.name, args.move, args.seed);
1704
+ case "captionImage":
1705
+ return await this.imageTool.captionImage(args.filename, args.backend);
1706
+ case "pngInfo":
1707
+ return await this.imageTool.pngInfo(args.filename);
1708
+ case "listImageModels":
1709
+ return await this.imageTool.listImageModels();
1710
+ case "readXlsx":
1711
+ return await this.fileConversionTool.readXlsx(args.filename, args.sheetName, args.outputFormat, args.output);
1712
+ case "listXlsxSheets":
1713
+ return await this.fileConversionTool.listXlsxSheets(args.filename);
1714
+ default:
1715
+ // Check if this is an MCP tool
1716
+ if (toolCall.function.name.startsWith("mcp__")) {
1717
+ return await this.executeMCPTool(toolCall.function.name, args);
1718
+ }
1719
+ return {
1720
+ success: false,
1721
+ error: `Unknown tool: ${toolCall.function.name}`,
1722
+ };
1723
+ }
1724
+ }
1725
+ catch (error) {
1726
+ return {
1727
+ success: false,
1728
+ error: `Tool execution error: ${error.message}`,
1729
+ };
1730
+ }
1731
+ }
1732
+ async executeMCPTool(toolName, args) {
1733
+ try {
1734
+ const mcpManager = getMCPManager();
1735
+ const result = await mcpManager.callTool(toolName, args);
1736
+ if (result.isError) {
1737
+ return {
1738
+ success: false,
1739
+ error: result.content[0]?.text || "MCP tool error",
1740
+ };
1741
+ }
1742
+ // Extract content from result
1743
+ const output = result.content
1744
+ .map((item) => {
1745
+ if (item.type === "text") {
1746
+ return item.text;
1747
+ }
1748
+ else if (item.type === "resource") {
1749
+ return `Resource: ${item.resource?.uri || "Unknown"}`;
1750
+ }
1751
+ return String(item);
1752
+ })
1753
+ .join("\n");
1754
+ // After successful MCP tool execution, invalidate cache for that server
1755
+ // Next call to getAllLLMTools() will lazy-refresh this server
1756
+ const serverNameMatch = toolName.match(/^mcp__(.+?)__/);
1757
+ if (serverNameMatch) {
1758
+ const serverName = serverNameMatch[1];
1759
+ mcpManager.invalidateCache(serverName);
1760
+ }
1761
+ return {
1762
+ success: true,
1763
+ output: output || "Success",
1764
+ };
1765
+ }
1766
+ catch (error) {
1767
+ return {
1768
+ success: false,
1769
+ error: `MCP tool execution error: ${error.message}`,
1770
+ };
1771
+ }
1772
+ }
1773
+ getChatHistory() {
1774
+ return [...this.chatHistory];
1775
+ }
1776
+ setChatHistory(history) {
1777
+ this.chatHistory = [...history];
1778
+ }
1779
+ getSystemPrompt() {
1780
+ return this.systemPrompt;
1781
+ }
1782
+ setSystemPrompt(prompt) {
1783
+ this.systemPrompt = prompt;
1784
+ this.messages[0] = {
1785
+ role: "system",
1786
+ content: prompt,
1787
+ };
1788
+ }
1789
+ getMessages() {
1790
+ return [...this.messages];
1791
+ }
1792
+ getCurrentTokenCount() {
1793
+ return this.tokenCounter.countMessageTokens(this.messages);
1794
+ }
1795
+ getMaxContextSize() {
1796
+ // TODO: Make this model-specific when different models have different context windows
1797
+ // For now, return the standard Grok context window size
1798
+ return 128000;
1799
+ }
1800
+ getContextUsagePercent() {
1801
+ const current = this.getCurrentTokenCount();
1802
+ const max = this.getMaxContextSize();
1803
+ return (current / max) * 100;
1804
+ }
1805
+ /**
1806
+ * Convert context messages to markdown format for viewing
1807
+ * Format: (N) Name (role) - timestamp
1808
+ */
1809
+ async convertContextToMarkdown() {
1810
+ const lines = [];
1811
+ // Header
1812
+ const { ChatHistoryManager } = await import("../utils/chat-history-manager.js");
1813
+ const historyManager = ChatHistoryManager.getInstance();
1814
+ const contextFilePath = historyManager.getContextFilePath();
1815
+ lines.push("# Conversation Context");
1816
+ lines.push(`Context File: ${contextFilePath}`);
1817
+ lines.push(`Session: ${process.env.ZDS_AI_AGENT_SESSION || "N/A"}`);
1818
+ lines.push(`Tokens: ${this.getCurrentTokenCount()} / ${this.getMaxContextSize()} (${this.getContextUsagePercent().toFixed(1)}%)`);
1819
+ lines.push("");
1820
+ lines.push("---");
1821
+ lines.push("");
1822
+ // Get agent name from environment or default
1823
+ const agentName = process.env.ZDS_AI_AGENT_BOT_NAME || "Assistant";
1824
+ const userName = process.env.ZDS_AI_AGENT_MESSAGE_AUTHOR || "User";
1825
+ // Process messages
1826
+ this.chatHistory.forEach((entry, index) => {
1827
+ const msgNum = index + 1;
1828
+ const timestamp = entry.timestamp.toLocaleTimeString();
1829
+ if (entry.type === 'user') {
1830
+ lines.push(`(${msgNum}) ${userName} (user) - ${timestamp}`);
1831
+ lines.push(getTextContent(entry.content) || "");
1832
+ lines.push("");
1833
+ }
1834
+ else if (entry.type === 'assistant') {
1835
+ lines.push(`(${msgNum}) ${agentName} (assistant) - ${timestamp}`);
1836
+ lines.push(getTextContent(entry.content) || "");
1837
+ lines.push("");
1838
+ }
1839
+ else if (entry.type === 'system') {
1840
+ lines.push(`(${msgNum}) System (system) - ${timestamp}`);
1841
+ lines.push(getTextContent(entry.content) || "");
1842
+ lines.push("");
1843
+ }
1844
+ else if (entry.type === 'tool_call') {
1845
+ const toolCall = entry.toolCall;
1846
+ const toolName = toolCall?.function?.name || "unknown";
1847
+ const params = toolCall?.function?.arguments ? JSON.parse(toolCall.function.arguments) : {};
1848
+ lines.push(`(${msgNum}) ${agentName} (tool_call: ${toolName}) - ${timestamp}`);
1849
+ lines.push(`Parameters: ${JSON.stringify(params, null, 2)}`);
1850
+ lines.push("");
1851
+ }
1852
+ else if (entry.type === 'tool_result') {
1853
+ const toolCall = entry.toolCall;
1854
+ const toolName = toolCall?.function?.name || "unknown";
1855
+ lines.push(`(${msgNum}) System (tool_result: ${toolName}) - ${timestamp}`);
1856
+ lines.push(getTextContent(entry.content) || "");
1857
+ lines.push("");
1858
+ }
1859
+ });
1860
+ return lines.join("\n");
1861
+ }
1862
+ getPersona() {
1863
+ return this.persona;
1864
+ }
1865
+ getPersonaColor() {
1866
+ return this.personaColor;
1867
+ }
1868
+ getMood() {
1869
+ return this.mood;
1870
+ }
1871
+ getMoodColor() {
1872
+ return this.moodColor;
1873
+ }
1874
+ getActiveTask() {
1875
+ return this.activeTask;
1876
+ }
1877
+ getActiveTaskAction() {
1878
+ return this.activeTaskAction;
1879
+ }
1880
+ getActiveTaskColor() {
1881
+ return this.activeTaskColor;
1882
+ }
1883
+ setPendingContextEditSession(tmpJsonPath, contextFilePath) {
1884
+ this.pendingContextEditSession = { tmpJsonPath, contextFilePath };
1885
+ }
1886
+ getPendingContextEditSession() {
1887
+ return this.pendingContextEditSession;
1888
+ }
1889
+ clearPendingContextEditSession() {
1890
+ this.pendingContextEditSession = null;
1891
+ }
1892
+ setRephraseState(originalAssistantMessageIndex, rephraseRequestIndex, newResponseIndex, messageType, prefillText) {
1893
+ this.rephraseState = { originalAssistantMessageIndex, rephraseRequestIndex, newResponseIndex, messageType, prefillText };
1894
+ }
1895
+ getRephraseState() {
1896
+ return this.rephraseState;
1897
+ }
1898
+ clearRephraseState() {
1899
+ this.rephraseState = null;
1900
+ }
1901
+ async setPersona(persona, color) {
1902
+ // Execute hook if configured
1903
+ const settings = getSettingsManager();
1904
+ const hookPath = settings.getPersonaHook();
1905
+ const hookMandatory = settings.isPersonaHookMandatory();
1906
+ if (!hookPath && hookMandatory) {
1907
+ const reason = "Persona hook is mandatory but not configured";
1908
+ return {
1909
+ success: false,
1910
+ error: reason
1911
+ };
1912
+ }
1913
+ if (hookPath) {
1914
+ const hookResult = await executeOperationHook(hookPath, "setPersona", {
1915
+ persona_old: this.persona || "",
1916
+ persona_new: persona,
1917
+ persona_color: color || "white"
1918
+ }, 30000, hookMandatory, this.getCurrentTokenCount(), this.getMaxContextSize());
1919
+ if (!hookResult.approved) {
1920
+ const reason = hookResult.reason || "Hook rejected persona change";
1921
+ // Process rejection commands (MODEL, SYSTEM)
1922
+ // Even in rejection, we process commands (might have MODEL change)
1923
+ await this.processHookResult(hookResult);
1924
+ // Note: We ignore the return value here since we're already rejecting the persona
1925
+ return {
1926
+ success: false,
1927
+ error: reason
1928
+ };
1929
+ }
1930
+ if (hookResult.timedOut) {
1931
+ // Hook timed out but was auto-approved
1932
+ }
1933
+ // Process hook commands (ENV, MODEL, SYSTEM)
1934
+ const result = await this.processHookResult(hookResult, 'ZDS_AI_AGENT_PERSONA');
1935
+ if (!result.success) {
1936
+ // Model/backend test failed - don't apply persona change
1937
+ return {
1938
+ success: false,
1939
+ error: "Persona change rejected due to failed model/backend test"
1940
+ };
1941
+ }
1942
+ // Apply persona transformation if present
1943
+ if (result.transformedValue) {
1944
+ persona = result.transformedValue;
1945
+ }
1946
+ }
1947
+ const oldPersona = this.persona;
1948
+ const oldColor = this.personaColor;
1949
+ this.persona = persona;
1950
+ this.personaColor = color || "white";
1951
+ process.env.ZDS_AI_AGENT_PERSONA = persona;
1952
+ // Persona hook generates success message - no need for redundant CLI message
1953
+ this.emit('personaChange', {
1954
+ persona: this.persona,
1955
+ color: this.personaColor
1956
+ });
1957
+ return { success: true };
1958
+ }
1959
+ async setMood(mood, color) {
1960
+ // Execute hook if configured
1961
+ const settings = getSettingsManager();
1962
+ const hookPath = settings.getMoodHook();
1963
+ const hookMandatory = settings.isMoodHookMandatory();
1964
+ if (!hookPath && hookMandatory) {
1965
+ const reason = "Mood hook is mandatory but not configured";
1966
+ return {
1967
+ success: false,
1968
+ error: reason
1969
+ };
1970
+ }
1971
+ if (hookPath) {
1972
+ const hookResult = await executeOperationHook(hookPath, "setMood", {
1973
+ mood_old: this.mood || "",
1974
+ mood_new: mood,
1975
+ mood_color: color || "white"
1976
+ }, 30000, hookMandatory, this.getCurrentTokenCount(), this.getMaxContextSize());
1977
+ if (!hookResult.approved) {
1978
+ const reason = hookResult.reason || "Hook rejected mood change";
1979
+ // Process rejection commands (MODEL, SYSTEM)
1980
+ await this.processHookResult(hookResult);
1981
+ return {
1982
+ success: false,
1983
+ error: reason
1984
+ };
1985
+ }
1986
+ if (hookResult.timedOut) {
1987
+ // Hook timed out but was auto-approved
1988
+ }
1989
+ // Process hook commands (ENV, MODEL, SYSTEM)
1990
+ const result = await this.processHookResult(hookResult, 'ZDS_AI_AGENT_MOOD');
1991
+ if (!result.success) {
1992
+ // Model/backend test failed - don't apply mood change
1993
+ return {
1994
+ success: false,
1995
+ error: "Mood change rejected due to failed model/backend test"
1996
+ };
1997
+ }
1998
+ // Apply mood transformation if present
1999
+ if (result.transformedValue) {
2000
+ mood = result.transformedValue;
2001
+ }
2002
+ }
2003
+ const oldMood = this.mood;
2004
+ const oldColor = this.moodColor;
2005
+ this.mood = mood;
2006
+ this.moodColor = color || "white";
2007
+ process.env.ZDS_AI_AGENT_MOOD = mood;
2008
+ // Add system message for recordkeeping
2009
+ let systemContent;
2010
+ if (oldMood) {
2011
+ const oldColorStr = oldColor && oldColor !== "white" ? ` (${oldColor})` : "";
2012
+ const newColorStr = this.moodColor && this.moodColor !== "white" ? ` (${this.moodColor})` : "";
2013
+ systemContent = `Assistant changed the mood from "${oldMood}"${oldColorStr} to "${this.mood}"${newColorStr}`;
2014
+ }
2015
+ else {
2016
+ const colorStr = this.moodColor && this.moodColor !== "white" ? ` (${this.moodColor})` : "";
2017
+ systemContent = `Assistant set the mood to "${this.mood}"${colorStr}`;
2018
+ }
2019
+ // Note: Don't add to this.messages during tool execution - only chatHistory
2020
+ // System messages added during tool execution create invalid message sequences
2021
+ // because they get inserted between tool_calls and tool_results
2022
+ this.chatHistory.push({
2023
+ type: 'system',
2024
+ content: systemContent,
2025
+ timestamp: new Date()
2026
+ });
2027
+ this.emit('moodChange', {
2028
+ mood: this.mood,
2029
+ color: this.moodColor
2030
+ });
2031
+ return { success: true };
2032
+ }
2033
+ async startActiveTask(activeTask, action, color) {
2034
+ // Cannot start new task if one already exists
2035
+ if (this.activeTask) {
2036
+ return {
2037
+ success: false,
2038
+ error: `Cannot start new task "${activeTask}". Active task "${this.activeTask}" must be stopped first.`
2039
+ };
2040
+ }
2041
+ // Execute hook if configured
2042
+ const settings = getSettingsManager();
2043
+ const hookPath = settings.getTaskApprovalHook();
2044
+ if (hookPath) {
2045
+ const hookResult = await executeOperationHook(hookPath, "startActiveTask", {
2046
+ activetask: activeTask,
2047
+ action: action,
2048
+ color: color || "white"
2049
+ }, 30000, false, // Task hook is not mandatory
2050
+ this.getCurrentTokenCount(), this.getMaxContextSize());
2051
+ // Process hook commands (MODEL, SYSTEM, ENV, BACKEND, etc.) for both approval and rejection
2052
+ await this.processHookResult(hookResult);
2053
+ if (!hookResult.approved) {
2054
+ const reason = hookResult.reason || "Hook rejected task start";
2055
+ return {
2056
+ success: false,
2057
+ error: reason
2058
+ };
2059
+ }
2060
+ if (hookResult.timedOut) {
2061
+ // Hook timed out but was auto-approved
2062
+ }
2063
+ }
2064
+ // Set the task
2065
+ this.activeTask = activeTask;
2066
+ this.activeTaskAction = action;
2067
+ this.activeTaskColor = color || "white";
2068
+ // Add system message
2069
+ const colorStr = this.activeTaskColor && this.activeTaskColor !== "white" ? ` (${this.activeTaskColor})` : "";
2070
+ this.messages.push({
2071
+ role: 'system',
2072
+ content: `Assistant changed task status for "${this.activeTask}" to ${this.activeTaskAction}${colorStr}`
2073
+ });
2074
+ // Emit event
2075
+ this.emit('activeTaskChange', {
2076
+ activeTask: this.activeTask,
2077
+ action: this.activeTaskAction,
2078
+ color: this.activeTaskColor
2079
+ });
2080
+ return { success: true };
2081
+ }
2082
+ async transitionActiveTaskStatus(action, color) {
2083
+ // Cannot transition if no active task
2084
+ if (!this.activeTask) {
2085
+ return {
2086
+ success: false,
2087
+ error: "Cannot transition task status. No active task is currently running."
2088
+ };
2089
+ }
2090
+ // Execute hook if configured
2091
+ const settings = getSettingsManager();
2092
+ const hookPath = settings.getTaskApprovalHook();
2093
+ if (hookPath) {
2094
+ const hookResult = await executeOperationHook(hookPath, "transitionActiveTaskStatus", {
2095
+ action: action,
2096
+ color: color || "white"
2097
+ }, 30000, false, // Task hook is not mandatory
2098
+ this.getCurrentTokenCount(), this.getMaxContextSize());
2099
+ // Process hook commands (MODEL, SYSTEM, ENV, BACKEND, etc.) for both approval and rejection
2100
+ await this.processHookResult(hookResult);
2101
+ if (!hookResult.approved) {
2102
+ const reason = hookResult.reason || "Hook rejected task status transition";
2103
+ return {
2104
+ success: false,
2105
+ error: reason
2106
+ };
2107
+ }
2108
+ if (hookResult.timedOut) {
2109
+ // Hook timed out but was auto-approved
2110
+ }
2111
+ }
2112
+ // Store old action for system message
2113
+ const oldAction = this.activeTaskAction;
2114
+ // Update the action and color
2115
+ this.activeTaskAction = action;
2116
+ this.activeTaskColor = color || this.activeTaskColor;
2117
+ // Add system message
2118
+ const colorStr = this.activeTaskColor && this.activeTaskColor !== "white" ? ` (${this.activeTaskColor})` : "";
2119
+ this.messages.push({
2120
+ role: 'system',
2121
+ content: `Assistant changed task status for "${this.activeTask}" from ${oldAction} to ${this.activeTaskAction}${colorStr}`
2122
+ });
2123
+ // Emit event
2124
+ this.emit('activeTaskChange', {
2125
+ activeTask: this.activeTask,
2126
+ action: this.activeTaskAction,
2127
+ color: this.activeTaskColor
2128
+ });
2129
+ return { success: true };
2130
+ }
2131
+ async stopActiveTask(reason, documentationFile, color) {
2132
+ // Cannot stop if no active task
2133
+ if (!this.activeTask) {
2134
+ return {
2135
+ success: false,
2136
+ error: "Cannot stop task. No active task is currently running."
2137
+ };
2138
+ }
2139
+ // Record the start time for 3-second minimum
2140
+ const startTime = Date.now();
2141
+ // Execute hook if configured
2142
+ const settings = getSettingsManager();
2143
+ const hookPath = settings.getTaskApprovalHook();
2144
+ if (hookPath) {
2145
+ const hookResult = await executeOperationHook(hookPath, "stopActiveTask", {
2146
+ reason: reason,
2147
+ documentation_file: documentationFile,
2148
+ color: color || "white"
2149
+ }, 30000, false, // Task hook is not mandatory
2150
+ this.getCurrentTokenCount(), this.getMaxContextSize());
2151
+ // Process hook commands (MODEL, SYSTEM, ENV, BACKEND, etc.) for both approval and rejection
2152
+ await this.processHookResult(hookResult);
2153
+ if (!hookResult.approved) {
2154
+ const hookReason = hookResult.reason || "Hook rejected task stop";
2155
+ return {
2156
+ success: false,
2157
+ error: hookReason
2158
+ };
2159
+ }
2160
+ if (hookResult.timedOut) {
2161
+ // Hook timed out but was auto-approved
2162
+ }
2163
+ }
2164
+ // Calculate remaining time to meet 3-second minimum
2165
+ const elapsed = Date.now() - startTime;
2166
+ const minimumDelay = MINIMUM_STOP_TASK_DELAY_MS;
2167
+ const remainingDelay = Math.max(0, minimumDelay - elapsed);
2168
+ // Wait for remaining time if needed
2169
+ if (remainingDelay > 0) {
2170
+ await new Promise(resolve => setTimeout(resolve, remainingDelay));
2171
+ }
2172
+ // Store task info for system message before clearing
2173
+ const stoppedTask = this.activeTask;
2174
+ const stoppedAction = this.activeTaskAction;
2175
+ // Clear the task
2176
+ this.activeTask = "";
2177
+ this.activeTaskAction = "";
2178
+ this.activeTaskColor = "white";
2179
+ // Add system message
2180
+ const colorStr = color && color !== "white" ? ` (${color})` : "";
2181
+ this.messages.push({
2182
+ role: 'system',
2183
+ content: `Assistant stopped task "${stoppedTask}" (was ${stoppedAction}) with reason: ${reason}${colorStr}`
2184
+ });
2185
+ // Emit event to clear widget
2186
+ this.emit('activeTaskChange', {
2187
+ activeTask: "",
2188
+ action: "",
2189
+ color: "white"
2190
+ });
2191
+ return { success: true };
2192
+ }
2193
+ async emitContextChange() {
2194
+ const percent = this.getContextUsagePercent();
2195
+ this.emit('contextChange', {
2196
+ current: this.getCurrentTokenCount(),
2197
+ max: this.getMaxContextSize(),
2198
+ percent
2199
+ });
2200
+ // Add system warnings based on context usage (may auto-clear at 100%)
2201
+ await this.addContextWarningIfNeeded(percent);
2202
+ }
2203
+ async addContextWarningIfNeeded(percent) {
2204
+ let warning = null;
2205
+ const roundedPercent = Math.round(percent);
2206
+ if (percent >= 100) {
2207
+ // Auto-clear at 100%+ to prevent exceeding context limits
2208
+ warning = `CONTEXT LIMIT REACHED: You are at ${roundedPercent}% context capacity! Automatically clearing cache to prevent context overflow...`;
2209
+ this.messages.push({
2210
+ role: 'system',
2211
+ content: warning
2212
+ });
2213
+ // Perform automatic cache clear
2214
+ await this.clearCache();
2215
+ return;
2216
+ }
2217
+ if (percent >= 95) {
2218
+ // Very stern warning at 95%+ (every time)
2219
+ warning = `CRITICAL CONTEXT WARNING: You are at ${roundedPercent}% context capacity! You MUST immediately save any notes and lessons learned, then run the 'clearCache' tool to reset the conversation context. The conversation will fail if you do not take action now.`;
2220
+ }
2221
+ else if (percent >= 90 && !this.contextWarningAt90) {
2222
+ // Dire warning at 90% (one time only)
2223
+ this.contextWarningAt90 = true;
2224
+ warning = `URGENT CONTEXT WARNING: You are at ${roundedPercent}% context capacity! Perform your final tasks or responses and prepare to be reset.`;
2225
+ }
2226
+ else if (percent >= 80 && !this.contextWarningAt80) {
2227
+ // Initial warning at 80% (one time only)
2228
+ this.contextWarningAt80 = true;
2229
+ warning = `Context Warning: You are at ${roundedPercent}% context capacity! You are approaching the limit. Be concise and avoid lengthy outputs.`;
2230
+ }
2231
+ if (warning) {
2232
+ // Add as a system message
2233
+ this.messages.push({
2234
+ role: 'system',
2235
+ content: warning
2236
+ });
2237
+ }
2238
+ }
2239
+ async executeCommand(command, skipConfirmation = false) {
2240
+ return await this.zsh.execute(command, 30000, skipConfirmation);
2241
+ }
2242
+ getCurrentModel() {
2243
+ return this.llmClient.getCurrentModel();
2244
+ }
2245
+ setModel(model) {
2246
+ this.llmClient.setModel(model);
2247
+ // Reset supportsVision flag for new model
2248
+ this.llmClient.setSupportsVision(true);
2249
+ // Update token counter for new model
2250
+ this.tokenCounter.dispose();
2251
+ this.tokenCounter = createTokenCounter(model);
2252
+ }
2253
+ /**
2254
+ * Strip in-progress tool calls from messages for backend/model testing
2255
+ * Removes tool_calls from the last assistant message and any corresponding tool results
2256
+ * @returns Cleaned copy of messages array, or original if no stripping needed
2257
+ */
2258
+ static stripInProgressToolCalls(messages) {
2259
+ // Find the last assistant message
2260
+ let lastAssistantIndex = -1;
2261
+ for (let i = messages.length - 1; i >= 0; i--) {
2262
+ if (messages[i].role === 'assistant') {
2263
+ lastAssistantIndex = i;
2264
+ break;
2265
+ }
2266
+ }
2267
+ // If no assistant message or it has no tool_calls, return original
2268
+ if (lastAssistantIndex === -1 || !messages[lastAssistantIndex].tool_calls) {
2269
+ return messages;
2270
+ }
2271
+ // Create deep copy to avoid modifying original
2272
+ const cleanedMessages = JSON.parse(JSON.stringify(messages));
2273
+ // Collect tool_call_ids from the last assistant message
2274
+ const toolCallIds = new Set((cleanedMessages[lastAssistantIndex].tool_calls || []).map((tc) => tc.id));
2275
+ // Remove tool_calls from the last assistant message
2276
+ delete cleanedMessages[lastAssistantIndex].tool_calls;
2277
+ // Remove any tool result messages that correspond to those tool_call_ids
2278
+ // (in case some finished but not all)
2279
+ return cleanedMessages.filter((msg, idx) => {
2280
+ if (idx <= lastAssistantIndex) {
2281
+ return true; // Keep all messages before and including the assistant message
2282
+ }
2283
+ if (msg.role === 'tool' && toolCallIds.has(msg.tool_call_id)) {
2284
+ return false; // Remove tool results for the in-progress tool calls
2285
+ }
2286
+ return true;
2287
+ });
2288
+ }
2289
+ /**
2290
+ * Test a model change by making a test API call with current conversation context
2291
+ * Rolls back to previous model if test fails
2292
+ * @param newModel Model to test
2293
+ * @returns Promise with success status and optional error message
2294
+ */
2295
+ async testModel(newModel) {
2296
+ const previousModel = this.getCurrentModel();
2297
+ const previousTokenCounter = this.tokenCounter;
2298
+ // Strip in-progress tool calls to avoid sending incomplete assistant messages
2299
+ const testMessages = LLMAgent.stripInProgressToolCalls(this.messages);
2300
+ // Build request payload for logging
2301
+ const supportsTools = this.llmClient.getSupportsTools();
2302
+ const tools = supportsTools ? await getAllLLMTools() : [];
2303
+ const requestPayload = {
2304
+ model: newModel,
2305
+ messages: testMessages,
2306
+ tools: supportsTools && tools.length > 0 ? tools : undefined,
2307
+ temperature: this.temperature,
2308
+ max_tokens: 10
2309
+ };
2310
+ try {
2311
+ // Temporarily set the new model
2312
+ this.llmClient.setModel(newModel);
2313
+ this.tokenCounter = createTokenCounter(newModel);
2314
+ // Test with actual conversation context to verify the model can handle it
2315
+ // This catches issues like ollama models that fail to parse tool calls
2316
+ const response = await this.llmClient.chat(testMessages, tools, newModel, undefined, this.temperature, undefined, 10);
2317
+ // Check if response is valid
2318
+ if (!response || !response.choices || response.choices.length === 0) {
2319
+ throw new Error("Invalid response from API");
2320
+ }
2321
+ // Test succeeded - keep the new model
2322
+ previousTokenCounter.dispose();
2323
+ return { success: true };
2324
+ }
2325
+ catch (error) {
2326
+ // Test failed - roll back to previous model
2327
+ this.llmClient.setModel(previousModel);
2328
+ this.tokenCounter.dispose();
2329
+ this.tokenCounter = previousTokenCounter;
2330
+ // Log test failure with full request/response for debugging
2331
+ const { message: logPaths } = await logApiError(requestPayload, error, { errorType: 'model-switch-test-failure', previousModel, newModel }, 'test-fail');
2332
+ const errorMessage = error.message || "Unknown error during model test";
2333
+ return {
2334
+ success: false,
2335
+ error: `Model test failed: ${errorMessage}\n${logPaths}`
2336
+ };
2337
+ }
2338
+ }
2339
+ /**
2340
+ * Test backend/baseUrl/model changes by making a test API call with current conversation context
2341
+ * Rolls back all changes if test fails
2342
+ * @param backend Backend display name
2343
+ * @param baseUrl Base URL for API calls
2344
+ * @param apiKeyEnvVar Name of environment variable containing API key
2345
+ * @param model Model to use (optional, uses current model if not specified)
2346
+ * @returns Promise with success status and optional error message
2347
+ */
2348
+ async testBackendModelChange(backend, baseUrl, apiKeyEnvVar, model) {
2349
+ const previousClient = this.llmClient;
2350
+ const previousApiKeyEnvVar = this.apiKeyEnvVar;
2351
+ const previousBackend = this.llmClient.getBackendName();
2352
+ const previousModel = this.getCurrentModel();
2353
+ let requestPayload;
2354
+ let newModel;
2355
+ try {
2356
+ // Get API key from environment
2357
+ const apiKey = process.env[apiKeyEnvVar];
2358
+ if (!apiKey) {
2359
+ throw new Error(`API key not found in environment variable: ${apiKeyEnvVar}`);
2360
+ }
2361
+ // Use current model if not specified
2362
+ newModel = model || this.getCurrentModel();
2363
+ // Create new client with new configuration
2364
+ this.llmClient = new LLMClient(apiKey, newModel, baseUrl, backend);
2365
+ // Store the API key env var name for session persistence
2366
+ this.apiKeyEnvVar = apiKeyEnvVar;
2367
+ // Reinitialize MCP servers since we're switching to a new backend/model
2368
+ try {
2369
+ const config = loadMCPConfig();
2370
+ if (config.servers.length > 0) {
2371
+ await initializeMCPServers();
2372
+ }
2373
+ }
2374
+ catch (mcpError) {
2375
+ console.warn("MCP reinitialization failed:", mcpError);
2376
+ }
2377
+ // Strip in-progress tool calls to avoid sending incomplete assistant messages
2378
+ const testMessages = LLMAgent.stripInProgressToolCalls(this.messages);
2379
+ // Build request payload for logging
2380
+ const supportsTools = this.llmClient.getSupportsTools();
2381
+ const tools = supportsTools ? await getAllLLMTools() : [];
2382
+ requestPayload = {
2383
+ backend,
2384
+ baseUrl,
2385
+ model: newModel,
2386
+ messages: testMessages,
2387
+ tools: supportsTools && tools.length > 0 ? tools : undefined,
2388
+ temperature: this.temperature,
2389
+ max_tokens: 10
2390
+ };
2391
+ // Test with actual conversation context to verify the backend/model can handle it
2392
+ // This catches issues like ollama models that fail to parse tool calls
2393
+ const response = await this.llmClient.chat(testMessages, tools, newModel, undefined, this.temperature, undefined, 10);
2394
+ // Check if response is valid
2395
+ if (!response || !response.choices || response.choices.length === 0) {
2396
+ throw new Error("Invalid response from API");
2397
+ }
2398
+ // Test succeeded - new client is now active
2399
+ return { success: true };
2400
+ }
2401
+ catch (error) {
2402
+ // Test failed - restore previous client and API key env var
2403
+ this.llmClient = previousClient;
2404
+ this.apiKeyEnvVar = previousApiKeyEnvVar;
2405
+ // Log test failure with full request/response for debugging (if we got far enough to build the payload)
2406
+ let logPaths = '';
2407
+ if (requestPayload) {
2408
+ const result = await logApiError(requestPayload, error, {
2409
+ errorType: 'backend-switch-test-failure',
2410
+ previousBackend,
2411
+ previousModel,
2412
+ newBackend: backend,
2413
+ newModel,
2414
+ baseUrl,
2415
+ apiKeyEnvVar
2416
+ }, 'test-fail');
2417
+ logPaths = result.message;
2418
+ }
2419
+ const errorMessage = error.message || "Unknown error during backend/model test";
2420
+ return {
2421
+ success: false,
2422
+ error: logPaths ? `${errorMessage}\n${logPaths}` : errorMessage
2423
+ };
2424
+ }
2425
+ }
2426
+ /**
2427
+ * Process hook result including commands and transformations
2428
+ * Handles ENV transformations, model/backend testing, and error messaging
2429
+ * @param hookResult Hook execution result
2430
+ * @param envKey Optional ENV key to check for transformation (e.g., ZDS_AI_AGENT_PERSONA)
2431
+ * @returns Object with success status and transformed value (if any)
2432
+ */
2433
+ async processHookResult(hookResult, envKey) {
2434
+ if (!hookResult.commands) {
2435
+ return { success: true };
2436
+ }
2437
+ const results = applyHookCommands(hookResult.commands);
2438
+ // Check for transformation via ENV if key provided
2439
+ let transformedValue;
2440
+ if (envKey && results.env[envKey]) {
2441
+ transformedValue = results.env[envKey];
2442
+ }
2443
+ // Process commands (test model/backend, apply ENV vars, add SYSTEM messages)
2444
+ const success = await this.processHookCommands(results);
2445
+ return { success, transformedValue };
2446
+ }
2447
+ /**
2448
+ * Process hook commands (MODEL, BACKEND, BASE_URL, SYSTEM, ENV)
2449
+ * Handles model/backend testing and error messaging
2450
+ * @param commands Hook commands from applyHookCommands()
2451
+ */
2452
+ async processHookCommands(commands) {
2453
+ // Import the helper function
2454
+ const { applyEnvVariables } = await import('../utils/hook-executor.js');
2455
+ // Check if backend or model change is requested
2456
+ const hasBackendChange = commands.backend && commands.baseUrl && commands.apiKeyEnvVar;
2457
+ const hasModelChange = commands.model;
2458
+ // Test backend/model changes FIRST before applying anything
2459
+ if (hasBackendChange) {
2460
+ // Backend change - test backend/baseUrl/model together
2461
+ const testResult = await this.testBackendModelChange(commands.backend, commands.baseUrl, commands.apiKeyEnvVar, commands.model);
2462
+ if (!testResult.success) {
2463
+ // Test failed - don't apply ANYTHING
2464
+ const parts = [];
2465
+ if (commands.backend)
2466
+ parts.push(`backend to "${commands.backend}"`);
2467
+ if (commands.model)
2468
+ parts.push(`model to "${commands.model}"`);
2469
+ const errorMsg = `Failed to change ${parts.join(' and ')}: ${testResult.error}`;
2470
+ // Note: Don't add to this.messages during tool execution - only chatHistory
2471
+ this.chatHistory.push({
2472
+ type: "system",
2473
+ content: errorMsg,
2474
+ timestamp: new Date(),
2475
+ });
2476
+ return false; // Signal failure - caller should not apply other changes
2477
+ }
2478
+ // Test succeeded - apply ENV variables and add success message
2479
+ applyEnvVariables(commands.env);
2480
+ const parts = [];
2481
+ if (commands.backend)
2482
+ parts.push(`backend to "${commands.backend}"`);
2483
+ if (commands.model)
2484
+ parts.push(`model to "${commands.model}"`);
2485
+ const successMsg = `Changed ${parts.join(' and ')}`;
2486
+ // Note: Don't add to this.messages during tool execution - only chatHistory
2487
+ this.chatHistory.push({
2488
+ type: "system",
2489
+ content: successMsg,
2490
+ timestamp: new Date(),
2491
+ });
2492
+ // Emit events for UI updates
2493
+ if (commands.backend) {
2494
+ this.emit('backendChange', {
2495
+ backend: commands.backend
2496
+ });
2497
+ }
2498
+ if (commands.model) {
2499
+ this.emit('modelChange', {
2500
+ model: commands.model
2501
+ });
2502
+ }
2503
+ }
2504
+ else if (hasModelChange) {
2505
+ // Model-only change
2506
+ const testResult = await this.testModel(commands.model);
2507
+ if (!testResult.success) {
2508
+ // Test failed - don't apply ANYTHING
2509
+ const errorMsg = `Failed to change model to "${commands.model}": ${testResult.error}`;
2510
+ // Note: Don't add to this.messages during tool execution - only chatHistory
2511
+ this.chatHistory.push({
2512
+ type: "system",
2513
+ content: errorMsg,
2514
+ timestamp: new Date(),
2515
+ });
2516
+ return false; // Signal failure - caller should not apply other changes
2517
+ }
2518
+ // Test succeeded - apply ENV variables and add success message
2519
+ applyEnvVariables(commands.env);
2520
+ const successMsg = `Model changed to "${commands.model}"`;
2521
+ // Note: Don't add to this.messages during tool execution - only chatHistory
2522
+ this.chatHistory.push({
2523
+ type: "system",
2524
+ content: successMsg,
2525
+ timestamp: new Date(),
2526
+ });
2527
+ // Emit modelChange event for UI updates
2528
+ this.emit('modelChange', {
2529
+ model: commands.model
2530
+ });
2531
+ }
2532
+ else {
2533
+ // No model or backend change - just apply ENV variables
2534
+ applyEnvVariables(commands.env);
2535
+ }
2536
+ // Add SYSTEM message if present
2537
+ if (commands.system) {
2538
+ // Note: Don't add to this.messages during tool execution - only chatHistory
2539
+ this.chatHistory.push({
2540
+ type: "system",
2541
+ content: commands.system,
2542
+ timestamp: new Date(),
2543
+ });
2544
+ }
2545
+ return true; // Signal success - caller can apply other changes
2546
+ }
2547
+ getBackend() {
2548
+ // Just return the backend name from the client (no detection)
2549
+ return this.llmClient.getBackendName();
2550
+ }
2551
+ abortCurrentOperation() {
2552
+ if (this.abortController) {
2553
+ this.abortController.abort();
2554
+ }
2555
+ }
2556
+ async clearCache() {
2557
+ const { ChatHistoryManager } = await import("../utils/chat-history-manager.js");
2558
+ const { executeStartupHook } = await import("../utils/startup-hook.js");
2559
+ const { executeOperationHook, applyHookCommands } = await import("../utils/hook-executor.js");
2560
+ const historyManager = ChatHistoryManager.getInstance();
2561
+ // Backup current context to timestamped files
2562
+ historyManager.backupHistory();
2563
+ // Clear the context
2564
+ this.chatHistory = [];
2565
+ this.messages = [];
2566
+ this.contextWarningAt80 = false;
2567
+ this.contextWarningAt90 = false;
2568
+ this.firstMessageProcessed = false;
2569
+ // Add temporary system message (will be replaced by initialize())
2570
+ this.messages.push({
2571
+ role: "system",
2572
+ content: "Initializing...",
2573
+ });
2574
+ this.chatHistory.push({
2575
+ type: "system",
2576
+ content: "Initializing...",
2577
+ timestamp: new Date(),
2578
+ });
2579
+ try {
2580
+ // Re-execute startup hook to get fresh output
2581
+ this.startupHookOutput = await executeStartupHook();
2582
+ // Reinitialize with system message and startup hook
2583
+ // Instance hook runs automatically at end of initialize()
2584
+ await this.initialize();
2585
+ }
2586
+ catch (error) {
2587
+ console.error("Error during initialize() in clearCache():", error);
2588
+ // Continue anyway - we still want to save the cleared state
2589
+ }
2590
+ // Save the cleared state FIRST before emitting (in case emit causes exit)
2591
+ const sessionState = this.getSessionState();
2592
+ historyManager.saveContext(this.systemPrompt, this.chatHistory, sessionState);
2593
+ historyManager.saveMessages(this.messages);
2594
+ // Emit context change WITHOUT calling addContextWarningIfNeeded (to avoid recursive clearCache)
2595
+ const percent = this.getContextUsagePercent();
2596
+ this.emit('contextChange', {
2597
+ current: this.getCurrentTokenCount(),
2598
+ max: this.getMaxContextSize(),
2599
+ percent
2600
+ });
2601
+ // Note: Intentionally NOT calling addContextWarningIfNeeded here to prevent recursion
2602
+ }
2603
+ /**
2604
+ * Get current session state for persistence
2605
+ */
2606
+ getSessionState() {
2607
+ return {
2608
+ session: process.env.ZDS_AI_AGENT_SESSION || "",
2609
+ persona: this.persona,
2610
+ personaColor: this.personaColor,
2611
+ mood: this.mood,
2612
+ moodColor: this.moodColor,
2613
+ activeTask: this.activeTask,
2614
+ activeTaskAction: this.activeTaskAction,
2615
+ activeTaskColor: this.activeTaskColor,
2616
+ cwd: process.cwd(),
2617
+ contextCurrent: this.getCurrentTokenCount(),
2618
+ contextMax: this.getMaxContextSize(),
2619
+ backend: this.llmClient.getBackendName(),
2620
+ baseUrl: this.llmClient.getBaseURL(),
2621
+ apiKeyEnvVar: this.apiKeyEnvVar,
2622
+ model: this.getCurrentModel(),
2623
+ supportsVision: this.llmClient.getSupportsVision(),
2624
+ };
2625
+ }
2626
+ /**
2627
+ * Restore session state from persistence
2628
+ */
2629
+ async restoreSessionState(state) {
2630
+ // Restore session ID
2631
+ if (state.session) {
2632
+ process.env.ZDS_AI_AGENT_SESSION = state.session;
2633
+ }
2634
+ // Restore cwd early (hooks may need correct working directory)
2635
+ if (state.cwd) {
2636
+ try {
2637
+ const fs = await import('fs');
2638
+ // Only attempt to change directory if it exists
2639
+ if (fs.existsSync(state.cwd)) {
2640
+ process.chdir(state.cwd);
2641
+ }
2642
+ // Silently skip if directory doesn't exist (common in containerized environments)
2643
+ }
2644
+ catch (error) {
2645
+ // Silently skip on any error - working directory restoration is non-critical
2646
+ }
2647
+ }
2648
+ // Restore backend/baseUrl/apiKeyEnvVar/model if present (creates initial client)
2649
+ if (state.backend && state.baseUrl && state.apiKeyEnvVar) {
2650
+ try {
2651
+ // Get API key from environment
2652
+ const apiKey = process.env[state.apiKeyEnvVar];
2653
+ if (apiKey) {
2654
+ // Create new client with restored configuration
2655
+ const model = state.model || this.getCurrentModel();
2656
+ this.llmClient = new LLMClient(apiKey, model, state.baseUrl, state.backend);
2657
+ this.apiKeyEnvVar = state.apiKeyEnvVar;
2658
+ // Restore supportsVision flag if present
2659
+ if (state.supportsVision !== undefined) {
2660
+ this.llmClient.setSupportsVision(state.supportsVision);
2661
+ }
2662
+ // Reinitialize MCP servers when restoring session
2663
+ try {
2664
+ const config = loadMCPConfig();
2665
+ if (config.servers.length > 0) {
2666
+ await initializeMCPServers();
2667
+ }
2668
+ }
2669
+ catch (mcpError) {
2670
+ console.warn("MCP reinitialization failed:", mcpError);
2671
+ }
2672
+ // Dispose old token counter and create new one for the restored model
2673
+ this.tokenCounter.dispose();
2674
+ this.tokenCounter = createTokenCounter(model);
2675
+ // Emit events for UI updates
2676
+ this.emit('backendChange', { backend: state.backend });
2677
+ this.emit('modelChange', { model });
2678
+ }
2679
+ else {
2680
+ console.warn("Failed to restore backend: API key not found in environment.");
2681
+ }
2682
+ }
2683
+ catch (error) {
2684
+ console.warn(`Failed to restore backend configuration:`, error);
2685
+ }
2686
+ }
2687
+ // Restore persona (hook may change backend/model and sets env vars)
2688
+ if (state.persona) {
2689
+ try {
2690
+ const result = await this.setPersona(state.persona, state.personaColor);
2691
+ if (!result.success) {
2692
+ // If persona hook failed (e.g., backend test failed), still set the persona values
2693
+ // but don't change backend/model. This prevents losing persona state on transitory errors.
2694
+ console.warn(`Persona hook failed, setting persona without backend change: ${result.error}`);
2695
+ this.persona = state.persona;
2696
+ this.personaColor = state.personaColor;
2697
+ process.env.ZDS_AI_AGENT_PERSONA = state.persona;
2698
+ }
2699
+ }
2700
+ catch (error) {
2701
+ console.warn(`Failed to restore persona "${state.persona}":`, error);
2702
+ // Still set persona values even if hook crashed
2703
+ this.persona = state.persona;
2704
+ this.personaColor = state.personaColor;
2705
+ process.env.ZDS_AI_AGENT_PERSONA = state.persona;
2706
+ }
2707
+ }
2708
+ // Restore mood (hook sets env vars)
2709
+ if (state.mood) {
2710
+ try {
2711
+ const result = await this.setMood(state.mood, state.moodColor);
2712
+ if (!result.success) {
2713
+ // If mood hook failed (e.g., backend test failed), still set the mood values
2714
+ // but don't change backend/model. This prevents losing mood state on transitory errors.
2715
+ console.warn(`Mood hook failed, setting mood without backend change: ${result.error}`);
2716
+ this.mood = state.mood;
2717
+ this.moodColor = state.moodColor;
2718
+ process.env.ZDS_AI_AGENT_MOOD = state.mood;
2719
+ }
2720
+ }
2721
+ catch (error) {
2722
+ console.warn(`Failed to restore mood "${state.mood}":`, error);
2723
+ // Still set mood values even if hook crashed
2724
+ this.mood = state.mood;
2725
+ this.moodColor = state.moodColor;
2726
+ process.env.ZDS_AI_AGENT_MOOD = state.mood;
2727
+ }
2728
+ }
2729
+ // Restore active task (hook sets env vars)
2730
+ if (state.activeTask) {
2731
+ try {
2732
+ const result = await this.startActiveTask(state.activeTask, state.activeTaskAction, state.activeTaskColor);
2733
+ if (!result.success) {
2734
+ // If task hook failed (e.g., backend test failed), still set the task values
2735
+ // but don't change backend/model. This prevents losing task state on transitory errors.
2736
+ console.warn(`Task hook failed, setting active task without backend change: ${result.error}`);
2737
+ this.activeTask = state.activeTask;
2738
+ this.activeTaskAction = state.activeTaskAction;
2739
+ this.activeTaskColor = state.activeTaskColor;
2740
+ process.env.ZDS_AI_AGENT_ACTIVE_TASK = state.activeTask;
2741
+ process.env.ZDS_AI_AGENT_ACTIVE_TASK_ACTION = state.activeTaskAction;
2742
+ }
2743
+ }
2744
+ catch (error) {
2745
+ console.warn(`Failed to restore active task "${state.activeTask}":`, error);
2746
+ // Still set task values even if hook crashed
2747
+ this.activeTask = state.activeTask;
2748
+ this.activeTaskAction = state.activeTaskAction;
2749
+ this.activeTaskColor = state.activeTaskColor;
2750
+ process.env.ZDS_AI_AGENT_ACTIVE_TASK = state.activeTask;
2751
+ process.env.ZDS_AI_AGENT_ACTIVE_TASK_ACTION = state.activeTaskAction;
2752
+ }
2753
+ }
2754
+ }
2755
+ /**
2756
+ * Compact conversation context by keeping system prompt and last N messages
2757
+ * Reduces context size when it grows too large for backend to handle
2758
+ * @returns Number of messages removed
2759
+ */
2760
+ compactContext(keepLastMessages = 20) {
2761
+ if (this.chatHistory.length <= keepLastMessages) {
2762
+ // Nothing to compact
2763
+ return 0;
2764
+ }
2765
+ const removedCount = this.chatHistory.length - keepLastMessages;
2766
+ const keptMessages = this.chatHistory.slice(-keepLastMessages);
2767
+ // Clear both arrays
2768
+ this.chatHistory = keptMessages;
2769
+ this.messages = [];
2770
+ // Add system message noting the compaction
2771
+ const compactionNote = {
2772
+ type: 'system',
2773
+ content: `Context compacted: removed ${removedCount} older messages, keeping last ${keepLastMessages} messages.`,
2774
+ timestamp: new Date()
2775
+ };
2776
+ this.chatHistory.push(compactionNote);
2777
+ // Rebuild this.messages from compacted chatHistory
2778
+ for (const entry of this.chatHistory) {
2779
+ if (entry.type === 'system') {
2780
+ this.messages.push({
2781
+ role: 'system',
2782
+ content: entry.content
2783
+ });
2784
+ }
2785
+ else if (entry.type === 'user') {
2786
+ this.messages.push({
2787
+ role: 'user',
2788
+ content: entry.content
2789
+ });
2790
+ }
2791
+ else if (entry.type === 'assistant') {
2792
+ this.messages.push({
2793
+ role: 'assistant',
2794
+ content: entry.content
2795
+ });
2796
+ }
2797
+ else if (entry.type === 'tool_result') {
2798
+ this.messages.push({
2799
+ role: 'tool',
2800
+ tool_call_id: entry.toolResult.output || '',
2801
+ content: JSON.stringify(entry.toolResult)
2802
+ });
2803
+ }
2804
+ }
2805
+ return removedCount;
2806
+ }
2807
+ /**
2808
+ * Get all tool instances and their class names for display purposes
2809
+ */
2810
+ getToolClassInfo() {
2811
+ const toolInstances = this.getToolInstances();
2812
+ return toolInstances.map(({ instance, className }) => ({
2813
+ className,
2814
+ methods: instance.getHandledToolNames ? instance.getHandledToolNames() : []
2815
+ }));
2816
+ }
2817
+ /**
2818
+ * Get all tool instances via reflection
2819
+ */
2820
+ getToolInstances() {
2821
+ const instances = [];
2822
+ // Use reflection to find all tool instance properties
2823
+ const propertyNames = Object.getOwnPropertyNames(this);
2824
+ for (const propName of propertyNames) {
2825
+ const propValue = this[propName];
2826
+ // Check if this property is a tool instance (has getHandledToolNames method)
2827
+ if (propValue &&
2828
+ typeof propValue === 'object' &&
2829
+ typeof propValue.getHandledToolNames === 'function') {
2830
+ instances.push({
2831
+ instance: propValue,
2832
+ className: propValue.constructor.name
2833
+ });
2834
+ }
2835
+ }
2836
+ return instances;
2837
+ }
2838
+ }
2839
+ //# sourceMappingURL=llm-agent.js.map