@zds-ai/cli 0.1.8 → 0.1.10

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 (73) hide show
  1. package/README.md +387 -34
  2. package/dist/agent/context-manager.d.ts +70 -0
  3. package/dist/agent/context-manager.js +138 -0
  4. package/dist/agent/context-manager.js.map +1 -0
  5. package/dist/agent/hook-manager.d.ts +194 -0
  6. package/dist/agent/hook-manager.js +676 -0
  7. package/dist/agent/hook-manager.js.map +1 -0
  8. package/dist/agent/llm-agent.d.ts +469 -100
  9. package/dist/agent/llm-agent.js +781 -1580
  10. package/dist/agent/llm-agent.js.map +1 -1
  11. package/dist/agent/message-processor.d.ts +103 -0
  12. package/dist/agent/message-processor.js +225 -0
  13. package/dist/agent/message-processor.js.map +1 -0
  14. package/dist/agent/prompt-variables.d.ts +103 -40
  15. package/dist/agent/prompt-variables.js +250 -113
  16. package/dist/agent/prompt-variables.js.map +1 -1
  17. package/dist/agent/session-manager.d.ts +75 -0
  18. package/dist/agent/session-manager.js +194 -0
  19. package/dist/agent/session-manager.js.map +1 -0
  20. package/dist/agent/tool-executor.d.ts +111 -0
  21. package/dist/agent/tool-executor.js +397 -0
  22. package/dist/agent/tool-executor.js.map +1 -0
  23. package/dist/bin/generate_image_sd.sh +19 -12
  24. package/dist/bin/joycaption.sh +37 -0
  25. package/dist/grok/client.d.ts +52 -0
  26. package/dist/grok/client.js +127 -19
  27. package/dist/grok/client.js.map +1 -1
  28. package/dist/grok/tools.js +42 -8
  29. package/dist/grok/tools.js.map +1 -1
  30. package/dist/hooks/use-input-handler.d.ts +1 -1
  31. package/dist/hooks/use-input-handler.js +100 -13
  32. package/dist/hooks/use-input-handler.js.map +1 -1
  33. package/dist/index.js +25 -3
  34. package/dist/index.js.map +1 -1
  35. package/dist/mcp/config.d.ts +1 -0
  36. package/dist/mcp/config.js +45 -7
  37. package/dist/mcp/config.js.map +1 -1
  38. package/dist/tools/character-tool.js +13 -1
  39. package/dist/tools/character-tool.js.map +1 -1
  40. package/dist/tools/image-tool.d.ts +11 -1
  41. package/dist/tools/image-tool.js +109 -2
  42. package/dist/tools/image-tool.js.map +1 -1
  43. package/dist/tools/introspect-tool.js +131 -30
  44. package/dist/tools/introspect-tool.js.map +1 -1
  45. package/dist/tools/morph-editor.d.ts +21 -9
  46. package/dist/tools/morph-editor.js +21 -9
  47. package/dist/tools/morph-editor.js.map +1 -1
  48. package/dist/ui/components/active-task-status.d.ts +1 -1
  49. package/dist/ui/components/api-key-input.d.ts +1 -1
  50. package/dist/ui/components/backend-status.d.ts +1 -1
  51. package/dist/ui/components/chat-history.d.ts +1 -1
  52. package/dist/ui/components/chat-interface.d.ts +1 -1
  53. package/dist/ui/components/chat-interface.js +1 -1
  54. package/dist/ui/components/chat-interface.js.map +1 -1
  55. package/dist/ui/components/context-status.d.ts +1 -1
  56. package/dist/ui/components/mood-status.d.ts +1 -1
  57. package/dist/ui/components/persona-status.d.ts +1 -1
  58. package/dist/utils/chat-history-manager.d.ts +12 -4
  59. package/dist/utils/chat-history-manager.js +26 -11
  60. package/dist/utils/chat-history-manager.js.map +1 -1
  61. package/dist/utils/hook-executor.d.ts +53 -2
  62. package/dist/utils/hook-executor.js +258 -36
  63. package/dist/utils/hook-executor.js.map +1 -1
  64. package/dist/utils/rephrase-handler.d.ts +1 -1
  65. package/dist/utils/settings-manager.d.ts +41 -11
  66. package/dist/utils/settings-manager.js +172 -40
  67. package/dist/utils/settings-manager.js.map +1 -1
  68. package/dist/utils/slash-commands.d.ts +3 -3
  69. package/dist/utils/slash-commands.js +11 -5
  70. package/dist/utils/slash-commands.js.map +1 -1
  71. package/dist/utils/startup-hook.js +9 -2
  72. package/dist/utils/startup-hook.js.map +1 -1
  73. package/package.json +10 -8
@@ -1,9 +1,7 @@
1
1
  import { LLMClient } from "../grok/client.js";
2
- import { getAllLLMTools, getMCPManager, initializeMCPServers, } from "../grok/tools.js";
2
+ import { getAllLLMTools, initializeMCPServers, } from "../grok/tools.js";
3
3
  import { loadMCPConfig } from "../mcp/config.js";
4
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
5
  import { getTextContent } from "../utils/content-utils.js";
8
6
  import { Variable } from "./prompt-variables.js";
9
7
  import fs from "fs";
@@ -11,13 +9,14 @@ import { TextEditorTool, MorphEditorTool, ZshTool, ConfirmationTool, SearchTool,
11
9
  import { EventEmitter } from "events";
12
10
  import { createTokenCounter } from "../utils/token-counter.js";
13
11
  import { getSettingsManager } from "../utils/settings-manager.js";
14
- import { executeOperationHook, executeToolApprovalHook, applyHookCommands } from "../utils/hook-executor.js";
12
+ import { executeOperationHook, applyHookCommands, applyEnvVariables } from "../utils/hook-executor.js";
13
+ import { ToolExecutor } from "./tool-executor.js";
14
+ import { HookManager } from "./hook-manager.js";
15
+ import { SessionManager } from "./session-manager.js";
16
+ import { MessageProcessor } from "./message-processor.js";
17
+ import { ContextManager } from "./context-manager.js";
15
18
  // Interval (ms) between token count updates when streaming
16
19
  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
20
  /**
22
21
  * Threshold used to determine whether an AI response is "substantial" (in characters).
23
22
  */
@@ -73,6 +72,60 @@ function sanitizeToolArguments(args) {
73
72
  }
74
73
  return argsString;
75
74
  }
75
+ /**
76
+ * Main LLM Agent class that orchestrates AI conversations with tool execution capabilities.
77
+ *
78
+ * ## Architecture Overview
79
+ *
80
+ * The LLMAgent serves as the central coordinator for AI-powered conversations, managing:
81
+ * - **Conversation Flow**: Handles user messages, AI responses, and multi-turn conversations
82
+ * - **Tool Execution**: Coordinates with various tools (file editing, shell commands, web search, etc.)
83
+ * - **Context Management**: Tracks conversation history and manages token limits
84
+ * - **Session State**: Maintains persona, mood, active tasks, and other session data
85
+ * - **Streaming Support**: Provides real-time response streaming for better UX
86
+ *
87
+ * ## Delegation Architecture
88
+ *
89
+ * The agent delegates specialized functionality to focused manager classes:
90
+ * - **ToolExecutor**: Handles all tool execution, validation, and approval workflows
91
+ * - **HookManager**: Manages persona/mood/task hooks and backend testing
92
+ * - **SessionManager**: Handles session persistence and state restoration
93
+ * - **MessageProcessor**: Processes user input, handles rephrasing, and XML parsing
94
+ * - **ContextManager**: Manages context warnings, compaction, and token tracking
95
+ *
96
+ * ## Key Features
97
+ *
98
+ * - **Multi-Model Support**: Works with various LLM backends (Grok, OpenAI, etc.)
99
+ * - **Tool Integration**: Seamlessly integrates with 15+ built-in tools
100
+ * - **MCP Support**: Extends capabilities via Model Context Protocol servers
101
+ * - **Vision Support**: Handles image inputs for vision-capable models
102
+ * - **Streaming Responses**: Real-time response generation with token counting
103
+ * - **Context Awareness**: Intelligent context management and automatic compaction
104
+ * - **Hook System**: Extensible hook system for custom behaviors
105
+ * - **Session Persistence**: Maintains conversation state across restarts
106
+ *
107
+ * ## Usage Patterns
108
+ *
109
+ * ```typescript
110
+ * // Initialize agent
111
+ * const agent = new LLMAgent(apiKey, baseURL, model);
112
+ * await agent.initialize();
113
+ *
114
+ * // Process messages (non-streaming)
115
+ * const entries = await agent.processUserMessage("Hello, world!");
116
+ *
117
+ * // Process messages (streaming)
118
+ * for await (const chunk of agent.processUserMessageStream("Write a file")) {
119
+ * console.log(chunk);
120
+ * }
121
+ *
122
+ * // Manage session state
123
+ * await agent.setPersona("helpful assistant");
124
+ * await agent.startActiveTask("coding", "writing tests");
125
+ * ```
126
+ *
127
+ * @extends EventEmitter Emits 'contextChange' events for token usage updates
128
+ */
76
129
  export class LLMAgent extends EventEmitter {
77
130
  llmClient;
78
131
  textEditor;
@@ -99,8 +152,6 @@ export class LLMAgent extends EventEmitter {
99
152
  temperature;
100
153
  maxTokens;
101
154
  firstMessageProcessed = false;
102
- contextWarningAt80 = false;
103
- contextWarningAt90 = false;
104
155
  persona = "";
105
156
  personaColor = "white";
106
157
  mood = "";
@@ -111,7 +162,106 @@ export class LLMAgent extends EventEmitter {
111
162
  apiKeyEnvVar = "GROK_API_KEY";
112
163
  pendingContextEditSession = null;
113
164
  rephraseState = null;
114
- hookPrefillText = null;
165
+ toolExecutor;
166
+ hookManager;
167
+ sessionManager;
168
+ messageProcessor;
169
+ contextManager;
170
+ maxContextSize = 128000; // Default context size, can be overridden by MAXCONTEXT hook command
171
+ /**
172
+ * Cleans up incomplete tool calls in the message history.
173
+ * Ensures all tool calls have corresponding tool results to prevent API errors.
174
+ *
175
+ * This method scans the last assistant message for tool calls and adds
176
+ * "[Cancelled by user]" results for any tool calls that don't have results.
177
+ *
178
+ * @private
179
+ */
180
+ async cleanupIncompleteToolCalls() {
181
+ const lastMessage = this.messages[this.messages.length - 1];
182
+ if (lastMessage?.role === "assistant" && lastMessage.tool_calls) {
183
+ const toolCallIds = new Set(lastMessage.tool_calls.map((tc) => tc.id));
184
+ const completedToolCallIds = new Set();
185
+ for (let i = this.messages.length - 1; i >= 0; i--) {
186
+ const msg = this.messages[i];
187
+ if (msg.role === "tool" && msg.tool_call_id) {
188
+ completedToolCallIds.add(msg.tool_call_id);
189
+ }
190
+ if (this.messages[i] === lastMessage)
191
+ break;
192
+ }
193
+ for (const toolCallId of toolCallIds) {
194
+ if (!completedToolCallIds.has(toolCallId)) {
195
+ console.error(`Adding cancelled result for incomplete tool call: ${toolCallId}`);
196
+ this.messages.push({
197
+ role: "tool",
198
+ content: "[Cancelled by user]",
199
+ tool_call_id: toolCallId,
200
+ });
201
+ }
202
+ }
203
+ }
204
+ }
205
+ /**
206
+ * Executes the instance hook if it hasn't been run yet.
207
+ *
208
+ * The instance hook runs once per agent session and can:
209
+ * - Set prompt variables
210
+ * - Add system messages
211
+ * - Provide prefill text for responses
212
+ *
213
+ * @private
214
+ */
215
+ async executeInstanceHookIfNeeded() {
216
+ if (!this.hasRunInstanceHook) {
217
+ this.hasRunInstanceHook = true;
218
+ const settings = getSettingsManager();
219
+ const instanceHookPath = settings.getInstanceHook();
220
+ if (instanceHookPath) {
221
+ const hookResult = await executeOperationHook(instanceHookPath, "instance", {}, 30000, false, this.getCurrentTokenCount(), this.getMaxContextSize());
222
+ if (hookResult.approved && hookResult.commands && hookResult.commands.length > 0) {
223
+ const results = applyHookCommands(hookResult.commands);
224
+ applyEnvVariables(results.env);
225
+ const seenVars = new Set();
226
+ for (const { name, value } of results.promptVars) {
227
+ if (seenVars.has(name)) {
228
+ Variable.append(name, value);
229
+ }
230
+ else {
231
+ Variable.set(name, value);
232
+ seenVars.add(name);
233
+ }
234
+ }
235
+ // Process hook commands through HookManager
236
+ // Note: This is a simplified version - full hook processing is now in HookManager
237
+ if (results.system) {
238
+ this.messages.push({
239
+ role: 'system',
240
+ content: results.system
241
+ });
242
+ }
243
+ if (results.prefill) {
244
+ this.messageProcessor.setHookPrefillText(results.prefill);
245
+ }
246
+ if (results.maxContext !== undefined) {
247
+ this.maxContextSize = results.maxContext;
248
+ }
249
+ }
250
+ }
251
+ }
252
+ }
253
+ /**
254
+ * Creates a new LLMAgent instance.
255
+ *
256
+ * @param apiKey - API key for the LLM service
257
+ * @param baseURL - Optional base URL for the API endpoint
258
+ * @param model - Optional model name (defaults to saved model or "grok-code-fast-1")
259
+ * @param maxToolRounds - Maximum number of tool execution rounds (default: 400)
260
+ * @param debugLogFile - Optional path for MCP debug logging
261
+ * @param startupHookOutput - Optional output from startup hook execution
262
+ * @param temperature - Optional temperature for API requests (0.0-2.0)
263
+ * @param maxTokens - Optional maximum tokens for API responses
264
+ */
115
265
  constructor(apiKey, baseURL, model, maxToolRounds, debugLogFile, startupHookOutput, temperature, maxTokens) {
116
266
  super();
117
267
  const manager = getSettingsManager();
@@ -149,6 +299,68 @@ export class LLMAgent extends EventEmitter {
149
299
  this.imageTool.setAgent(this); // Give image tool access to agent
150
300
  this.zsh.setAgent(this); // Give zsh tool access to agent for CWD tracking
151
301
  this.tokenCounter = createTokenCounter(modelToUse);
302
+ // Initialize tool executor
303
+ this.toolExecutor = new ToolExecutor(this, this.textEditor, this.morphEditor, this.zsh, this.search, this.env, this.introspect, this.clearCacheTool, this.restartTool, this.characterTool, this.taskTool, this.internetTool, this.imageTool, this.fileConversionTool, this.audioTool);
304
+ // Initialize hook manager
305
+ this.hookManager = new HookManager({
306
+ getLLMClient: () => this.llmClient,
307
+ getTokenCounter: () => this.tokenCounter,
308
+ apiKeyEnvVar: this.apiKeyEnvVar,
309
+ messages: this.messages,
310
+ chatHistory: this.chatHistory,
311
+ temperature: this.temperature,
312
+ getCurrentTokenCount: () => this.getCurrentTokenCount(),
313
+ getMaxContextSize: () => this.getMaxContextSize(),
314
+ getCurrentModel: () => this.getCurrentModel(),
315
+ emit: (event, data) => this.emit(event, data),
316
+ setApiKeyEnvVar: (value) => { this.apiKeyEnvVar = value; },
317
+ setTokenCounter: (counter) => { this.tokenCounter = counter; },
318
+ setLLMClient: (client) => { this.llmClient = client; },
319
+ setPersona: (persona, color) => { this.persona = persona; this.personaColor = color; },
320
+ setMood: (mood, color) => { this.mood = mood; this.moodColor = color; },
321
+ setActiveTask: (task, action, color) => { this.activeTask = task; this.activeTaskAction = action; this.activeTaskColor = color; }
322
+ });
323
+ // Initialize session manager
324
+ this.sessionManager = new SessionManager({
325
+ getLLMClient: () => this.llmClient,
326
+ getTokenCounter: () => this.tokenCounter,
327
+ getApiKeyEnvVar: () => this.apiKeyEnvVar,
328
+ hookManager: this.hookManager,
329
+ getPersona: () => this.persona,
330
+ getPersonaColor: () => this.personaColor,
331
+ getMood: () => this.mood,
332
+ getMoodColor: () => this.moodColor,
333
+ getActiveTask: () => this.activeTask,
334
+ getActiveTaskAction: () => this.activeTaskAction,
335
+ getActiveTaskColor: () => this.activeTaskColor,
336
+ getCurrentModel: () => this.getCurrentModel(),
337
+ emit: (event, data) => this.emit(event, data),
338
+ setLLMClient: (client) => { this.llmClient = client; },
339
+ setTokenCounter: (counter) => { this.tokenCounter = counter; },
340
+ setApiKeyEnvVar: (value) => { this.apiKeyEnvVar = value; },
341
+ setPersona: (persona, color) => { this.persona = persona; this.personaColor = color; },
342
+ setMood: (mood, color) => { this.mood = mood; this.moodColor = color; },
343
+ setActiveTask: (task, action, color) => { this.activeTask = task; this.activeTaskAction = action; this.activeTaskColor = color; }
344
+ });
345
+ // Initialize message processor
346
+ this.messageProcessor = new MessageProcessor({
347
+ chatHistory: this.chatHistory,
348
+ getCurrentTokenCount: () => this.getCurrentTokenCount(),
349
+ getMaxContextSize: () => this.getMaxContextSize(),
350
+ setRephraseState: (originalAssistantMessageIndex, rephraseRequestIndex, newResponseIndex, messageType, prefillText) => {
351
+ this.setRephraseState(originalAssistantMessageIndex, rephraseRequestIndex, newResponseIndex, messageType, prefillText);
352
+ }
353
+ });
354
+ // Initialize context manager
355
+ this.contextManager = new ContextManager({
356
+ chatHistory: this.chatHistory,
357
+ messages: this.messages,
358
+ tokenCounter: this.tokenCounter,
359
+ getCurrentTokenCount: () => this.getCurrentTokenCount(),
360
+ getMaxContextSize: () => this.getMaxContextSize(),
361
+ emit: (event, data) => this.emit(event, data),
362
+ clearCache: () => this.clearCache()
363
+ });
152
364
  // Initialize MCP servers if configured
153
365
  this.initializeMCP(debugLogFile);
154
366
  // System message will be set after async initialization
@@ -165,16 +377,32 @@ export class LLMAgent extends EventEmitter {
165
377
  systemPrompt = "Initializing..."; // THE system prompt (always at messages[0])
166
378
  hasRunInstanceHook = false;
167
379
  /**
168
- * Initialize the agent with dynamic system prompt
169
- * Must be called after construction
380
+ * Initialize the agent with dynamic system prompt.
381
+ *
382
+ * This method must be called after construction to:
383
+ * - Build the system message with current tool availability
384
+ * - Set up the initial conversation context
385
+ * - Execute the instance hook if configured
386
+ *
387
+ * @throws {Error} If system message generation fails
170
388
  */
171
389
  async initialize() {
172
390
  // Build system message
173
391
  await this.buildSystemMessage();
392
+ // Run instance hook after initialization is complete
393
+ await this.executeInstanceHookIfNeeded();
174
394
  }
175
395
  /**
176
- * Build/rebuild the system message with current tool availability
177
- * Updates this.systemPrompt which is always used for messages[0]
396
+ * Build/rebuild the system message with current tool availability.
397
+ *
398
+ * This method:
399
+ * - Generates a dynamic tool list using the introspect tool
400
+ * - Sets the APP:TOOLS variable for template rendering
401
+ * - Renders the full SYSTEM template with all variables
402
+ * - Updates messages[0] with the new system prompt
403
+ *
404
+ * The system prompt is always at messages[0] and contains the core
405
+ * instructions, tool descriptions, and current context information.
178
406
  */
179
407
  async buildSystemMessage() {
180
408
  // Generate dynamic tool list using introspect tool
@@ -182,16 +410,36 @@ export class LLMAgent extends EventEmitter {
182
410
  const toolsSection = toolsResult.success ? toolsResult.output : "Tools: Unknown";
183
411
  // Set APP:TOOLS variable
184
412
  Variable.set("APP:TOOLS", toolsSection);
185
- // Build THE system prompt
413
+ // Note: System prompt rendering moved to renderSystemMessage()
414
+ // which is called just before each LLM API call to ensure
415
+ // variables reflect current state including hook-set values
416
+ }
417
+ /**
418
+ * Render system message with current variable state
419
+ * Called before LLM API calls and task processing to ensure fresh content
420
+ */
421
+ renderSystemMessage() {
422
+ // Render SYSTEM template with current variables
186
423
  this.systemPrompt = Variable.renderFull('SYSTEM');
187
- // Update messages[0] with the system prompt
424
+ // Update messages[0] with the rendered system prompt
188
425
  this.messages[0] = {
189
426
  role: "system",
190
427
  content: this.systemPrompt,
191
428
  };
192
- // Note: chatHistory no longer contains THE system prompt
193
- // Only conversational system messages (persona, mood, etc.) go in chatHistory
194
429
  }
430
+ /**
431
+ * Load initial conversation history from persistence.
432
+ *
433
+ * This method:
434
+ * - Loads the chat history (excluding system messages)
435
+ * - Sets or generates the system prompt
436
+ * - Converts history to API message format
437
+ * - Handles tool call/result matching
438
+ * - Updates token counts
439
+ *
440
+ * @param history - Array of chat entries to load
441
+ * @param systemPrompt - Optional system prompt (will generate if not provided)
442
+ */
195
443
  async loadInitialHistory(history, systemPrompt) {
196
444
  // Load chatHistory (no system messages in new architecture)
197
445
  this.chatHistory = history;
@@ -287,6 +535,16 @@ export class LLMAgent extends EventEmitter {
287
535
  this.messages[0].content = this.messages[0].content.replace(/Current conversation token usage: .*/, `Current conversation token usage: ${currentTokens}`);
288
536
  }
289
537
  }
538
+ /**
539
+ * Initialize Model Context Protocol (MCP) servers in the background.
540
+ *
541
+ * This method loads MCP configuration and initializes any configured
542
+ * servers without blocking agent construction. Errors are logged but
543
+ * don't prevent agent operation.
544
+ *
545
+ * @param debugLogFile - Optional path for MCP debug output
546
+ * @private
547
+ */
290
548
  async initializeMCP(debugLogFile) {
291
549
  // Initialize MCP in the background without blocking
292
550
  Promise.resolve().then(async () => {
@@ -304,11 +562,27 @@ export class LLMAgent extends EventEmitter {
304
562
  }
305
563
  });
306
564
  }
565
+ /**
566
+ * Checks if the current model is a Grok model.
567
+ * Used to enable Grok-specific features like web search.
568
+ *
569
+ * @returns True if the current model name contains "grok"
570
+ * @private
571
+ */
307
572
  isGrokModel() {
308
573
  const currentModel = this.llmClient.getCurrentModel();
309
574
  return currentModel.toLowerCase().includes("grok");
310
575
  }
311
- // Heuristic: enable web search only when likely needed
576
+ /**
577
+ * Heuristic to determine if web search should be enabled for a message.
578
+ *
579
+ * Analyzes the message content for keywords that suggest the user is
580
+ * asking for current information, news, or time-sensitive data.
581
+ *
582
+ * @param message - The user message to analyze
583
+ * @returns True if web search should be enabled
584
+ * @private
585
+ */
312
586
  shouldUseSearchFor(message) {
313
587
  const q = message.toLowerCase();
314
588
  const keywords = [
@@ -337,197 +611,132 @@ export class LLMAgent extends EventEmitter {
337
611
  return true;
338
612
  return false;
339
613
  }
614
+ /**
615
+ * Process a user message and return all conversation entries generated.
616
+ *
617
+ * This is the main non-streaming message processing method that:
618
+ * - Handles rephrase commands and message preprocessing
619
+ * - Manages the agent loop with tool execution
620
+ * - Processes multiple rounds of AI responses and tool calls
621
+ * - Handles errors and context management
622
+ * - Returns all new conversation entries
623
+ *
624
+ * ## Processing Flow
625
+ *
626
+ * 1. **Setup**: Parse rephrase commands, clean incomplete tool calls
627
+ * 2. **Message Processing**: Parse images, assemble content, add to history
628
+ * 3. **Agent Loop**: Continue until no more tool calls or max rounds reached
629
+ * - Get AI response
630
+ * - Execute any tool calls
631
+ * - Add results to conversation
632
+ * - Get next response if needed
633
+ * 4. **Cleanup**: Handle errors, update context, return entries
634
+ *
635
+ * @param message - The user message to process
636
+ * @returns Promise resolving to array of new conversation entries
637
+ * @throws {Error} If message processing fails critically
638
+ */
340
639
  async processUserMessage(message) {
341
- // Detect rephrase commands
342
- let isRephraseCommand = false;
343
- let isSystemRephrase = false;
344
- let messageToSend = message;
345
- let messageType = "user";
346
- let prefillText;
347
- if (message.startsWith("/system rephrase")) {
348
- isRephraseCommand = true;
349
- isSystemRephrase = true;
350
- messageToSend = message.substring(8).trim(); // Strip "/system " (8 chars including space)
351
- messageType = "system";
352
- // Extract prefill text after "/system rephrase "
353
- const prefillMatch = message.match(/^\/system rephrase\s+(.+)$/);
354
- if (prefillMatch) {
355
- prefillText = prefillMatch[1];
356
- }
357
- }
358
- else if (message.startsWith("/rephrase")) {
359
- isRephraseCommand = true;
360
- messageToSend = message; // Keep full text including "/rephrase"
361
- messageType = "user";
362
- // Extract prefill text after "/rephrase "
363
- const prefillMatch = message.match(/^\/rephrase\s+(.+)$/);
364
- if (prefillMatch) {
365
- prefillText = prefillMatch[1];
366
- }
367
- }
368
- // If this is a rephrase command, find the last assistant message
369
- if (isRephraseCommand) {
370
- // Find index of last assistant message in chatHistory
371
- let lastAssistantIndex = -1;
372
- for (let i = this.chatHistory.length - 1; i >= 0; i--) {
373
- if (this.chatHistory[i].type === "assistant") {
374
- lastAssistantIndex = i;
375
- break;
376
- }
377
- }
378
- if (lastAssistantIndex === -1) {
379
- throw new Error("No previous assistant message to rephrase");
380
- }
381
- // Store rephrase state (will be updated with newResponseIndex after response)
382
- // For now, just mark that we're in rephrase mode
383
- this.setRephraseState(lastAssistantIndex, this.chatHistory.length, -1, messageType, prefillText);
384
- }
385
- // Before adding the new user message, check if there are incomplete tool calls
386
- // from a previous interrupted turn. This prevents malformed message sequences
387
- // that cause Ollama 500 errors.
388
- const lastMessage = this.messages[this.messages.length - 1];
389
- if (lastMessage?.role === "assistant" && lastMessage.tool_calls) {
390
- // Find tool_call_ids that don't have corresponding tool result messages
391
- const toolCallIds = new Set(lastMessage.tool_calls.map((tc) => tc.id));
392
- const completedToolCallIds = new Set();
393
- // Check which tool calls have results
394
- for (let i = this.messages.length - 1; i >= 0; i--) {
395
- const msg = this.messages[i];
396
- if (msg.role === "tool" && msg.tool_call_id) {
397
- completedToolCallIds.add(msg.tool_call_id);
398
- }
399
- // Stop when we hit the assistant message with tool_calls
400
- if (this.messages[i] === lastMessage)
401
- break;
402
- }
403
- // Add cancelled results for any incomplete tool calls
404
- for (const toolCallId of toolCallIds) {
405
- if (!completedToolCallIds.has(toolCallId)) {
406
- console.error(`Adding cancelled result for incomplete tool call: ${toolCallId}`);
407
- this.messages.push({
408
- role: "tool",
409
- content: "[Cancelled by user]",
410
- tool_call_id: toolCallId,
411
- });
412
- }
413
- }
414
- }
415
- // Clear one-shot variables
640
+ const { isRephraseCommand, messageType, messageToSend, prefillText } = await this.messageProcessor.setupRephraseCommand(message);
641
+ await this.cleanupIncompleteToolCalls();
416
642
  Variable.clearOneShot();
417
- // Execute instance hook once per session (after first clearOneShot)
418
- if (!this.hasRunInstanceHook) {
419
- this.hasRunInstanceHook = true;
420
- const settings = getSettingsManager();
421
- const instanceHookPath = settings.getInstanceHook();
422
- if (instanceHookPath) {
423
- const hookResult = await executeOperationHook(instanceHookPath, "instance", {}, 30000, false, // Instance hook is not mandatory
424
- this.getCurrentTokenCount(), this.getMaxContextSize());
425
- if (hookResult.approved && hookResult.commands && hookResult.commands.length > 0) {
426
- // Apply hook commands (ENV, TOOL_RESULT, MODEL, SYSTEM, SET*)
427
- const results = applyHookCommands(hookResult.commands);
428
- // Apply prompt variables from SET* commands
429
- for (const [varName, value] of results.promptVars.entries()) {
430
- Variable.set(varName, value);
431
- }
432
- // Process other hook commands (MODEL, BACKEND, ENV)
433
- await this.processHookCommands(results);
434
- // Add SYSTEM message to messages array if present
435
- if (results.system) {
436
- this.messages.push({
437
- role: 'system',
438
- content: results.system
439
- });
440
- }
441
- // Store prefill text from hook if present
442
- if (results.prefill) {
443
- this.hookPrefillText = results.prefill;
444
- }
445
- }
446
- }
447
- }
448
- // Parse images once if present (for both text extraction and later assembly)
449
- const parsed = hasImageReferences(messageToSend)
450
- ? parseImagesFromMessage(messageToSend)
451
- : { text: messageToSend, images: [] };
452
- // Set USER:PROMPT variable (text only, images stripped)
453
- Variable.set("USER:PROMPT", parsed.text);
454
- // Execute prePrompt hook if configured
455
- const hookPath = getSettingsManager().getPrePromptHook();
456
- if (hookPath) {
457
- const hookResult = await executeOperationHook(hookPath, "prePrompt", { USER_MESSAGE: parsed.text }, 30000, false, // prePrompt hook is never mandatory
458
- this.getCurrentTokenCount(), this.getMaxContextSize());
643
+ // Execute postUserInput hook
644
+ const postUserInputHookPath = getSettingsManager().getPostUserInputHook();
645
+ if (postUserInputHookPath) {
646
+ const hookResult = await executeOperationHook(postUserInputHookPath, "postUserInput", { USER_MESSAGE: message }, 30000, false, this.getCurrentTokenCount(), this.getMaxContextSize());
459
647
  if (hookResult.approved && hookResult.commands) {
460
648
  const results = applyHookCommands(hookResult.commands);
461
- // Set prompt variables from hook output (SET, SET_FILE, SET_TEMP_FILE)
462
- for (const [varName, value] of results.promptVars.entries()) {
463
- Variable.set(varName, value);
649
+ applyEnvVariables(results.env);
650
+ const seenVars = new Set();
651
+ for (const { name, value } of results.promptVars) {
652
+ if (seenVars.has(name)) {
653
+ Variable.append(name, value);
654
+ }
655
+ else {
656
+ Variable.set(name, value);
657
+ seenVars.add(name);
658
+ }
464
659
  }
465
- // Process other hook commands (MODEL, BACKEND, SYSTEM, etc.)
466
- await this.processHookCommands(results);
467
- // Store prefill text from hook if present
468
660
  if (results.prefill) {
469
- this.hookPrefillText = results.prefill;
661
+ this.messageProcessor.setHookPrefillText(results.prefill);
662
+ }
663
+ if (results.maxContext !== undefined) {
664
+ this.maxContextSize = results.maxContext;
470
665
  }
471
666
  }
472
667
  }
473
- // Assemble final message from variables
474
- const assembledMessage = Variable.renderFull("USER");
475
- // Add user/system message to conversation
476
- // Note: System messages can only have string content, so images are only supported for user messages
477
- const supportsVision = this.llmClient.getSupportsVision();
478
- let messageContent = assembledMessage;
479
- if (messageType === "user" && parsed.images.length > 0 && supportsVision) {
480
- // Construct content array with assembled text and images
481
- messageContent = [
482
- { type: "text", text: assembledMessage },
483
- ...parsed.images
484
- ];
485
- }
486
- const userEntry = {
487
- type: messageType,
488
- content: messageContent,
489
- originalContent: messageType === "user" ? (parsed.images.length > 0 && supportsVision
490
- ? [{ type: "text", text: parsed.text }, ...parsed.images]
491
- : parsed.text) : undefined,
492
- timestamp: new Date(),
493
- };
668
+ const { parsed, assembledMessage } = await this.messageProcessor.parseAndAssembleMessage(messageToSend);
669
+ const { userEntry, messageContent } = this.messageProcessor.prepareMessageContent(messageType, assembledMessage, parsed, messageToSend, this.llmClient.getSupportsVision());
494
670
  this.chatHistory.push(userEntry);
495
- // Push to messages array with proper typing based on role
496
671
  if (messageType === "user") {
497
672
  this.messages.push({ role: "user", content: messageContent });
498
673
  }
499
674
  else {
500
- // System messages must have string content only
501
675
  this.messages.push({ role: "system", content: typeof messageContent === "string" ? messageContent : messageToSend });
502
676
  }
503
- await this.emitContextChange();
677
+ await this.contextManager.emitContextChange();
504
678
  const newEntries = [userEntry];
505
679
  const maxToolRounds = this.maxToolRounds; // Prevent infinite loops
506
680
  let toolRounds = 0;
507
681
  let consecutiveNonToolResponses = 0;
508
682
  try {
509
- // If this is a rephrase with prefill text, add the assistant message now
510
- if (this.rephraseState?.prefillText) {
683
+ // Execute preLLMResponse hook just before LLM call
684
+ const hookPath = getSettingsManager().getPreLLMResponseHook();
685
+ if (hookPath) {
686
+ const hookResult = await executeOperationHook(hookPath, "preLLMResponse", { USER_MESSAGE: message }, 30000, false, this.getCurrentTokenCount(), this.getMaxContextSize());
687
+ if (hookResult.approved && hookResult.commands) {
688
+ const results = applyHookCommands(hookResult.commands);
689
+ applyEnvVariables(results.env);
690
+ const seenVars = new Set();
691
+ for (const { name, value } of results.promptVars) {
692
+ if (seenVars.has(name)) {
693
+ Variable.append(name, value);
694
+ }
695
+ else {
696
+ Variable.set(name, value);
697
+ seenVars.add(name);
698
+ }
699
+ }
700
+ if (results.prefill) {
701
+ this.messageProcessor.setHookPrefillText(results.prefill);
702
+ }
703
+ }
704
+ }
705
+ // If rephrase or hook returned prefill text, add the assistant message now
706
+ const rephraseText = this.rephraseState?.prefillText;
707
+ const hookPrefillText = this.messageProcessor.getHookPrefillText();
708
+ if (rephraseText) {
511
709
  this.messages.push({
512
710
  role: "assistant",
513
- content: this.rephraseState.prefillText
711
+ content: rephraseText
514
712
  });
515
713
  }
516
- // If a hook returned prefill text, add the assistant message now
517
- if (this.hookPrefillText) {
714
+ else if (hookPrefillText) {
518
715
  this.messages.push({
519
716
  role: "assistant",
520
- content: this.hookPrefillText
717
+ content: hookPrefillText
521
718
  });
522
719
  }
523
- // Always fetch tools fresh - getAllLLMTools() handles lazy refresh internally
720
+ // Render system message with current variable state before sending to LLM
721
+ this.renderSystemMessage();
524
722
  const supportsTools = this.llmClient.getSupportsTools();
525
723
  let currentResponse = await this.llmClient.chat(this.messages, supportsTools ? await getAllLLMTools() : [], undefined, this.isGrokModel() && this.shouldUseSearchFor(message)
526
724
  ? { search_parameters: { mode: "auto" } }
527
725
  : { search_parameters: { mode: "off" } }, this.temperature, this.abortController?.signal, this.maxTokens);
528
726
  // Parse XML tool calls from response if present
529
727
  if (currentResponse.choices?.[0]?.message) {
530
- currentResponse.choices[0].message = this.parseXMLToolCalls(currentResponse.choices[0].message);
728
+ currentResponse.choices[0].message = this.messageProcessor.parseXMLToolCalls(currentResponse.choices[0].message);
729
+ }
730
+ // Execute postLLMResponse hook
731
+ const postLLMResponseHookPath = getSettingsManager().getPostLLMResponseHook();
732
+ if (postLLMResponseHookPath) {
733
+ const hookResult = await executeOperationHook(postLLMResponseHookPath, "postLLMResponse", {
734
+ LLM_RESPONSE: getTextContent(currentResponse.choices?.[0]?.message?.content),
735
+ TOOL_CALLS: JSON.stringify(currentResponse.choices?.[0]?.message?.tool_calls || [])
736
+ }, 30000, false, this.getCurrentTokenCount(), this.getMaxContextSize());
737
+ if (hookResult.approved && hookResult.commands) {
738
+ await this.processHookResult(hookResult);
739
+ }
531
740
  }
532
741
  // Agent loop - continue until no more tool calls or max rounds reached
533
742
  while (toolRounds < maxToolRounds) {
@@ -567,7 +776,7 @@ export class LLMAgent extends EventEmitter {
567
776
  };
568
777
  this.chatHistory.push(assistantToolCallEntry);
569
778
  newEntries.push(assistantToolCallEntry);
570
- await this.emitContextChange();
779
+ await this.contextManager.emitContextChange();
571
780
  // Create initial tool call entries to show tools are being executed
572
781
  // Use cleanedToolCalls to preserve arguments in chatHistory
573
782
  cleanedToolCalls.forEach((toolCall) => {
@@ -600,7 +809,7 @@ export class LLMAgent extends EventEmitter {
600
809
  }
601
810
  throw new Error("Operation cancelled by user");
602
811
  }
603
- const result = await this.executeTool(toolCall);
812
+ const result = await this.toolExecutor.executeTool(toolCall);
604
813
  // Update the existing tool_call entry with the result
605
814
  const entryIndex = this.chatHistory.findIndex((entry) => entry.type === "tool_call" && entry.toolCall?.id === toolCall.id);
606
815
  if (entryIndex !== -1) {
@@ -629,7 +838,7 @@ export class LLMAgent extends EventEmitter {
629
838
  tool_call_id: toolCall.id,
630
839
  });
631
840
  completedToolCallIds.add(toolCall.id);
632
- await this.emitContextChange();
841
+ await this.contextManager.emitContextChange();
633
842
  toolIndex++;
634
843
  }
635
844
  }
@@ -687,6 +896,8 @@ export class LLMAgent extends EventEmitter {
687
896
  msgSummary.tool_call_id = msg.tool_call_id;
688
897
  fs.appendFileSync(debugLogPath, ` ${JSON.stringify(msgSummary)}\n`);
689
898
  });
899
+ // Render system message with current variable state before sending to LLM
900
+ this.renderSystemMessage();
690
901
  currentResponse = await this.llmClient.chat(this.messages, supportsTools ? await getAllLLMTools() : [], undefined, this.isGrokModel() && this.shouldUseSearchFor(message)
691
902
  ? { search_parameters: { mode: "auto" } }
692
903
  : { search_parameters: { mode: "off" } }, this.temperature, this.abortController?.signal, this.maxTokens);
@@ -699,9 +910,10 @@ export class LLMAgent extends EventEmitter {
699
910
  trimmedContent = this.rephraseState.prefillText + trimmedContent;
700
911
  }
701
912
  // If a hook provided prefill, prepend it to the response
702
- if (trimmedContent && this.hookPrefillText) {
703
- trimmedContent = this.hookPrefillText + trimmedContent;
704
- this.hookPrefillText = null; // Clear after use
913
+ const hookPrefillText = this.messageProcessor.getHookPrefillText();
914
+ if (trimmedContent && hookPrefillText) {
915
+ trimmedContent = hookPrefillText + trimmedContent;
916
+ this.messageProcessor.clearHookPrefillText();
705
917
  }
706
918
  if (trimmedContent) {
707
919
  const responseEntry = {
@@ -733,12 +945,14 @@ export class LLMAgent extends EventEmitter {
733
945
  break; // Task complete - bot gave a full response
734
946
  }
735
947
  // Short/empty response, give AI another chance
948
+ // Render system message with current variable state before sending to LLM
949
+ this.renderSystemMessage();
736
950
  currentResponse = await this.llmClient.chat(this.messages, supportsTools ? await getAllLLMTools() : [], undefined, this.isGrokModel() && this.shouldUseSearchFor(message)
737
951
  ? { search_parameters: { mode: "auto" } }
738
952
  : { search_parameters: { mode: "off" } }, this.temperature, this.abortController?.signal, this.maxTokens);
739
953
  // Parse XML tool calls from followup response if present
740
954
  if (currentResponse.choices?.[0]?.message) {
741
- currentResponse.choices[0].message = this.parseXMLToolCalls(currentResponse.choices[0].message);
955
+ currentResponse.choices[0].message = this.messageProcessor.parseXMLToolCalls(currentResponse.choices[0].message);
742
956
  }
743
957
  const followupMessage = currentResponse.choices?.[0]?.message;
744
958
  if (!followupMessage?.tool_calls || followupMessage.tool_calls.length === 0) {
@@ -795,284 +1009,84 @@ export class LLMAgent extends EventEmitter {
795
1009
  }
796
1010
  }
797
1011
  /**
798
- * Parse XML-formatted tool calls from message content (x.ai format)
799
- * Converts <xai:function_call> elements to standard LLMToolCall format
1012
+ * Process a user message with real-time streaming response.
1013
+ *
1014
+ * This is the main streaming message processing method that yields
1015
+ * chunks of data as the conversation progresses. Provides real-time
1016
+ * updates for:
1017
+ * - User message processing
1018
+ * - AI response streaming (content as it's generated)
1019
+ * - Tool execution progress
1020
+ * - Token count updates
1021
+ * - System messages from hooks
1022
+ *
1023
+ * ## Streaming Flow
1024
+ *
1025
+ * 1. **Setup**: Process user message, yield user entry
1026
+ * 2. **Agent Loop**: Stream AI responses and execute tools
1027
+ * - Stream AI response content in real-time
1028
+ * - Yield tool calls when detected
1029
+ * - Execute tools and yield results
1030
+ * - Continue until completion
1031
+ * 3. **Completion**: Yield final token counts and done signal
1032
+ *
1033
+ * ## Chunk Types
1034
+ *
1035
+ * - `user_message`: Initial user message entry
1036
+ * - `content`: Streaming AI response content
1037
+ * - `tool_calls`: Tool calls detected in AI response
1038
+ * - `tool_result`: Results from tool execution
1039
+ * - `token_count`: Updated token usage
1040
+ * - `done`: Processing complete
1041
+ *
1042
+ * @param message - The user message to process
1043
+ * @yields StreamingChunk objects with real-time updates
1044
+ * @throws {Error} If streaming fails critically
800
1045
  */
801
- parseXMLToolCalls(message) {
802
- if (!message.content || typeof message.content !== 'string') {
803
- return message;
804
- }
805
- const content = message.content;
806
- const xmlToolCallRegex = /<xai:function_call\s+name="([^"]+)">([\s\S]*?)<\/xai:function_call>/g;
807
- const matches = Array.from(content.matchAll(xmlToolCallRegex));
808
- if (matches.length === 0) {
809
- return message;
810
- }
811
- // Parse each XML tool call
812
- const toolCalls = [];
813
- let cleanedContent = content;
814
- for (const match of matches) {
815
- const functionName = match[1];
816
- const paramsXML = match[2];
817
- // Parse parameters
818
- const paramRegex = /<parameter\s+name="([^"]+)">([^<]*)<\/parameter>/g;
819
- const paramMatches = Array.from(paramsXML.matchAll(paramRegex));
820
- const args = {};
821
- for (const paramMatch of paramMatches) {
822
- args[paramMatch[1]] = paramMatch[2];
823
- }
824
- // Generate a unique ID for this tool call
825
- const toolCallId = `call_xml_${Date.now()}_${Math.random().toString(36).substring(7)}`;
826
- toolCalls.push({
827
- id: toolCallId,
828
- type: "function",
829
- function: {
830
- name: functionName,
831
- arguments: JSON.stringify(args)
832
- }
833
- });
834
- // Remove this XML block from content
835
- cleanedContent = cleanedContent.replace(match[0], '');
836
- }
837
- // Trim any extra whitespace
838
- cleanedContent = cleanedContent.trim();
839
- // Return modified message with tool_calls and cleaned content
840
- return {
841
- ...message,
842
- content: cleanedContent || null,
843
- tool_calls: [...(message.tool_calls || []), ...toolCalls]
844
- };
845
- }
846
- messageReducer(previous, item) {
847
- const reduce = (acc, delta) => {
848
- // Ensure acc is always an object before spreading (handles null/undefined)
849
- acc = { ...(acc || {}) };
850
- for (const [key, value] of Object.entries(delta)) {
851
- // Skip null values in delta (Venice sends tool_calls: null which breaks Object.entries)
852
- if (value === null)
853
- continue;
854
- if (acc[key] === undefined || acc[key] === null) {
855
- acc[key] = value;
856
- // Clean up index properties from tool calls
857
- if (Array.isArray(acc[key])) {
858
- for (const arr of acc[key]) {
859
- delete arr.index;
860
- }
861
- }
862
- }
863
- else if (typeof acc[key] === "string" && typeof value === "string") {
864
- // Don't concatenate certain properties that should remain separate
865
- const nonConcatenableProps = ['id', 'type', 'name'];
866
- if (nonConcatenableProps.includes(key)) {
867
- // For non-concatenable properties, keep the new value
868
- acc[key] = value;
869
- }
870
- else {
871
- // For content, arguments, and other text properties, concatenate
872
- acc[key] += value;
873
- }
874
- }
875
- else if (Array.isArray(acc[key]) && Array.isArray(value)) {
876
- const accArray = acc[key];
877
- for (let i = 0; i < value.length; i++) {
878
- if (!accArray[i])
879
- accArray[i] = {};
880
- accArray[i] = reduce(accArray[i], value[i]);
881
- }
882
- }
883
- else if (typeof acc[key] === "object" && typeof value === "object") {
884
- acc[key] = reduce(acc[key], value);
885
- }
886
- }
887
- return acc;
888
- };
889
- return reduce(previous, item.choices?.[0]?.delta || {});
890
- }
891
1046
  async *processUserMessageStream(message) {
892
- // Create new abort controller for this request
893
1047
  this.abortController = new AbortController();
894
- // Detect rephrase commands
895
- let isRephraseCommand = false;
896
- let isSystemRephrase = false;
897
- let messageToSend = message;
898
- let messageType = "user";
899
- let prefillText;
900
- if (message.startsWith("/system rephrase")) {
901
- isRephraseCommand = true;
902
- isSystemRephrase = true;
903
- messageToSend = message.substring(8).trim(); // Strip "/system " (8 chars including space)
904
- messageType = "system";
905
- // Extract prefill text after "/system rephrase "
906
- const prefillMatch = message.match(/^\/system rephrase\s+(.+)$/);
907
- if (prefillMatch) {
908
- prefillText = prefillMatch[1];
909
- }
910
- }
911
- else if (message.startsWith("/rephrase")) {
912
- isRephraseCommand = true;
913
- messageToSend = message; // Keep full text including "/rephrase"
914
- messageType = "user";
915
- // Extract prefill text after "/rephrase "
916
- const prefillMatch = message.match(/^\/rephrase\s+(.+)$/);
917
- if (prefillMatch) {
918
- prefillText = prefillMatch[1];
919
- }
920
- }
921
- // If this is a rephrase command, find the last assistant message
922
- if (isRephraseCommand) {
923
- // Find index of last assistant message in chatHistory
924
- let lastAssistantIndex = -1;
925
- for (let i = this.chatHistory.length - 1; i >= 0; i--) {
926
- if (this.chatHistory[i].type === "assistant") {
927
- lastAssistantIndex = i;
928
- break;
929
- }
930
- }
931
- if (lastAssistantIndex === -1) {
932
- throw new Error("No previous assistant message to rephrase");
933
- }
934
- // Store rephrase state (will be updated with newResponseIndex after response)
935
- // For now, just mark that we're in rephrase mode
936
- this.setRephraseState(lastAssistantIndex, this.chatHistory.length, -1, messageType, prefillText);
937
- }
938
- // Before adding the new user message, check if there are incomplete tool calls
939
- // from a previous interrupted turn. This prevents malformed message sequences
940
- // that cause Ollama 500 errors.
941
- const lastMessage = this.messages[this.messages.length - 1];
942
- if (lastMessage?.role === "assistant" && lastMessage.tool_calls) {
943
- // Find tool_call_ids that don't have corresponding tool result messages
944
- const toolCallIds = new Set(lastMessage.tool_calls.map((tc) => tc.id));
945
- const completedToolCallIds = new Set();
946
- // Check which tool calls have results
947
- for (let i = this.messages.length - 1; i >= 0; i--) {
948
- const msg = this.messages[i];
949
- if (msg.role === "tool" && msg.tool_call_id) {
950
- completedToolCallIds.add(msg.tool_call_id);
951
- }
952
- // Stop when we hit the assistant message with tool_calls
953
- if (this.messages[i] === lastMessage)
954
- break;
955
- }
956
- // Add cancelled results for any incomplete tool calls
957
- for (const toolCallId of toolCallIds) {
958
- if (!completedToolCallIds.has(toolCallId)) {
959
- console.error(`Adding cancelled result for incomplete tool call: ${toolCallId}`);
960
- this.messages.push({
961
- role: "tool",
962
- content: "[Cancelled by user]",
963
- tool_call_id: toolCallId,
964
- });
965
- }
966
- }
967
- }
968
- // Clear one-shot variables
1048
+ const { isRephraseCommand, messageType, messageToSend, prefillText } = await this.messageProcessor.setupRephraseCommand(message);
1049
+ await this.cleanupIncompleteToolCalls();
969
1050
  Variable.clearOneShot();
970
- // Execute instance hook once per session (after first clearOneShot)
971
- if (!this.hasRunInstanceHook) {
972
- this.hasRunInstanceHook = true;
973
- const settings = getSettingsManager();
974
- const instanceHookPath = settings.getInstanceHook();
975
- if (instanceHookPath) {
976
- const hookResult = await executeOperationHook(instanceHookPath, "instance", {}, 30000, false, // Instance hook is not mandatory
977
- this.getCurrentTokenCount(), this.getMaxContextSize());
978
- if (hookResult.approved && hookResult.commands && hookResult.commands.length > 0) {
979
- // Apply hook commands (ENV, TOOL_RESULT, MODEL, SYSTEM, SET*)
980
- const results = applyHookCommands(hookResult.commands);
981
- // Apply prompt variables from SET* commands
982
- for (const [varName, value] of results.promptVars.entries()) {
983
- Variable.set(varName, value);
984
- }
985
- // Process other hook commands (MODEL, BACKEND, ENV)
986
- await this.processHookCommands(results);
987
- // Add SYSTEM message to messages array if present
988
- if (results.system) {
989
- this.messages.push({
990
- role: 'system',
991
- content: results.system
992
- });
993
- }
994
- // Store prefill text from hook if present
995
- if (results.prefill) {
996
- this.hookPrefillText = results.prefill;
997
- }
998
- }
999
- }
1000
- }
1001
- // Parse images once if present (for both text extraction and later assembly)
1002
- const parsed = hasImageReferences(messageToSend)
1003
- ? parseImagesFromMessage(messageToSend)
1004
- : { text: messageToSend, images: [] };
1005
- // Set USER:PROMPT variable (text only, images stripped)
1006
- Variable.set("USER:PROMPT", parsed.text);
1007
- // Execute prePrompt hook if configured
1008
- const hookPath = getSettingsManager().getPrePromptHook();
1009
- if (hookPath) {
1010
- const hookResult = await executeOperationHook(hookPath, "prePrompt", { USER_MESSAGE: parsed.text }, 30000, false, // prePrompt hook is never mandatory
1011
- this.getCurrentTokenCount(), this.getMaxContextSize());
1051
+ // Execute postUserInput hook
1052
+ const postUserInputHookPath = getSettingsManager().getPostUserInputHook();
1053
+ if (postUserInputHookPath) {
1054
+ const hookResult = await executeOperationHook(postUserInputHookPath, "postUserInput", { USER_MESSAGE: message }, 30000, false, this.getCurrentTokenCount(), this.getMaxContextSize());
1012
1055
  if (hookResult.approved && hookResult.commands) {
1013
1056
  const results = applyHookCommands(hookResult.commands);
1014
- // Set prompt variables from hook output (SET, SET_FILE, SET_TEMP_FILE)
1015
- for (const [varName, value] of results.promptVars.entries()) {
1016
- Variable.set(varName, value);
1057
+ applyEnvVariables(results.env);
1058
+ const seenVars = new Set();
1059
+ for (const { name, value } of results.promptVars) {
1060
+ if (seenVars.has(name)) {
1061
+ Variable.append(name, value);
1062
+ }
1063
+ else {
1064
+ Variable.set(name, value);
1065
+ seenVars.add(name);
1066
+ }
1017
1067
  }
1018
- // Process other hook commands (MODEL, BACKEND, SYSTEM, etc.)
1019
- await this.processHookCommands(results);
1020
- // Store prefill text from hook if present
1021
1068
  if (results.prefill) {
1022
- this.hookPrefillText = results.prefill;
1069
+ this.messageProcessor.setHookPrefillText(results.prefill);
1070
+ }
1071
+ if (results.maxContext !== undefined) {
1072
+ this.maxContextSize = results.maxContext;
1023
1073
  }
1024
1074
  }
1025
1075
  }
1026
- // Assemble final message from variables
1027
- const assembledMessage = Variable.renderFull("USER");
1028
- // Add user/system message to both API conversation and chat history
1029
- // Note: System messages can only have string content, so images are only supported for user messages
1030
- const supportsVision = this.llmClient.getSupportsVision();
1031
- let messageContent = assembledMessage;
1032
- if (messageType === "user" && parsed.images.length > 0 && supportsVision) {
1033
- // Construct content array with assembled text and images
1034
- messageContent = [
1035
- { type: "text", text: assembledMessage },
1036
- ...parsed.images
1037
- ];
1038
- }
1039
- const userEntry = {
1040
- type: messageType,
1041
- content: messageContent,
1042
- originalContent: messageType === "user" ? (parsed.images.length > 0 && supportsVision
1043
- ? [{ type: "text", text: parsed.text }, ...parsed.images]
1044
- : parsed.text) : undefined,
1045
- timestamp: new Date(),
1046
- };
1076
+ const { parsed, assembledMessage } = await this.messageProcessor.parseAndAssembleMessage(messageToSend);
1077
+ const { userEntry, messageContent } = this.messageProcessor.prepareMessageContent(messageType, assembledMessage, parsed, messageToSend, this.llmClient.getSupportsVision());
1047
1078
  this.chatHistory.push(userEntry);
1048
- // Push to messages array with proper typing based on role
1049
1079
  if (messageType === "user") {
1050
1080
  this.messages.push({ role: "user", content: messageContent });
1051
1081
  }
1052
1082
  else {
1053
- // System messages must have string content only
1054
- this.messages.push({ role: "system", content: typeof messageContent === "string" ? messageContent : assembledMessage });
1083
+ this.messages.push({ role: "system", content: typeof messageContent === "string" ? messageContent : messageToSend });
1055
1084
  }
1056
- await this.emitContextChange();
1057
- // Yield user message so UI can display it immediately
1085
+ await this.contextManager.emitContextChange();
1058
1086
  yield {
1059
1087
  type: "user_message",
1060
1088
  userEntry: userEntry,
1061
1089
  };
1062
- // If this is a rephrase with prefill text, add the assistant message now
1063
- if (this.rephraseState?.prefillText) {
1064
- this.messages.push({
1065
- role: "assistant",
1066
- content: this.rephraseState.prefillText
1067
- });
1068
- }
1069
- // If a hook returned prefill text, add the assistant message now
1070
- if (this.hookPrefillText) {
1071
- this.messages.push({
1072
- role: "assistant",
1073
- content: this.hookPrefillText
1074
- });
1075
- }
1076
1090
  // Calculate input tokens
1077
1091
  let inputTokens = this.tokenCounter.countMessageTokens(this.messages);
1078
1092
  yield {
@@ -1103,6 +1117,37 @@ export class LLMAgent extends EventEmitter {
1103
1117
  this.messages[0].content = this.messages[0].content.replace(/Current conversation token usage: .*/, `Current conversation token usage: ${inputTokens}`);
1104
1118
  }
1105
1119
  // Stream response and accumulate
1120
+ // Execute preLLMResponse hook just before LLM call
1121
+ const hookPath = getSettingsManager().getPreLLMResponseHook();
1122
+ if (hookPath) {
1123
+ const hookResult = await executeOperationHook(hookPath, "preLLMResponse", { USER_MESSAGE: message }, 30000, false, this.getCurrentTokenCount(), this.getMaxContextSize());
1124
+ if (hookResult.approved && hookResult.commands) {
1125
+ const results = applyHookCommands(hookResult.commands);
1126
+ for (const { name, value } of results.promptVars) {
1127
+ Variable.set(name, value);
1128
+ }
1129
+ if (results.prefill) {
1130
+ this.messageProcessor.setHookPrefillText(results.prefill);
1131
+ }
1132
+ }
1133
+ }
1134
+ // If rephrase or hook returned prefill text, add the assistant message now
1135
+ const rephraseText = this.rephraseState?.prefillText;
1136
+ const hookPrefillText = this.messageProcessor.getHookPrefillText();
1137
+ if (rephraseText) {
1138
+ this.messages.push({
1139
+ role: "assistant",
1140
+ content: rephraseText
1141
+ });
1142
+ }
1143
+ else if (hookPrefillText) {
1144
+ this.messages.push({
1145
+ role: "assistant",
1146
+ content: hookPrefillText
1147
+ });
1148
+ }
1149
+ // Render system message with current variable state before sending to LLM
1150
+ this.renderSystemMessage();
1106
1151
  const stream = this.llmClient.chatStream(this.messages, supportsTools ? await getAllLLMTools() : [], undefined, this.isGrokModel() && this.shouldUseSearchFor(message)
1107
1152
  ? { search_parameters: { mode: "auto" } }
1108
1153
  : { search_parameters: { mode: "off" } }, this.temperature, this.abortController?.signal, this.maxTokens);
@@ -1119,15 +1164,6 @@ export class LLMAgent extends EventEmitter {
1119
1164
  };
1120
1165
  accumulatedContent = this.rephraseState.prefillText;
1121
1166
  }
1122
- // If a hook provided prefill, yield it first and add to accumulated content
1123
- if (this.hookPrefillText) {
1124
- yield {
1125
- type: "content",
1126
- content: this.hookPrefillText,
1127
- };
1128
- accumulatedContent = this.hookPrefillText;
1129
- this.hookPrefillText = null; // Clear after use
1130
- }
1131
1167
  try {
1132
1168
  for await (const chunk of stream) {
1133
1169
  // Check for cancellation in the streaming loop
@@ -1146,7 +1182,7 @@ export class LLMAgent extends EventEmitter {
1146
1182
  streamFinished = true;
1147
1183
  }
1148
1184
  // Accumulate the message using reducer
1149
- accumulatedMessage = this.messageReducer(accumulatedMessage, chunk);
1185
+ accumulatedMessage = this.messageProcessor.messageReducer(accumulatedMessage, chunk);
1150
1186
  // Check for tool calls - yield when we have complete tool calls with function names
1151
1187
  if (!tool_calls_yielded && accumulatedMessage.tool_calls?.length > 0) {
1152
1188
  // Check if we have at least one complete tool call with a function name
@@ -1224,7 +1260,18 @@ export class LLMAgent extends EventEmitter {
1224
1260
  throw streamError;
1225
1261
  }
1226
1262
  // Parse XML tool calls from accumulated message if present
1227
- accumulatedMessage = this.parseXMLToolCalls(accumulatedMessage);
1263
+ accumulatedMessage = this.messageProcessor.parseXMLToolCalls(accumulatedMessage);
1264
+ // Execute postLLMResponse hook
1265
+ const postLLMResponseHookPath = getSettingsManager().getPostLLMResponseHook();
1266
+ if (postLLMResponseHookPath) {
1267
+ const hookResult = await executeOperationHook(postLLMResponseHookPath, "postLLMResponse", {
1268
+ LLM_RESPONSE: getTextContent(accumulatedMessage.content),
1269
+ TOOL_CALLS: JSON.stringify(accumulatedMessage.tool_calls || [])
1270
+ }, 30000, false, this.getCurrentTokenCount(), this.getMaxContextSize());
1271
+ if (hookResult.approved && hookResult.commands) {
1272
+ await this.processHookResult(hookResult);
1273
+ }
1274
+ }
1228
1275
  // Clean up tool call arguments before adding to conversation history
1229
1276
  // This prevents Ollama from rejecting malformed tool calls on subsequent API calls
1230
1277
  const cleanedToolCalls = accumulatedMessage.tool_calls?.map(toolCall => {
@@ -1251,7 +1298,7 @@ export class LLMAgent extends EventEmitter {
1251
1298
  tool_calls: accumulatedMessage.tool_calls,
1252
1299
  };
1253
1300
  this.chatHistory.push(assistantEntry);
1254
- await this.emitContextChange();
1301
+ await this.contextManager.emitContextChange();
1255
1302
  // Update rephrase state if this is a final response (no tool calls)
1256
1303
  if (this.rephraseState && this.rephraseState.newResponseIndex === -1 && (!accumulatedMessage.tool_calls || accumulatedMessage.tool_calls.length === 0)) {
1257
1304
  const newResponseIndex = this.chatHistory.length - 1;
@@ -1305,7 +1352,7 @@ export class LLMAgent extends EventEmitter {
1305
1352
  }
1306
1353
  // Capture chatHistory length before tool execution to detect new system messages
1307
1354
  const chatHistoryLengthBefore = this.chatHistory.length;
1308
- const result = await this.executeTool(toolCall);
1355
+ const result = await this.toolExecutor.executeTool(toolCall);
1309
1356
  // Collect any new system messages added during tool execution (from hooks)
1310
1357
  const newSystemMessages = [];
1311
1358
  for (let i = chatHistoryLengthBefore; i < this.chatHistory.length; i++) {
@@ -1467,351 +1514,83 @@ export class LLMAgent extends EventEmitter {
1467
1514
  * Apply default parameter values for tools
1468
1515
  * This ensures the approval hook sees the same parameters that will be used during execution
1469
1516
  */
1470
- applyToolParameterDefaults(toolName, params) {
1471
- // Handle null/undefined params (can happen if API sends "null" as arguments string)
1472
- const result = { ...(params || {}) };
1473
- switch (toolName) {
1474
- case "listFiles":
1475
- // dirname defaults to current directory
1476
- if (!result.dirname) {
1477
- result.dirname = ".";
1478
- }
1479
- break;
1480
- // Add other tools with defaults here as needed
1481
- }
1482
- return result;
1483
- }
1484
1517
  /**
1485
1518
  * Validate tool arguments against the tool's schema
1486
1519
  * Returns null if valid, or an error message if invalid
1487
1520
  */
1488
- async validateToolArguments(toolName, args) {
1489
- try {
1490
- // Get all tools (including MCP tools)
1491
- const supportsTools = this.llmClient.getSupportsTools();
1492
- const allTools = supportsTools ? await getAllLLMTools() : [];
1493
- // Find the tool schema
1494
- const toolSchema = allTools.find(t => t.function.name === toolName);
1495
- if (!toolSchema) {
1496
- return `Unknown tool: ${toolName}`;
1497
- }
1498
- const schema = toolSchema.function.parameters;
1499
- const properties = schema.properties || {};
1500
- const required = schema.required || [];
1501
- // Check if tool accepts no parameters
1502
- const acceptsNoParams = Object.keys(properties).length === 0;
1503
- const hasArgs = args && typeof args === 'object' && Object.keys(args).length > 0;
1504
- if (acceptsNoParams && hasArgs) {
1505
- return `Tool ${toolName} accepts no parameters, but received: ${JSON.stringify(args)}`;
1506
- }
1507
- // Check for unknown parameters
1508
- for (const argKey of Object.keys(args || {})) {
1509
- if (!properties[argKey]) {
1510
- return `Tool ${toolName} does not accept parameter '${argKey}'. Valid parameters: ${Object.keys(properties).join(', ') || 'none'}`;
1511
- }
1512
- }
1513
- // Check for missing required parameters
1514
- for (const requiredParam of required) {
1515
- if (!(requiredParam in (args || {}))) {
1516
- return `Tool ${toolName} missing required parameter '${requiredParam}'`;
1517
- }
1518
- }
1519
- return null; // Valid
1520
- }
1521
- catch (error) {
1522
- console.error(`Error validating tool arguments for ${toolName}:`, error);
1523
- return null; // Allow execution if validation itself fails
1524
- }
1525
- }
1526
- async executeTool(toolCall) {
1527
- try {
1528
- // Parse arguments - handle empty string as empty object for parameter-less tools
1529
- let argsString = toolCall.function.arguments?.trim() || "{}";
1530
- // Handle duplicate/concatenated JSON objects (LLM bug)
1531
- // Pattern: {"key":"val"}{"key":"val"}
1532
- let hadDuplicateJson = false;
1533
- const extractedArgsString = extractFirstJsonObject(argsString);
1534
- if (extractedArgsString !== argsString) {
1535
- hadDuplicateJson = true;
1536
- argsString = extractedArgsString;
1537
- }
1538
- let args = JSON.parse(argsString);
1539
- // Handle multiple layers of JSON encoding (API bug)
1540
- // Keep parsing until we get an object, not a string
1541
- let parseCount = 0;
1542
- while (typeof args === 'string' && parseCount < MAX_JSON_PARSE_ATTEMPTS) {
1543
- parseCount++;
1544
- try {
1545
- args = JSON.parse(args);
1546
- }
1547
- catch (e) {
1548
- // If parse fails, the string isn't valid JSON - stop trying
1549
- break;
1550
- }
1551
- }
1552
- // Log if we had to fix encoding
1553
- if (parseCount > 0) {
1554
- const bugMsg = `[BUG] Tool ${toolCall.function.name} had ${parseCount} extra layer(s) of JSON encoding`;
1555
- console.warn(bugMsg);
1556
- const systemMsg = `Warning: Tool arguments for ${toolCall.function.name} had ${parseCount} extra encoding layer(s) - this is an API bug`;
1557
- this.messages.push({
1558
- role: 'system',
1559
- content: systemMsg
1560
- });
1561
- this.chatHistory.push({
1562
- type: 'system',
1563
- content: systemMsg,
1564
- timestamp: new Date()
1565
- });
1566
- }
1567
- // Log if we had to fix duplicate JSON
1568
- if (hadDuplicateJson) {
1569
- const bugMsg = `[BUG] Tool ${toolCall.function.name} had duplicate/concatenated JSON objects`;
1570
- console.warn(bugMsg);
1571
- const systemMsg = `Warning: Tool arguments for ${toolCall.function.name} had duplicate JSON objects (used first object only) - this is an LLM bug`;
1572
- this.messages.push({
1573
- role: 'system',
1574
- content: systemMsg
1575
- });
1576
- this.chatHistory.push({
1577
- type: 'system',
1578
- content: systemMsg,
1579
- timestamp: new Date()
1580
- });
1581
- }
1582
- // Ensure args is always an object (API might send null)
1583
- if (!args || typeof args !== 'object' || Array.isArray(args)) {
1584
- args = {};
1585
- }
1586
- // Apply parameter defaults before validation and execution
1587
- args = this.applyToolParameterDefaults(toolCall.function.name, args);
1588
- // Validate tool arguments against schema
1589
- const validationError = await this.validateToolArguments(toolCall.function.name, args);
1590
- if (validationError) {
1591
- // Validation failed - return error
1592
- const errorMsg = `Tool call validation failed: ${validationError}. Please try again with correct parameters.`;
1593
- console.warn(`[VALIDATION ERROR] ${errorMsg}`);
1594
- return {
1595
- success: false,
1596
- error: validationError
1597
- };
1598
- }
1599
- // Task tools (startActiveTask, transitionActiveTaskStatus, stopActiveTask) have their own
1600
- // dedicated task approval hook, so skip the general tool approval hook for them
1601
- const isTaskTool = ['startActiveTask', 'transitionActiveTaskStatus', 'stopActiveTask'].includes(toolCall.function.name);
1602
- // Check tool approval hook if configured (skip for task tools)
1603
- const settings = getSettingsManager();
1604
- const toolApprovalHook = settings.getToolApprovalHook();
1605
- if (toolApprovalHook && !isTaskTool) {
1606
- const approvalResult = await executeToolApprovalHook(toolApprovalHook, toolCall.function.name, args, 30000, // 30 second timeout
1607
- this.getCurrentTokenCount(), this.getMaxContextSize());
1608
- if (!approvalResult.approved) {
1609
- const reason = approvalResult.reason || "Tool execution denied by approval hook";
1610
- // Process rejection commands (MODEL, SYSTEM, BACKEND, etc.)
1611
- await this.processHookResult(approvalResult);
1612
- return {
1613
- success: false,
1614
- error: `Tool execution blocked: ${reason}`,
1615
- };
1616
- }
1617
- if (approvalResult.timedOut) {
1618
- // Log timeout for debugging (don't block)
1619
- console.warn(`Tool approval hook timed out for ${toolCall.function.name} (auto-approved)`);
1620
- }
1621
- // Process hook commands (ENV, TOOL_RESULT, MODEL, SYSTEM, BACKEND, etc.)
1622
- // TOOL_RESULT is for tool return values, not used by approval hook
1623
- // ENV variables can affect tool behavior if tools read from process.env
1624
- await this.processHookResult(approvalResult);
1625
- }
1626
- switch (toolCall.function.name) {
1627
- case "viewFile":
1628
- {
1629
- let range;
1630
- range = args.start_line && args.end_line
1631
- ? [args.start_line, args.end_line]
1632
- : undefined;
1633
- return await this.textEditor.viewFile(args.filename, range);
1634
- }
1635
- case "createNewFile":
1636
- return await this.textEditor.createNewFile(args.filename, args.content);
1637
- case "strReplace":
1638
- return await this.textEditor.strReplace(args.filename, args.old_str, args.new_str, args.replace_all);
1639
- case "editFile":
1640
- if (!this.morphEditor) {
1641
- return {
1642
- success: false,
1643
- error: "Morph Fast Apply not available. Please set MORPH_API_KEY environment variable to use this feature.",
1644
- };
1645
- }
1646
- return await this.morphEditor.editFile(args.filename, args.instructions, args.code_edit);
1647
- case "execute":
1648
- return await this.zsh.execute(args.command);
1649
- case "listFiles":
1650
- return await this.zsh.listFiles(args.dirname);
1651
- case "universalSearch":
1652
- return await this.search.universalSearch(args.query, {
1653
- searchType: args.search_type,
1654
- includePattern: args.include_pattern,
1655
- excludePattern: args.exclude_pattern,
1656
- caseSensitive: args.case_sensitive,
1657
- wholeWord: args.whole_word,
1658
- regex: args.regex,
1659
- maxResults: args.max_results,
1660
- fileTypes: args.file_types,
1661
- includeHidden: args.include_hidden,
1662
- });
1663
- case "getEnv":
1664
- return await this.env.getEnv(args.variable);
1665
- case "getAllEnv":
1666
- return await this.env.getAllEnv();
1667
- case "searchEnv":
1668
- return await this.env.searchEnv(args.pattern);
1669
- case "introspect":
1670
- return await this.introspect.introspect(args.target);
1671
- case "clearCache":
1672
- return await this.clearCacheTool.clearCache(args.confirmationCode);
1673
- case "restart":
1674
- return await this.restartTool.restart();
1675
- case "setPersona":
1676
- return await this.characterTool.setPersona(args.persona, args.color);
1677
- case "setMood":
1678
- return await this.characterTool.setMood(args.mood, args.color);
1679
- case "getPersona":
1680
- return await this.characterTool.getPersona();
1681
- case "getMood":
1682
- return await this.characterTool.getMood();
1683
- case "getAvailablePersonas":
1684
- return await this.characterTool.getAvailablePersonas();
1685
- case "startActiveTask":
1686
- return await this.taskTool.startActiveTask(args.activeTask, args.action, args.color);
1687
- case "transitionActiveTaskStatus":
1688
- return await this.taskTool.transitionActiveTaskStatus(args.action, args.color);
1689
- case "stopActiveTask":
1690
- return await this.taskTool.stopActiveTask(args.reason, args.documentationFile, args.color);
1691
- case "insertLines":
1692
- return await this.textEditor.insertLines(args.filename, args.insert_line, args.new_str);
1693
- case "replaceLines":
1694
- return await this.textEditor.replaceLines(args.filename, args.start_line, args.end_line, args.new_str);
1695
- case "undoEdit":
1696
- return await this.textEditor.undoEdit();
1697
- case "chdir":
1698
- return this.zsh.chdir(args.dirname);
1699
- case "pwdir":
1700
- return this.zsh.pwdir();
1701
- case "downloadFile":
1702
- return await this.internetTool.downloadFile(args.url);
1703
- case "generateImage":
1704
- 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);
1705
- case "captionImage":
1706
- return await this.imageTool.captionImage(args.filename, args.backend);
1707
- case "pngInfo":
1708
- return await this.imageTool.pngInfo(args.filename);
1709
- case "listImageModels":
1710
- return await this.imageTool.listImageModels();
1711
- case "listImageLoras":
1712
- return await this.imageTool.listImageLoras();
1713
- case "extractTextFromImage":
1714
- return await this.imageTool.extractTextFromImage(args.filename);
1715
- case "extractTextFromAudio":
1716
- return await this.audioTool.extractTextFromAudio(args.filename);
1717
- case "readXlsx":
1718
- return await this.fileConversionTool.readXlsx(args.filename, args.sheetName, args.outputFormat, args.output);
1719
- case "listXlsxSheets":
1720
- return await this.fileConversionTool.listXlsxSheets(args.filename);
1721
- default:
1722
- // Check if this is an MCP tool
1723
- if (toolCall.function.name.startsWith("mcp__")) {
1724
- return await this.executeMCPTool(toolCall.function.name, args);
1725
- }
1726
- return {
1727
- success: false,
1728
- error: `Unknown tool: ${toolCall.function.name}`,
1729
- };
1730
- }
1731
- }
1732
- catch (error) {
1733
- return {
1734
- success: false,
1735
- error: `Tool execution error: ${error.message}`,
1736
- };
1737
- }
1738
- }
1739
- async executeMCPTool(toolName, args) {
1740
- try {
1741
- const mcpManager = getMCPManager();
1742
- const result = await mcpManager.callTool(toolName, args);
1743
- if (result.isError) {
1744
- return {
1745
- success: false,
1746
- error: result.content[0]?.text || "MCP tool error",
1747
- };
1748
- }
1749
- // Extract content from result
1750
- const output = result.content
1751
- .map((item) => {
1752
- if (item.type === "text") {
1753
- return item.text;
1754
- }
1755
- else if (item.type === "resource") {
1756
- return `Resource: ${item.resource?.uri || "Unknown"}`;
1757
- }
1758
- return String(item);
1759
- })
1760
- .join("\n");
1761
- // After successful MCP tool execution, invalidate cache for that server
1762
- // Next call to getAllLLMTools() will lazy-refresh this server
1763
- const serverNameMatch = toolName.match(/^mcp__(.+?)__/);
1764
- if (serverNameMatch) {
1765
- const serverName = serverNameMatch[1];
1766
- mcpManager.invalidateCache(serverName);
1767
- }
1768
- return {
1769
- success: true,
1770
- output: output || "Success",
1771
- };
1772
- }
1773
- catch (error) {
1774
- return {
1775
- success: false,
1776
- error: `MCP tool execution error: ${error.message}`,
1777
- };
1778
- }
1779
- }
1521
+ /**
1522
+ * Get a copy of the current chat history.
1523
+ * @returns Array of chat entries (defensive copy)
1524
+ */
1780
1525
  getChatHistory() {
1781
1526
  return [...this.chatHistory];
1782
1527
  }
1528
+ /**
1529
+ * Set the chat history to a new array of entries.
1530
+ * @param history - New chat history entries
1531
+ */
1783
1532
  setChatHistory(history) {
1784
1533
  this.chatHistory = [...history];
1785
1534
  }
1535
+ /**
1536
+ * Get the current system prompt.
1537
+ * @returns The system prompt string
1538
+ */
1786
1539
  getSystemPrompt() {
1540
+ // Render system message with current variable state before returning
1541
+ this.renderSystemMessage();
1787
1542
  return this.systemPrompt;
1788
1543
  }
1544
+ /**
1545
+ * Set a new system prompt and update the first message.
1546
+ * @param prompt - Ignored (deprecated) - system prompt is always rendered from variables
1547
+ */
1789
1548
  setSystemPrompt(prompt) {
1790
- this.systemPrompt = prompt;
1791
- this.messages[0] = {
1792
- role: "system",
1793
- content: prompt,
1794
- };
1549
+ // System prompt is always dynamic - render from current variable state
1550
+ this.renderSystemMessage();
1795
1551
  }
1552
+ /**
1553
+ * Get a copy of the current API messages array.
1554
+ * @returns Array of LLM messages (defensive copy)
1555
+ */
1796
1556
  getMessages() {
1797
1557
  return [...this.messages];
1798
1558
  }
1559
+ /**
1560
+ * Get the current token count for the conversation.
1561
+ * @returns Number of tokens in the current message context
1562
+ */
1799
1563
  getCurrentTokenCount() {
1800
1564
  return this.tokenCounter.countMessageTokens(this.messages);
1801
1565
  }
1566
+ /**
1567
+ * Get the maximum context size for the current model.
1568
+ * @returns Maximum number of tokens supported
1569
+ * @todo Make this model-specific for different context windows
1570
+ */
1802
1571
  getMaxContextSize() {
1803
- // TODO: Make this model-specific when different models have different context windows
1804
- // For now, return the standard Grok context window size
1805
- return 128000;
1572
+ return this.maxContextSize;
1806
1573
  }
1574
+ /**
1575
+ * Get the current context usage as a percentage.
1576
+ * @returns Percentage of context window used (0-100)
1577
+ */
1807
1578
  getContextUsagePercent() {
1808
- const current = this.getCurrentTokenCount();
1809
- const max = this.getMaxContextSize();
1810
- return (current / max) * 100;
1579
+ return this.contextManager.getContextUsagePercent();
1811
1580
  }
1812
1581
  /**
1813
- * Convert context messages to markdown format for viewing
1582
+ * Convert the conversation context to markdown format for viewing.
1583
+ *
1584
+ * Creates a human-readable markdown representation of the conversation
1585
+ * including:
1586
+ * - Header with context file path and token usage
1587
+ * - Numbered messages with timestamps
1588
+ * - Formatted tool calls and results
1589
+ * - Proper attribution (User/Assistant/System)
1590
+ *
1814
1591
  * Format: (N) Name (role) - timestamp
1592
+ *
1593
+ * @returns Promise resolving to markdown-formatted conversation
1815
1594
  */
1816
1595
  async convertContextToMarkdown() {
1817
1596
  const lines = [];
@@ -1866,389 +1645,234 @@ export class LLMAgent extends EventEmitter {
1866
1645
  });
1867
1646
  return lines.join("\n");
1868
1647
  }
1648
+ /**
1649
+ * Get the current persona setting.
1650
+ * @returns Current persona string
1651
+ */
1869
1652
  getPersona() {
1870
1653
  return this.persona;
1871
1654
  }
1655
+ /**
1656
+ * Get the current persona display color.
1657
+ * @returns Color name for persona display
1658
+ */
1872
1659
  getPersonaColor() {
1873
1660
  return this.personaColor;
1874
1661
  }
1662
+ /**
1663
+ * Get the current mood setting.
1664
+ * @returns Current mood string
1665
+ */
1875
1666
  getMood() {
1876
1667
  return this.mood;
1877
1668
  }
1669
+ /**
1670
+ * Get the current mood display color.
1671
+ * @returns Color name for mood display
1672
+ */
1878
1673
  getMoodColor() {
1879
1674
  return this.moodColor;
1880
1675
  }
1676
+ /**
1677
+ * Get the current active task.
1678
+ * @returns Current active task string
1679
+ */
1881
1680
  getActiveTask() {
1882
1681
  return this.activeTask;
1883
1682
  }
1683
+ /**
1684
+ * Get the current active task action.
1685
+ * @returns Current task action string
1686
+ */
1884
1687
  getActiveTaskAction() {
1885
1688
  return this.activeTaskAction;
1886
1689
  }
1690
+ /**
1691
+ * Get the current active task display color.
1692
+ * @returns Color name for task display
1693
+ */
1887
1694
  getActiveTaskColor() {
1888
1695
  return this.activeTaskColor;
1889
1696
  }
1697
+ /**
1698
+ * Set a pending context edit session for file-based context editing.
1699
+ * @param tmpJsonPath - Path to temporary JSON file
1700
+ * @param contextFilePath - Path to actual context file
1701
+ */
1890
1702
  setPendingContextEditSession(tmpJsonPath, contextFilePath) {
1891
1703
  this.pendingContextEditSession = { tmpJsonPath, contextFilePath };
1892
1704
  }
1705
+ /**
1706
+ * Get the current pending context edit session.
1707
+ * @returns Edit session info or null if none pending
1708
+ */
1893
1709
  getPendingContextEditSession() {
1894
1710
  return this.pendingContextEditSession;
1895
1711
  }
1712
+ /**
1713
+ * Clear the pending context edit session.
1714
+ */
1896
1715
  clearPendingContextEditSession() {
1897
1716
  this.pendingContextEditSession = null;
1898
1717
  }
1718
+ /**
1719
+ * Set the rephrase state for message editing operations.
1720
+ * @param originalAssistantMessageIndex - Index of original assistant message
1721
+ * @param rephraseRequestIndex - Index of rephrase request
1722
+ * @param newResponseIndex - Index of new response (-1 if not yet created)
1723
+ * @param messageType - Type of message being rephrased
1724
+ * @param prefillText - Optional prefill text for the response
1725
+ */
1899
1726
  setRephraseState(originalAssistantMessageIndex, rephraseRequestIndex, newResponseIndex, messageType, prefillText) {
1900
1727
  this.rephraseState = { originalAssistantMessageIndex, rephraseRequestIndex, newResponseIndex, messageType, prefillText };
1901
1728
  }
1729
+ /**
1730
+ * Get the current rephrase state.
1731
+ * @returns Rephrase state info or null if none active
1732
+ */
1902
1733
  getRephraseState() {
1903
1734
  return this.rephraseState;
1904
1735
  }
1736
+ /**
1737
+ * Clear the current rephrase state.
1738
+ */
1905
1739
  clearRephraseState() {
1906
1740
  this.rephraseState = null;
1907
1741
  }
1742
+ /**
1743
+ * Set the agent's persona with optional color.
1744
+ *
1745
+ * Executes the persona hook if configured and updates the agent's
1746
+ * persona state on success.
1747
+ *
1748
+ * @param persona - The persona description
1749
+ * @param color - Optional display color (defaults to "white")
1750
+ * @returns Promise resolving to success/error result
1751
+ */
1908
1752
  async setPersona(persona, color) {
1909
- // Execute hook if configured
1910
- const settings = getSettingsManager();
1911
- const hookPath = settings.getPersonaHook();
1912
- const hookMandatory = settings.isPersonaHookMandatory();
1913
- if (!hookPath && hookMandatory) {
1914
- const reason = "Persona hook is mandatory but not configured";
1915
- return {
1916
- success: false,
1917
- error: reason
1918
- };
1753
+ const result = await this.hookManager.setPersona(persona, color);
1754
+ if (result.success) {
1755
+ this.persona = persona;
1756
+ this.personaColor = color || "white";
1919
1757
  }
1920
- if (hookPath) {
1921
- const hookResult = await executeOperationHook(hookPath, "setPersona", {
1922
- persona_old: this.persona || "",
1923
- persona_new: persona,
1924
- persona_color: color || "white"
1925
- }, 30000, hookMandatory, this.getCurrentTokenCount(), this.getMaxContextSize());
1926
- if (!hookResult.approved) {
1927
- const reason = hookResult.reason || "Hook rejected persona change";
1928
- // Process rejection commands (MODEL, SYSTEM)
1929
- // Even in rejection, we process commands (might have MODEL change)
1930
- await this.processHookResult(hookResult);
1931
- // Note: We ignore the return value here since we're already rejecting the persona
1932
- return {
1933
- success: false,
1934
- error: reason
1935
- };
1936
- }
1937
- if (hookResult.timedOut) {
1938
- // Hook timed out but was auto-approved
1939
- }
1940
- // Process hook commands (ENV, MODEL, SYSTEM)
1941
- const result = await this.processHookResult(hookResult, 'ZDS_AI_AGENT_PERSONA');
1942
- if (!result.success) {
1943
- // Model/backend test failed - don't apply persona change
1944
- return {
1945
- success: false,
1946
- error: "Persona change rejected due to failed model/backend test"
1947
- };
1948
- }
1949
- // Apply persona transformation if present
1950
- if (result.transformedValue) {
1951
- persona = result.transformedValue;
1952
- }
1953
- }
1954
- const oldPersona = this.persona;
1955
- const oldColor = this.personaColor;
1956
- this.persona = persona;
1957
- this.personaColor = color || "white";
1958
- process.env.ZDS_AI_AGENT_PERSONA = persona;
1959
- // Persona hook generates success message - no need for redundant CLI message
1960
- this.emit('personaChange', {
1961
- persona: this.persona,
1962
- color: this.personaColor
1963
- });
1964
- return { success: true };
1758
+ return result;
1965
1759
  }
1760
+ /**
1761
+ * Set the agent's mood with optional color.
1762
+ *
1763
+ * Executes the mood hook if configured and updates the agent's
1764
+ * mood state on success.
1765
+ *
1766
+ * @param mood - The mood description
1767
+ * @param color - Optional display color (defaults to "white")
1768
+ * @returns Promise resolving to success/error result
1769
+ */
1966
1770
  async setMood(mood, color) {
1967
- // Execute hook if configured
1968
- const settings = getSettingsManager();
1969
- const hookPath = settings.getMoodHook();
1970
- const hookMandatory = settings.isMoodHookMandatory();
1971
- if (!hookPath && hookMandatory) {
1972
- const reason = "Mood hook is mandatory but not configured";
1973
- return {
1974
- success: false,
1975
- error: reason
1976
- };
1977
- }
1978
- if (hookPath) {
1979
- const hookResult = await executeOperationHook(hookPath, "setMood", {
1980
- mood_old: this.mood || "",
1981
- mood_new: mood,
1982
- mood_color: color || "white"
1983
- }, 30000, hookMandatory, this.getCurrentTokenCount(), this.getMaxContextSize());
1984
- if (!hookResult.approved) {
1985
- const reason = hookResult.reason || "Hook rejected mood change";
1986
- // Process rejection commands (MODEL, SYSTEM)
1987
- await this.processHookResult(hookResult);
1988
- return {
1989
- success: false,
1990
- error: reason
1991
- };
1992
- }
1993
- if (hookResult.timedOut) {
1994
- // Hook timed out but was auto-approved
1995
- }
1996
- // Process hook commands (ENV, MODEL, SYSTEM)
1997
- const result = await this.processHookResult(hookResult, 'ZDS_AI_AGENT_MOOD');
1998
- if (!result.success) {
1999
- // Model/backend test failed - don't apply mood change
2000
- return {
2001
- success: false,
2002
- error: "Mood change rejected due to failed model/backend test"
2003
- };
2004
- }
2005
- // Apply mood transformation if present
2006
- if (result.transformedValue) {
2007
- mood = result.transformedValue;
2008
- }
2009
- }
2010
- const oldMood = this.mood;
2011
- const oldColor = this.moodColor;
2012
- this.mood = mood;
2013
- this.moodColor = color || "white";
2014
- process.env.ZDS_AI_AGENT_MOOD = mood;
2015
- // Add system message for recordkeeping
2016
- let systemContent;
2017
- if (oldMood) {
2018
- const oldColorStr = oldColor && oldColor !== "white" ? ` (${oldColor})` : "";
2019
- const newColorStr = this.moodColor && this.moodColor !== "white" ? ` (${this.moodColor})` : "";
2020
- systemContent = `Assistant changed the mood from "${oldMood}"${oldColorStr} to "${this.mood}"${newColorStr}`;
1771
+ const result = await this.hookManager.setMood(mood, color);
1772
+ if (result.success) {
1773
+ this.mood = mood;
1774
+ this.moodColor = color || "white";
2021
1775
  }
2022
- else {
2023
- const colorStr = this.moodColor && this.moodColor !== "white" ? ` (${this.moodColor})` : "";
2024
- systemContent = `Assistant set the mood to "${this.mood}"${colorStr}`;
2025
- }
2026
- // Note: Don't add to this.messages during tool execution - only chatHistory
2027
- // System messages added during tool execution create invalid message sequences
2028
- // because they get inserted between tool_calls and tool_results
2029
- this.chatHistory.push({
2030
- type: 'system',
2031
- content: systemContent,
2032
- timestamp: new Date()
2033
- });
2034
- this.emit('moodChange', {
2035
- mood: this.mood,
2036
- color: this.moodColor
2037
- });
2038
- return { success: true };
1776
+ return result;
2039
1777
  }
1778
+ /**
1779
+ * Start an active task with specified action and color.
1780
+ *
1781
+ * Executes the task start hook if configured and updates the agent's
1782
+ * task state on success.
1783
+ *
1784
+ * @param activeTask - The task description
1785
+ * @param action - The current action within the task
1786
+ * @param color - Optional display color (defaults to "white")
1787
+ * @returns Promise resolving to success/error result
1788
+ */
2040
1789
  async startActiveTask(activeTask, action, color) {
2041
- // Cannot start new task if one already exists
2042
- if (this.activeTask) {
2043
- return {
2044
- success: false,
2045
- error: `Cannot start new task "${activeTask}". Active task "${this.activeTask}" must be stopped first.`
2046
- };
2047
- }
2048
- // Execute hook if configured
2049
- const settings = getSettingsManager();
2050
- const hookPath = settings.getTaskApprovalHook();
2051
- if (hookPath) {
2052
- const hookResult = await executeOperationHook(hookPath, "startActiveTask", {
2053
- activetask: activeTask,
2054
- action: action,
2055
- color: color || "white"
2056
- }, 30000, false, // Task hook is not mandatory
2057
- this.getCurrentTokenCount(), this.getMaxContextSize());
2058
- // Process hook commands (MODEL, SYSTEM, ENV, BACKEND, etc.) for both approval and rejection
2059
- await this.processHookResult(hookResult);
2060
- if (!hookResult.approved) {
2061
- const reason = hookResult.reason || "Hook rejected task start";
2062
- return {
2063
- success: false,
2064
- error: reason
2065
- };
2066
- }
2067
- if (hookResult.timedOut) {
2068
- // Hook timed out but was auto-approved
2069
- }
1790
+ const result = await this.hookManager.startActiveTask(activeTask, action, color);
1791
+ if (result.success) {
1792
+ this.activeTask = activeTask;
1793
+ this.activeTaskAction = action;
1794
+ this.activeTaskColor = color || "white";
2070
1795
  }
2071
- // Set the task
2072
- this.activeTask = activeTask;
2073
- this.activeTaskAction = action;
2074
- this.activeTaskColor = color || "white";
2075
- // Add system message
2076
- const colorStr = this.activeTaskColor && this.activeTaskColor !== "white" ? ` (${this.activeTaskColor})` : "";
2077
- this.messages.push({
2078
- role: 'system',
2079
- content: `Assistant changed task status for "${this.activeTask}" to ${this.activeTaskAction}${colorStr}`
2080
- });
2081
- // Emit event
2082
- this.emit('activeTaskChange', {
2083
- activeTask: this.activeTask,
2084
- action: this.activeTaskAction,
2085
- color: this.activeTaskColor
2086
- });
2087
- return { success: true };
1796
+ return result;
2088
1797
  }
1798
+ /**
1799
+ * Transition the active task to a new action/status.
1800
+ *
1801
+ * Updates the current task action without changing the task itself.
1802
+ *
1803
+ * @param action - The new action description
1804
+ * @param color - Optional display color (defaults to current color)
1805
+ * @returns Promise resolving to success/error result
1806
+ */
2089
1807
  async transitionActiveTaskStatus(action, color) {
2090
- // Cannot transition if no active task
2091
- if (!this.activeTask) {
2092
- return {
2093
- success: false,
2094
- error: "Cannot transition task status. No active task is currently running."
2095
- };
1808
+ const result = await this.hookManager.transitionActiveTaskStatus(action, color);
1809
+ if (result.success) {
1810
+ this.activeTaskAction = action;
1811
+ this.activeTaskColor = color || this.activeTaskColor;
2096
1812
  }
2097
- // Execute hook if configured
2098
- const settings = getSettingsManager();
2099
- const hookPath = settings.getTaskApprovalHook();
2100
- if (hookPath) {
2101
- const hookResult = await executeOperationHook(hookPath, "transitionActiveTaskStatus", {
2102
- action: action,
2103
- color: color || "white"
2104
- }, 30000, false, // Task hook is not mandatory
2105
- this.getCurrentTokenCount(), this.getMaxContextSize());
2106
- // Process hook commands (MODEL, SYSTEM, ENV, BACKEND, etc.) for both approval and rejection
2107
- await this.processHookResult(hookResult);
2108
- if (!hookResult.approved) {
2109
- const reason = hookResult.reason || "Hook rejected task status transition";
2110
- return {
2111
- success: false,
2112
- error: reason
2113
- };
2114
- }
2115
- if (hookResult.timedOut) {
2116
- // Hook timed out but was auto-approved
2117
- }
2118
- }
2119
- // Store old action for system message
2120
- const oldAction = this.activeTaskAction;
2121
- // Update the action and color
2122
- this.activeTaskAction = action;
2123
- this.activeTaskColor = color || this.activeTaskColor;
2124
- // Add system message
2125
- const colorStr = this.activeTaskColor && this.activeTaskColor !== "white" ? ` (${this.activeTaskColor})` : "";
2126
- this.messages.push({
2127
- role: 'system',
2128
- content: `Assistant changed task status for "${this.activeTask}" from ${oldAction} to ${this.activeTaskAction}${colorStr}`
2129
- });
2130
- // Emit event
2131
- this.emit('activeTaskChange', {
2132
- activeTask: this.activeTask,
2133
- action: this.activeTaskAction,
2134
- color: this.activeTaskColor
2135
- });
2136
- return { success: true };
1813
+ return result;
2137
1814
  }
1815
+ /**
1816
+ * Stop the current active task with reason and documentation.
1817
+ *
1818
+ * Executes the task stop hook if configured and clears the agent's
1819
+ * task state on success.
1820
+ *
1821
+ * @param reason - Reason for stopping the task
1822
+ * @param documentationFile - Path to documentation file
1823
+ * @param color - Optional display color (defaults to "white")
1824
+ * @returns Promise resolving to success/error result
1825
+ */
2138
1826
  async stopActiveTask(reason, documentationFile, color) {
2139
- // Cannot stop if no active task
2140
- if (!this.activeTask) {
2141
- return {
2142
- success: false,
2143
- error: "Cannot stop task. No active task is currently running."
2144
- };
2145
- }
2146
- // Record the start time for 3-second minimum
2147
- const startTime = Date.now();
2148
- // Execute hook if configured
2149
- const settings = getSettingsManager();
2150
- const hookPath = settings.getTaskApprovalHook();
2151
- if (hookPath) {
2152
- const hookResult = await executeOperationHook(hookPath, "stopActiveTask", {
2153
- reason: reason,
2154
- documentation_file: documentationFile,
2155
- color: color || "white"
2156
- }, 30000, false, // Task hook is not mandatory
2157
- this.getCurrentTokenCount(), this.getMaxContextSize());
2158
- // Process hook commands (MODEL, SYSTEM, ENV, BACKEND, etc.) for both approval and rejection
2159
- await this.processHookResult(hookResult);
2160
- if (!hookResult.approved) {
2161
- const hookReason = hookResult.reason || "Hook rejected task stop";
2162
- return {
2163
- success: false,
2164
- error: hookReason
2165
- };
2166
- }
2167
- if (hookResult.timedOut) {
2168
- // Hook timed out but was auto-approved
2169
- }
2170
- }
2171
- // Calculate remaining time to meet 3-second minimum
2172
- const elapsed = Date.now() - startTime;
2173
- const minimumDelay = MINIMUM_STOP_TASK_DELAY_MS;
2174
- const remainingDelay = Math.max(0, minimumDelay - elapsed);
2175
- // Wait for remaining time if needed
2176
- if (remainingDelay > 0) {
2177
- await new Promise(resolve => setTimeout(resolve, remainingDelay));
1827
+ const result = await this.hookManager.stopActiveTask(reason, documentationFile, color);
1828
+ if (result.success) {
1829
+ this.activeTask = "";
1830
+ this.activeTaskAction = "";
1831
+ this.activeTaskColor = "white";
2178
1832
  }
2179
- // Store task info for system message before clearing
2180
- const stoppedTask = this.activeTask;
2181
- const stoppedAction = this.activeTaskAction;
2182
- // Clear the task
2183
- this.activeTask = "";
2184
- this.activeTaskAction = "";
2185
- this.activeTaskColor = "white";
2186
- // Add system message
2187
- const colorStr = color && color !== "white" ? ` (${color})` : "";
2188
- this.messages.push({
2189
- role: 'system',
2190
- content: `Assistant stopped task "${stoppedTask}" (was ${stoppedAction}) with reason: ${reason}${colorStr}`
2191
- });
2192
- // Emit event to clear widget
2193
- this.emit('activeTaskChange', {
2194
- activeTask: "",
2195
- action: "",
2196
- color: "white"
2197
- });
2198
- return { success: true };
2199
- }
2200
- async emitContextChange() {
2201
- const percent = this.getContextUsagePercent();
2202
- this.emit('contextChange', {
2203
- current: this.getCurrentTokenCount(),
2204
- max: this.getMaxContextSize(),
2205
- percent
2206
- });
2207
- // Add system warnings based on context usage (may auto-clear at 100%)
2208
- await this.addContextWarningIfNeeded(percent);
1833
+ return result;
2209
1834
  }
2210
- async addContextWarningIfNeeded(percent) {
2211
- let warning = null;
2212
- const roundedPercent = Math.round(percent);
2213
- if (percent >= 100) {
2214
- // Auto-clear at 100%+ to prevent exceeding context limits
2215
- warning = `CONTEXT LIMIT REACHED: You are at ${roundedPercent}% context capacity! Automatically clearing cache to prevent context overflow...`;
2216
- this.messages.push({
2217
- role: 'system',
2218
- content: warning
2219
- });
2220
- // Perform automatic cache clear
2221
- await this.clearCache();
2222
- return;
2223
- }
2224
- if (percent >= 95) {
2225
- // Very stern warning at 95%+ (every time)
2226
- 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.`;
2227
- }
2228
- else if (percent >= 90 && !this.contextWarningAt90) {
2229
- // Dire warning at 90% (one time only)
2230
- this.contextWarningAt90 = true;
2231
- warning = `URGENT CONTEXT WARNING: You are at ${roundedPercent}% context capacity! Perform your final tasks or responses and prepare to be reset.`;
2232
- }
2233
- else if (percent >= 80 && !this.contextWarningAt80) {
2234
- // Initial warning at 80% (one time only)
2235
- this.contextWarningAt80 = true;
2236
- warning = `Context Warning: You are at ${roundedPercent}% context capacity! You are approaching the limit. Be concise and avoid lengthy outputs.`;
2237
- }
2238
- if (warning) {
2239
- // Add as a system message
2240
- this.messages.push({
2241
- role: 'system',
2242
- content: warning
2243
- });
2244
- }
1835
+ /**
1836
+ * Delegation method for hook processing (used by ToolExecutor).
1837
+ *
1838
+ * Processes hook results through the HookManager to handle commands,
1839
+ * variable transformations, and other hook-specific logic.
1840
+ *
1841
+ * @param hookResult - Result object from hook execution
1842
+ * @param envKey - Optional environment key for variable transformation
1843
+ * @returns Promise resolving to processing result
1844
+ */
1845
+ async processHookResult(hookResult, envKey) {
1846
+ return await this.hookManager['processHookResult'](hookResult, envKey);
2245
1847
  }
1848
+ /**
1849
+ * Execute a shell command through the ZSH tool.
1850
+ *
1851
+ * @param command - Shell command to execute
1852
+ * @param skipConfirmation - Whether to skip confirmation prompts
1853
+ * @returns Promise resolving to tool execution result
1854
+ */
2246
1855
  async executeCommand(command, skipConfirmation = false) {
2247
1856
  return await this.zsh.execute(command, 30000, skipConfirmation);
2248
1857
  }
1858
+ /**
1859
+ * Get the current LLM model name.
1860
+ * @returns Current model identifier
1861
+ */
2249
1862
  getCurrentModel() {
2250
1863
  return this.llmClient.getCurrentModel();
2251
1864
  }
1865
+ /**
1866
+ * Set a new LLM model and update related components.
1867
+ *
1868
+ * This method:
1869
+ * - Updates the LLM client model
1870
+ * - Resets vision support flag
1871
+ * - Updates the token counter for the new model
1872
+ * - Handles model name suffixes (e.g., :nothinking)
1873
+ *
1874
+ * @param model - New model identifier
1875
+ */
2252
1876
  setModel(model) {
2253
1877
  this.llmClient.setModel(model);
2254
1878
  // Reset supportsVision flag for new model
@@ -2262,308 +1886,36 @@ export class LLMAgent extends EventEmitter {
2262
1886
  this.tokenCounter = createTokenCounter(cleanModel);
2263
1887
  }
2264
1888
  /**
2265
- * Strip in-progress tool calls from messages for backend/model testing
2266
- * Removes tool_calls from the last assistant message and any corresponding tool results
2267
- * @returns Cleaned copy of messages array, or original if no stripping needed
2268
- */
2269
- static stripInProgressToolCalls(messages) {
2270
- // Find the last assistant message
2271
- let lastAssistantIndex = -1;
2272
- for (let i = messages.length - 1; i >= 0; i--) {
2273
- if (messages[i].role === 'assistant') {
2274
- lastAssistantIndex = i;
2275
- break;
2276
- }
2277
- }
2278
- // If no assistant message or it has no tool_calls, return original
2279
- if (lastAssistantIndex === -1 || !messages[lastAssistantIndex].tool_calls) {
2280
- return messages;
2281
- }
2282
- // Create deep copy to avoid modifying original
2283
- const cleanedMessages = JSON.parse(JSON.stringify(messages));
2284
- // Collect tool_call_ids from the last assistant message
2285
- const toolCallIds = new Set((cleanedMessages[lastAssistantIndex].tool_calls || []).map((tc) => tc.id));
2286
- // Remove tool_calls from the last assistant message
2287
- delete cleanedMessages[lastAssistantIndex].tool_calls;
2288
- // Remove any tool result messages that correspond to those tool_call_ids
2289
- // (in case some finished but not all)
2290
- return cleanedMessages.filter((msg, idx) => {
2291
- if (idx <= lastAssistantIndex) {
2292
- return true; // Keep all messages before and including the assistant message
2293
- }
2294
- if (msg.role === 'tool' && toolCallIds.has(msg.tool_call_id)) {
2295
- return false; // Remove tool results for the in-progress tool calls
2296
- }
2297
- return true;
2298
- });
2299
- }
2300
- /**
2301
- * Test a model change by making a test API call with current conversation context
2302
- * Rolls back to previous model if test fails
2303
- * @param newModel Model to test
2304
- * @returns Promise with success status and optional error message
2305
- */
2306
- async testModel(newModel) {
2307
- const previousModel = this.getCurrentModel();
2308
- const previousTokenCounter = this.tokenCounter;
2309
- // Strip in-progress tool calls to avoid sending incomplete assistant messages
2310
- const testMessages = LLMAgent.stripInProgressToolCalls(this.messages);
2311
- // Build request payload for logging
2312
- const supportsTools = this.llmClient.getSupportsTools();
2313
- const tools = supportsTools ? await getAllLLMTools() : [];
2314
- const requestPayload = {
2315
- model: newModel,
2316
- messages: testMessages,
2317
- tools: supportsTools && tools.length > 0 ? tools : undefined,
2318
- temperature: this.temperature,
2319
- max_tokens: 10
2320
- };
2321
- try {
2322
- // Temporarily set the new model
2323
- this.llmClient.setModel(newModel);
2324
- this.tokenCounter = createTokenCounter(newModel);
2325
- // Test with actual conversation context to verify the model can handle it
2326
- // This catches issues like ollama models that fail to parse tool calls
2327
- const response = await this.llmClient.chat(testMessages, tools, newModel, undefined, this.temperature, undefined, 10);
2328
- // Check if response is valid
2329
- if (!response || !response.choices || response.choices.length === 0) {
2330
- throw new Error("Invalid response from API");
2331
- }
2332
- // Test succeeded - keep the new model
2333
- previousTokenCounter.dispose();
2334
- return { success: true };
2335
- }
2336
- catch (error) {
2337
- // Test failed - roll back to previous model
2338
- this.llmClient.setModel(previousModel);
2339
- this.tokenCounter.dispose();
2340
- this.tokenCounter = previousTokenCounter;
2341
- // Log test failure with full request/response for debugging
2342
- const { message: logPaths } = await logApiError(requestPayload, error, { errorType: 'model-switch-test-failure', previousModel, newModel }, 'test-fail');
2343
- const errorMessage = error.message || "Unknown error during model test";
2344
- return {
2345
- success: false,
2346
- error: `Model test failed: ${errorMessage}\n${logPaths}`
2347
- };
2348
- }
2349
- }
2350
- /**
2351
- * Test backend/baseUrl/model changes by making a test API call with current conversation context
2352
- * Rolls back all changes if test fails
2353
- * @param backend Backend display name
2354
- * @param baseUrl Base URL for API calls
2355
- * @param apiKeyEnvVar Name of environment variable containing API key
2356
- * @param model Model to use (optional, uses current model if not specified)
2357
- * @returns Promise with success status and optional error message
1889
+ * Get the backend name (e.g., "grok", "openai").
1890
+ * @returns Backend identifier string
2358
1891
  */
2359
- async testBackendModelChange(backend, baseUrl, apiKeyEnvVar, model) {
2360
- const previousClient = this.llmClient;
2361
- const previousApiKeyEnvVar = this.apiKeyEnvVar;
2362
- const previousBackend = this.llmClient.getBackendName();
2363
- const previousModel = this.getCurrentModel();
2364
- let requestPayload;
2365
- let newModel;
2366
- try {
2367
- // Get API key from environment
2368
- const apiKey = process.env[apiKeyEnvVar];
2369
- if (!apiKey) {
2370
- throw new Error(`API key not found in environment variable: ${apiKeyEnvVar}`);
2371
- }
2372
- // Use current model if not specified
2373
- newModel = model || this.getCurrentModel();
2374
- // Create new client with new configuration
2375
- this.llmClient = new LLMClient(apiKey, newModel, baseUrl, backend);
2376
- // Store the API key env var name for session persistence
2377
- this.apiKeyEnvVar = apiKeyEnvVar;
2378
- // Reinitialize MCP servers since we're switching to a new backend/model
2379
- try {
2380
- const config = loadMCPConfig();
2381
- if (config.servers.length > 0) {
2382
- await initializeMCPServers();
2383
- }
2384
- }
2385
- catch (mcpError) {
2386
- console.warn("MCP reinitialization failed:", mcpError);
2387
- }
2388
- // Strip in-progress tool calls to avoid sending incomplete assistant messages
2389
- const testMessages = LLMAgent.stripInProgressToolCalls(this.messages);
2390
- // Build request payload for logging
2391
- const supportsTools = this.llmClient.getSupportsTools();
2392
- const tools = supportsTools ? await getAllLLMTools() : [];
2393
- requestPayload = {
2394
- backend,
2395
- baseUrl,
2396
- model: newModel,
2397
- messages: testMessages,
2398
- tools: supportsTools && tools.length > 0 ? tools : undefined,
2399
- temperature: this.temperature,
2400
- max_tokens: 10
2401
- };
2402
- // Test with actual conversation context to verify the backend/model can handle it
2403
- // This catches issues like ollama models that fail to parse tool calls
2404
- const response = await this.llmClient.chat(testMessages, tools, newModel, undefined, this.temperature, undefined, 10);
2405
- // Check if response is valid
2406
- if (!response || !response.choices || response.choices.length === 0) {
2407
- throw new Error("Invalid response from API");
2408
- }
2409
- // Test succeeded - new client is now active
2410
- return { success: true };
2411
- }
2412
- catch (error) {
2413
- // Test failed - restore previous client and API key env var
2414
- this.llmClient = previousClient;
2415
- this.apiKeyEnvVar = previousApiKeyEnvVar;
2416
- // Log test failure with full request/response for debugging (if we got far enough to build the payload)
2417
- let logPaths = '';
2418
- if (requestPayload) {
2419
- const result = await logApiError(requestPayload, error, {
2420
- errorType: 'backend-switch-test-failure',
2421
- previousBackend,
2422
- previousModel,
2423
- newBackend: backend,
2424
- newModel,
2425
- baseUrl,
2426
- apiKeyEnvVar
2427
- }, 'test-fail');
2428
- logPaths = result.message;
2429
- }
2430
- const errorMessage = error.message || "Unknown error during backend/model test";
2431
- return {
2432
- success: false,
2433
- error: logPaths ? `${errorMessage}\n${logPaths}` : errorMessage
2434
- };
2435
- }
2436
- }
2437
- /**
2438
- * Process hook result including commands and transformations
2439
- * Handles ENV transformations, model/backend testing, and error messaging
2440
- * @param hookResult Hook execution result
2441
- * @param envKey Optional ENV key to check for transformation (e.g., ZDS_AI_AGENT_PERSONA)
2442
- * @returns Object with success status and transformed value (if any)
2443
- */
2444
- async processHookResult(hookResult, envKey) {
2445
- if (!hookResult.commands) {
2446
- return { success: true };
2447
- }
2448
- const results = applyHookCommands(hookResult.commands);
2449
- // Check for transformation via ENV if key provided
2450
- let transformedValue;
2451
- if (envKey && results.env[envKey]) {
2452
- transformedValue = results.env[envKey];
2453
- }
2454
- // Process commands (test model/backend, apply ENV vars, add SYSTEM messages)
2455
- const success = await this.processHookCommands(results);
2456
- return { success, transformedValue };
2457
- }
2458
- /**
2459
- * Process hook commands (MODEL, BACKEND, BASE_URL, SYSTEM, ENV)
2460
- * Handles model/backend testing and error messaging
2461
- * @param commands Hook commands from applyHookCommands()
2462
- */
2463
- async processHookCommands(commands) {
2464
- // Import the helper function
2465
- const { applyEnvVariables } = await import('../utils/hook-executor.js');
2466
- // Check if backend or model change is requested
2467
- const hasBackendChange = commands.backend && commands.baseUrl && commands.apiKeyEnvVar;
2468
- const hasModelChange = commands.model;
2469
- // Test backend/model changes FIRST before applying anything
2470
- if (hasBackendChange) {
2471
- // Backend change - test backend/baseUrl/model together
2472
- const testResult = await this.testBackendModelChange(commands.backend, commands.baseUrl, commands.apiKeyEnvVar, commands.model);
2473
- if (!testResult.success) {
2474
- // Test failed - don't apply ANYTHING
2475
- const parts = [];
2476
- if (commands.backend)
2477
- parts.push(`backend to "${commands.backend}"`);
2478
- if (commands.model)
2479
- parts.push(`model to "${commands.model}"`);
2480
- const errorMsg = `Failed to change ${parts.join(' and ')}: ${testResult.error}`;
2481
- // Note: Don't add to this.messages during tool execution - only chatHistory
2482
- this.chatHistory.push({
2483
- type: "system",
2484
- content: errorMsg,
2485
- timestamp: new Date(),
2486
- });
2487
- return false; // Signal failure - caller should not apply other changes
2488
- }
2489
- // Test succeeded - apply ENV variables and add success message
2490
- applyEnvVariables(commands.env);
2491
- const parts = [];
2492
- if (commands.backend)
2493
- parts.push(`backend to "${commands.backend}"`);
2494
- if (commands.model)
2495
- parts.push(`model to "${commands.model}"`);
2496
- const successMsg = `Changed ${parts.join(' and ')}`;
2497
- // Note: Don't add to this.messages during tool execution - only chatHistory
2498
- this.chatHistory.push({
2499
- type: "system",
2500
- content: successMsg,
2501
- timestamp: new Date(),
2502
- });
2503
- // Emit events for UI updates
2504
- if (commands.backend) {
2505
- this.emit('backendChange', {
2506
- backend: commands.backend
2507
- });
2508
- }
2509
- if (commands.model) {
2510
- this.emit('modelChange', {
2511
- model: commands.model
2512
- });
2513
- }
2514
- }
2515
- else if (hasModelChange) {
2516
- // Model-only change
2517
- const testResult = await this.testModel(commands.model);
2518
- if (!testResult.success) {
2519
- // Test failed - don't apply ANYTHING
2520
- const errorMsg = `Failed to change model to "${commands.model}": ${testResult.error}`;
2521
- // Note: Don't add to this.messages during tool execution - only chatHistory
2522
- this.chatHistory.push({
2523
- type: "system",
2524
- content: errorMsg,
2525
- timestamp: new Date(),
2526
- });
2527
- return false; // Signal failure - caller should not apply other changes
2528
- }
2529
- // Test succeeded - apply ENV variables and add success message
2530
- applyEnvVariables(commands.env);
2531
- const successMsg = `Model changed to "${commands.model}"`;
2532
- // Note: Don't add to this.messages during tool execution - only chatHistory
2533
- this.chatHistory.push({
2534
- type: "system",
2535
- content: successMsg,
2536
- timestamp: new Date(),
2537
- });
2538
- // Emit modelChange event for UI updates
2539
- this.emit('modelChange', {
2540
- model: commands.model
2541
- });
2542
- }
2543
- else {
2544
- // No model or backend change - just apply ENV variables
2545
- applyEnvVariables(commands.env);
2546
- }
2547
- // Add SYSTEM message if present
2548
- if (commands.system) {
2549
- // Note: Don't add to this.messages during tool execution - only chatHistory
2550
- this.chatHistory.push({
2551
- type: "system",
2552
- content: commands.system,
2553
- timestamp: new Date(),
2554
- });
2555
- }
2556
- return true; // Signal success - caller can apply other changes
2557
- }
2558
1892
  getBackend() {
2559
1893
  // Just return the backend name from the client (no detection)
2560
1894
  return this.llmClient.getBackendName();
2561
1895
  }
1896
+ /**
1897
+ * Abort the current operation if one is in progress.
1898
+ *
1899
+ * This will cancel streaming responses and tool execution.
1900
+ */
2562
1901
  abortCurrentOperation() {
2563
1902
  if (this.abortController) {
2564
1903
  this.abortController.abort();
2565
1904
  }
2566
1905
  }
1906
+ /**
1907
+ * Clear the conversation cache and reinitialize the agent.
1908
+ *
1909
+ * This method:
1910
+ * - Backs up current conversation to timestamped files
1911
+ * - Clears chat history and messages
1912
+ * - Resets context warnings and processing flags
1913
+ * - Re-executes startup and instance hooks
1914
+ * - Saves the cleared state
1915
+ * - Emits context change events
1916
+ *
1917
+ * Used when context becomes too large or user requests a fresh start.
1918
+ */
2567
1919
  async clearCache() {
2568
1920
  const { ChatHistoryManager } = await import("../utils/chat-history-manager.js");
2569
1921
  const { executeStartupHook } = await import("../utils/startup-hook.js");
@@ -2574,8 +1926,7 @@ export class LLMAgent extends EventEmitter {
2574
1926
  // Clear the context
2575
1927
  this.chatHistory = [];
2576
1928
  this.messages = [];
2577
- this.contextWarningAt80 = false;
2578
- this.contextWarningAt90 = false;
1929
+ this.contextManager.resetContextWarnings();
2579
1930
  this.firstMessageProcessed = false;
2580
1931
  // Add temporary system message (will be replaced by initialize())
2581
1932
  this.messages.push({
@@ -2612,211 +1963,55 @@ export class LLMAgent extends EventEmitter {
2612
1963
  // Note: Intentionally NOT calling addContextWarningIfNeeded here to prevent recursion
2613
1964
  }
2614
1965
  /**
2615
- * Get current session state for persistence
1966
+ * Get current session state for persistence.
1967
+ *
1968
+ * Collects all session-related state including:
1969
+ * - Model and backend configuration
1970
+ * - Persona, mood, and task settings
1971
+ * - Context usage statistics
1972
+ * - API key environment variable
1973
+ *
1974
+ * @returns Complete session state object
2616
1975
  */
2617
1976
  getSessionState() {
2618
- return {
2619
- session: process.env.ZDS_AI_AGENT_SESSION || "",
2620
- persona: this.persona,
2621
- personaColor: this.personaColor,
2622
- mood: this.mood,
2623
- moodColor: this.moodColor,
2624
- activeTask: this.activeTask,
2625
- activeTaskAction: this.activeTaskAction,
2626
- activeTaskColor: this.activeTaskColor,
2627
- cwd: process.cwd(),
2628
- contextCurrent: this.getCurrentTokenCount(),
2629
- contextMax: this.getMaxContextSize(),
2630
- backend: this.llmClient.getBackendName(),
2631
- baseUrl: this.llmClient.getBaseURL(),
2632
- apiKeyEnvVar: this.apiKeyEnvVar,
2633
- model: this.getCurrentModel(),
2634
- supportsVision: this.llmClient.getSupportsVision(),
2635
- };
1977
+ const state = this.sessionManager.getSessionState();
1978
+ // Add context values that SessionManager can't access
1979
+ state.contextCurrent = this.getCurrentTokenCount();
1980
+ state.contextMax = this.getMaxContextSize();
1981
+ return state;
2636
1982
  }
2637
1983
  /**
2638
- * Restore session state from persistence
1984
+ * Restore session state from persistence.
1985
+ *
1986
+ * Restores all session-related state including:
1987
+ * - Model and backend configuration
1988
+ * - Persona, mood, and task settings
1989
+ * - Token counter and API client setup
1990
+ *
1991
+ * @param state - Session state to restore
2639
1992
  */
2640
1993
  async restoreSessionState(state) {
2641
- // Restore session ID
2642
- if (state.session) {
2643
- process.env.ZDS_AI_AGENT_SESSION = state.session;
2644
- }
2645
- // Restore cwd early (hooks may need correct working directory)
2646
- if (state.cwd) {
2647
- try {
2648
- const fs = await import('fs');
2649
- // Only attempt to change directory if it exists
2650
- if (fs.existsSync(state.cwd)) {
2651
- process.chdir(state.cwd);
2652
- }
2653
- // Silently skip if directory doesn't exist (common in containerized environments)
2654
- }
2655
- catch (error) {
2656
- // Silently skip on any error - working directory restoration is non-critical
2657
- }
2658
- }
2659
- // Restore backend/baseUrl/apiKeyEnvVar/model if present (creates initial client)
2660
- if (state.backend && state.baseUrl && state.apiKeyEnvVar) {
2661
- try {
2662
- // Get API key from environment
2663
- const apiKey = process.env[state.apiKeyEnvVar];
2664
- if (apiKey) {
2665
- // Create new client with restored configuration
2666
- const model = state.model || this.getCurrentModel();
2667
- this.llmClient = new LLMClient(apiKey, model, state.baseUrl, state.backend);
2668
- this.apiKeyEnvVar = state.apiKeyEnvVar;
2669
- // Restore supportsVision flag if present
2670
- if (state.supportsVision !== undefined) {
2671
- this.llmClient.setSupportsVision(state.supportsVision);
2672
- }
2673
- // Reinitialize MCP servers when restoring session
2674
- try {
2675
- const config = loadMCPConfig();
2676
- if (config.servers.length > 0) {
2677
- await initializeMCPServers();
2678
- }
2679
- }
2680
- catch (mcpError) {
2681
- console.warn("MCP reinitialization failed:", mcpError);
2682
- }
2683
- // Dispose old token counter and create new one for the restored model
2684
- this.tokenCounter.dispose();
2685
- this.tokenCounter = createTokenCounter(model);
2686
- // Emit events for UI updates
2687
- this.emit('backendChange', { backend: state.backend });
2688
- this.emit('modelChange', { model });
2689
- }
2690
- else {
2691
- console.warn("Failed to restore backend: API key not found in environment.");
2692
- }
2693
- }
2694
- catch (error) {
2695
- console.warn(`Failed to restore backend configuration:`, error);
2696
- }
2697
- }
2698
- // Restore persona (hook may change backend/model and sets env vars)
2699
- if (state.persona) {
2700
- try {
2701
- const result = await this.setPersona(state.persona, state.personaColor);
2702
- if (!result.success) {
2703
- // If persona hook failed (e.g., backend test failed), still set the persona values
2704
- // but don't change backend/model. This prevents losing persona state on transitory errors.
2705
- console.warn(`Persona hook failed, setting persona without backend change: ${result.error}`);
2706
- this.persona = state.persona;
2707
- this.personaColor = state.personaColor;
2708
- process.env.ZDS_AI_AGENT_PERSONA = state.persona;
2709
- }
2710
- }
2711
- catch (error) {
2712
- console.warn(`Failed to restore persona "${state.persona}":`, error);
2713
- // Still set persona values even if hook crashed
2714
- this.persona = state.persona;
2715
- this.personaColor = state.personaColor;
2716
- process.env.ZDS_AI_AGENT_PERSONA = state.persona;
2717
- }
2718
- }
2719
- // Restore mood (hook sets env vars)
2720
- if (state.mood) {
2721
- try {
2722
- const result = await this.setMood(state.mood, state.moodColor);
2723
- if (!result.success) {
2724
- // If mood hook failed (e.g., backend test failed), still set the mood values
2725
- // but don't change backend/model. This prevents losing mood state on transitory errors.
2726
- console.warn(`Mood hook failed, setting mood without backend change: ${result.error}`);
2727
- this.mood = state.mood;
2728
- this.moodColor = state.moodColor;
2729
- process.env.ZDS_AI_AGENT_MOOD = state.mood;
2730
- }
2731
- }
2732
- catch (error) {
2733
- console.warn(`Failed to restore mood "${state.mood}":`, error);
2734
- // Still set mood values even if hook crashed
2735
- this.mood = state.mood;
2736
- this.moodColor = state.moodColor;
2737
- process.env.ZDS_AI_AGENT_MOOD = state.mood;
2738
- }
2739
- }
2740
- // Restore active task (hook sets env vars)
2741
- if (state.activeTask) {
2742
- try {
2743
- const result = await this.startActiveTask(state.activeTask, state.activeTaskAction, state.activeTaskColor);
2744
- if (!result.success) {
2745
- // If task hook failed (e.g., backend test failed), still set the task values
2746
- // but don't change backend/model. This prevents losing task state on transitory errors.
2747
- console.warn(`Task hook failed, setting active task without backend change: ${result.error}`);
2748
- this.activeTask = state.activeTask;
2749
- this.activeTaskAction = state.activeTaskAction;
2750
- this.activeTaskColor = state.activeTaskColor;
2751
- process.env.ZDS_AI_AGENT_ACTIVE_TASK = state.activeTask;
2752
- process.env.ZDS_AI_AGENT_ACTIVE_TASK_ACTION = state.activeTaskAction;
2753
- }
2754
- }
2755
- catch (error) {
2756
- console.warn(`Failed to restore active task "${state.activeTask}":`, error);
2757
- // Still set task values even if hook crashed
2758
- this.activeTask = state.activeTask;
2759
- this.activeTaskAction = state.activeTaskAction;
2760
- this.activeTaskColor = state.activeTaskColor;
2761
- process.env.ZDS_AI_AGENT_ACTIVE_TASK = state.activeTask;
2762
- process.env.ZDS_AI_AGENT_ACTIVE_TASK_ACTION = state.activeTaskAction;
2763
- }
2764
- }
1994
+ return await this.sessionManager.restoreSessionState(state);
2765
1995
  }
2766
1996
  /**
2767
- * Compact conversation context by keeping system prompt and last N messages
2768
- * Reduces context size when it grows too large for backend to handle
1997
+ * Compact conversation context by keeping system prompt and last N messages.
1998
+ *
1999
+ * Reduces context size when it grows too large for the backend to handle.
2000
+ * Removes older messages while preserving the system prompt and recent context.
2001
+ *
2002
+ * @param keepLastMessages - Number of recent messages to keep (default: 20)
2769
2003
  * @returns Number of messages removed
2770
2004
  */
2771
2005
  compactContext(keepLastMessages = 20) {
2772
- if (this.chatHistory.length <= keepLastMessages) {
2773
- // Nothing to compact
2774
- return 0;
2775
- }
2776
- const removedCount = this.chatHistory.length - keepLastMessages;
2777
- const keptMessages = this.chatHistory.slice(-keepLastMessages);
2778
- // Clear both arrays
2779
- this.chatHistory = keptMessages;
2780
- this.messages = [];
2781
- // Add system message noting the compaction
2782
- const compactionNote = {
2783
- type: 'system',
2784
- content: `Context compacted: removed ${removedCount} older messages, keeping last ${keepLastMessages} messages.`,
2785
- timestamp: new Date()
2786
- };
2787
- this.chatHistory.push(compactionNote);
2788
- // Rebuild this.messages from compacted chatHistory
2789
- for (const entry of this.chatHistory) {
2790
- if (entry.type === 'system') {
2791
- this.messages.push({
2792
- role: 'system',
2793
- content: entry.content
2794
- });
2795
- }
2796
- else if (entry.type === 'user') {
2797
- this.messages.push({
2798
- role: 'user',
2799
- content: entry.content
2800
- });
2801
- }
2802
- else if (entry.type === 'assistant') {
2803
- this.messages.push({
2804
- role: 'assistant',
2805
- content: entry.content
2806
- });
2807
- }
2808
- else if (entry.type === 'tool_result') {
2809
- this.messages.push({
2810
- role: 'tool',
2811
- tool_call_id: entry.toolResult.output || '',
2812
- content: JSON.stringify(entry.toolResult)
2813
- });
2814
- }
2815
- }
2816
- return removedCount;
2006
+ return this.contextManager.compactContext(keepLastMessages);
2817
2007
  }
2818
2008
  /**
2819
- * Get all tool instances and their class names for display purposes
2009
+ * Get all tool instances and their class names for display purposes.
2010
+ *
2011
+ * Uses reflection to find all tool instances and extract their
2012
+ * class names and handled method names for introspection.
2013
+ *
2014
+ * @returns Array of tool info objects with class names and methods
2820
2015
  */
2821
2016
  getToolClassInfo() {
2822
2017
  const toolInstances = this.getToolInstances();
@@ -2826,7 +2021,13 @@ export class LLMAgent extends EventEmitter {
2826
2021
  }));
2827
2022
  }
2828
2023
  /**
2829
- * Get all tool instances via reflection
2024
+ * Get all tool instances via reflection.
2025
+ *
2026
+ * Scans all properties of the agent instance to find objects that
2027
+ * implement the tool interface (have getHandledToolNames method).
2028
+ *
2029
+ * @returns Array of tool instances with their class names
2030
+ * @private
2830
2031
  */
2831
2032
  getToolInstances() {
2832
2033
  const instances = [];