aiden-runtime 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +465 -0
  3. package/config/devos.config.json +186 -0
  4. package/config/hardware.json +9 -0
  5. package/config/model-selection.json +7 -0
  6. package/config/setup-complete.json +20 -0
  7. package/dist/api/routes/computerUse.js +112 -0
  8. package/dist/api/server.js +6870 -0
  9. package/dist/bin/npx-init.js +71 -0
  10. package/dist/coordination/commandGate.js +115 -0
  11. package/dist/coordination/livePulse.js +127 -0
  12. package/dist/core/agentLoop.js +2718 -0
  13. package/dist/core/agentShield.js +231 -0
  14. package/dist/core/aidenIdentity.js +215 -0
  15. package/dist/core/aidenPersonality.js +166 -0
  16. package/dist/core/aidenSdk.js +374 -0
  17. package/dist/core/asyncTasks.js +82 -0
  18. package/dist/core/auditTrail.js +61 -0
  19. package/dist/core/auxiliaryClient.js +114 -0
  20. package/dist/core/bgLLM.js +108 -0
  21. package/dist/core/bm25.js +68 -0
  22. package/dist/core/callbackSystem.js +64 -0
  23. package/dist/core/channels/adapter.js +6 -0
  24. package/dist/core/channels/discord.js +173 -0
  25. package/dist/core/channels/email.js +253 -0
  26. package/dist/core/channels/imessage.js +164 -0
  27. package/dist/core/channels/manager.js +96 -0
  28. package/dist/core/channels/signal.js +140 -0
  29. package/dist/core/channels/slack.js +139 -0
  30. package/dist/core/channels/twilio.js +144 -0
  31. package/dist/core/channels/webhook.js +186 -0
  32. package/dist/core/channels/whatsapp.js +185 -0
  33. package/dist/core/clarifyBus.js +75 -0
  34. package/dist/core/codeInterpreter.js +82 -0
  35. package/dist/core/computerControl.js +439 -0
  36. package/dist/core/conversationMemory.js +334 -0
  37. package/dist/core/costTracker.js +221 -0
  38. package/dist/core/cronManager.js +217 -0
  39. package/dist/core/deepKB.js +77 -0
  40. package/dist/core/doctor.js +279 -0
  41. package/dist/core/dreamEngine.js +334 -0
  42. package/dist/core/entityGraph.js +169 -0
  43. package/dist/core/eventBus.js +16 -0
  44. package/dist/core/evolutionAnalyzer.js +153 -0
  45. package/dist/core/executionLoop.js +309 -0
  46. package/dist/core/executor.js +224 -0
  47. package/dist/core/failureAnalyzer.js +166 -0
  48. package/dist/core/fastPathExpansion.js +82 -0
  49. package/dist/core/faultEngine.js +106 -0
  50. package/dist/core/featureGates.js +70 -0
  51. package/dist/core/fileIngestion.js +113 -0
  52. package/dist/core/gateway.js +97 -0
  53. package/dist/core/goalTracker.js +75 -0
  54. package/dist/core/growthEngine.js +168 -0
  55. package/dist/core/hardwareDetector.js +98 -0
  56. package/dist/core/hooks.js +45 -0
  57. package/dist/core/httpKeepalive.js +46 -0
  58. package/dist/core/hybridSearch.js +101 -0
  59. package/dist/core/importers.js +164 -0
  60. package/dist/core/instinctSystem.js +223 -0
  61. package/dist/core/knowledgeBase.js +351 -0
  62. package/dist/core/learningMemory.js +121 -0
  63. package/dist/core/lessonsBrowser.js +125 -0
  64. package/dist/core/licenseManager.js +399 -0
  65. package/dist/core/logBuffer.js +85 -0
  66. package/dist/core/machineId.js +87 -0
  67. package/dist/core/mcpClient.js +442 -0
  68. package/dist/core/memoryDistiller.js +165 -0
  69. package/dist/core/memoryExtractor.js +212 -0
  70. package/dist/core/memoryIds.js +213 -0
  71. package/dist/core/memoryPreamble.js +113 -0
  72. package/dist/core/memoryQuery.js +136 -0
  73. package/dist/core/memoryRecall.js +140 -0
  74. package/dist/core/memoryStrategy.js +201 -0
  75. package/dist/core/messageValidator.js +85 -0
  76. package/dist/core/modelDiscovery.js +108 -0
  77. package/dist/core/modelRouter.js +118 -0
  78. package/dist/core/morningBriefing.js +203 -0
  79. package/dist/core/multiGoalValidator.js +51 -0
  80. package/dist/core/parallelExecutor.js +43 -0
  81. package/dist/core/passiveSkillObserver.js +204 -0
  82. package/dist/core/paths.js +57 -0
  83. package/dist/core/patternDetector.js +83 -0
  84. package/dist/core/planResponseRepair.js +64 -0
  85. package/dist/core/planTool.js +111 -0
  86. package/dist/core/playwrightBridge.js +356 -0
  87. package/dist/core/pluginSystem.js +121 -0
  88. package/dist/core/privateMode.js +85 -0
  89. package/dist/core/reactLoop.js +156 -0
  90. package/dist/core/recipeEngine.js +166 -0
  91. package/dist/core/responseCache.js +128 -0
  92. package/dist/core/runSandbox.js +132 -0
  93. package/dist/core/sandboxRunner.js +200 -0
  94. package/dist/core/scheduler.js +543 -0
  95. package/dist/core/secretScanner.js +49 -0
  96. package/dist/core/semanticMemory.js +223 -0
  97. package/dist/core/sessionMemory.js +259 -0
  98. package/dist/core/sessionRouter.js +91 -0
  99. package/dist/core/sessionSearch.js +163 -0
  100. package/dist/core/setupWizard.js +225 -0
  101. package/dist/core/skillImporter.js +303 -0
  102. package/dist/core/skillLibrary.js +144 -0
  103. package/dist/core/skillLoader.js +471 -0
  104. package/dist/core/skillTeacher.js +352 -0
  105. package/dist/core/skillValidator.js +210 -0
  106. package/dist/core/skillWriter.js +384 -0
  107. package/dist/core/slashAsTool.js +226 -0
  108. package/dist/core/spawnManager.js +197 -0
  109. package/dist/core/statusVerbs.js +43 -0
  110. package/dist/core/swarmManager.js +109 -0
  111. package/dist/core/taskQueue.js +119 -0
  112. package/dist/core/taskRecovery.js +128 -0
  113. package/dist/core/taskState.js +168 -0
  114. package/dist/core/telegramBot.js +152 -0
  115. package/dist/core/todoManager.js +70 -0
  116. package/dist/core/toolNameRepair.js +71 -0
  117. package/dist/core/toolRegistry.js +2730 -0
  118. package/dist/core/tools/calendarTool.js +98 -0
  119. package/dist/core/tools/companyFilingsTool.js +98 -0
  120. package/dist/core/tools/gmailTool.js +87 -0
  121. package/dist/core/tools/marketDataTool.js +135 -0
  122. package/dist/core/tools/socialResearchTool.js +121 -0
  123. package/dist/core/truthCheck.js +57 -0
  124. package/dist/core/updateChecker.js +74 -0
  125. package/dist/core/userCognitionProfile.js +238 -0
  126. package/dist/core/userProfile.js +341 -0
  127. package/dist/core/version.js +5 -0
  128. package/dist/core/visionAnalyze.js +161 -0
  129. package/dist/core/voice/audio.js +187 -0
  130. package/dist/core/voice/stt.js +226 -0
  131. package/dist/core/voice/tts.js +310 -0
  132. package/dist/core/voiceInput.js +118 -0
  133. package/dist/core/voiceOutput.js +130 -0
  134. package/dist/core/webSearch.js +326 -0
  135. package/dist/core/workflowTracker.js +72 -0
  136. package/dist/core/workspaceMemory.js +54 -0
  137. package/dist/core/youtubeTranscript.js +224 -0
  138. package/dist/integrations/computerUse/apiRegistry.js +113 -0
  139. package/dist/integrations/computerUse/screenAgent.js +203 -0
  140. package/dist/integrations/computerUse/visionLoop.js +296 -0
  141. package/dist/memory/memoryLayers.js +143 -0
  142. package/dist/providers/boa.js +93 -0
  143. package/dist/providers/cerebras.js +70 -0
  144. package/dist/providers/custom.js +89 -0
  145. package/dist/providers/gemini.js +82 -0
  146. package/dist/providers/groq.js +92 -0
  147. package/dist/providers/index.js +149 -0
  148. package/dist/providers/nvidia.js +70 -0
  149. package/dist/providers/ollama.js +99 -0
  150. package/dist/providers/openrouter.js +74 -0
  151. package/dist/providers/router.js +497 -0
  152. package/dist/providers/types.js +6 -0
  153. package/dist/security/browserVault.js +129 -0
  154. package/dist/security/dataGuard.js +89 -0
  155. package/dist/tools/eonetTool.js +72 -0
  156. package/dist/types/computerUse.js +2 -0
  157. package/dist/types/executor.js +2 -0
  158. package/dist-bundle/cli.js +357859 -0
  159. package/package.json +256 -0
@@ -0,0 +1,2718 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // DevOS — Autonomous AI Execution System
4
+ // Copyright (c) 2026 Shiva Deore. All rights reserved.
5
+ // ============================================================
6
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
+ if (k2 === undefined) k2 = k;
8
+ var desc = Object.getOwnPropertyDescriptor(m, k);
9
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
+ desc = { enumerable: true, get: function() { return m[k]; } };
11
+ }
12
+ Object.defineProperty(o, k2, desc);
13
+ }) : (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ o[k2] = m[k];
16
+ }));
17
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
19
+ }) : function(o, v) {
20
+ o["default"] = v;
21
+ });
22
+ var __importStar = (this && this.__importStar) || (function () {
23
+ var ownKeys = function(o) {
24
+ ownKeys = Object.getOwnPropertyNames || function (o) {
25
+ var ar = [];
26
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
+ return ar;
28
+ };
29
+ return ownKeys(o);
30
+ };
31
+ return function (mod) {
32
+ if (mod && mod.__esModule) return mod;
33
+ var result = {};
34
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
+ __setModuleDefault(result, mod);
36
+ return result;
37
+ };
38
+ })();
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.interruptCurrentCall = interruptCurrentCall;
41
+ exports.setStatusEmitter = setStatusEmitter;
42
+ exports.getBudgetState = getBudgetState;
43
+ exports.surfaceRelevantMemories = surfaceRelevantMemories;
44
+ exports.resolveTemplates = resolveTemplates;
45
+ exports.streamOpenAIResponse = streamOpenAIResponse;
46
+ exports.streamGeminiResponse = streamGeminiResponse;
47
+ exports.planWithLLM = planWithLLM;
48
+ exports.validatePlan = validatePlan;
49
+ exports.buildDependencyGroups = buildDependencyGroups;
50
+ exports.executePlan = executePlan;
51
+ exports.respondWithResults = respondWithResults;
52
+ exports.callLLM = callLLM;
53
+ exports.deepResearch = deepResearch;
54
+ // core/agentLoop.ts — 3-step agent loop:
55
+ // STEP 1: PLAN — LLM outputs JSON plan only (no execution)
56
+ // STEP 2: EXECUTE — Code runs each tool, gets real results
57
+ // STEP 3: RESPOND — LLM sees real results, streams natural language
58
+ const toolRegistry_1 = require("./toolRegistry");
59
+ const recipeEngine_1 = require("./recipeEngine");
60
+ const livePulse_1 = require("../coordination/livePulse");
61
+ const planTool_1 = require("./planTool");
62
+ const workspaceMemory_1 = require("./workspaceMemory");
63
+ const taskState_1 = require("./taskState");
64
+ const skillLoader_1 = require("./skillLoader");
65
+ const entityGraph_1 = require("./entityGraph");
66
+ const learningMemory_1 = require("./learningMemory");
67
+ const conversationMemory_1 = require("./conversationMemory");
68
+ const router_1 = require("../providers/router");
69
+ const index_1 = require("../providers/index");
70
+ const knowledgeBase_1 = require("./knowledgeBase");
71
+ const skillTeacher_1 = require("./skillTeacher");
72
+ const growthEngine_1 = require("./growthEngine");
73
+ const aidenPersonality_1 = require("./aidenPersonality");
74
+ const auditTrail_1 = require("./auditTrail");
75
+ const mcpClient_1 = require("./mcpClient");
76
+ const memoryRecall_1 = require("./memoryRecall");
77
+ const costTracker_1 = require("./costTracker");
78
+ const modelDiscovery_1 = require("./modelDiscovery");
79
+ const semanticMemory_1 = require("./semanticMemory");
80
+ const sessionMemory_1 = require("./sessionMemory");
81
+ const goalTracker_1 = require("./goalTracker");
82
+ const hooks_1 = require("./hooks");
83
+ const instinctSystem_1 = require("./instinctSystem");
84
+ const workflowTracker_1 = require("./workflowTracker");
85
+ const parallelExecutor_1 = require("./parallelExecutor");
86
+ const messageValidator_1 = require("./messageValidator");
87
+ const toolNameRepair_1 = require("./toolNameRepair");
88
+ const slashAsTool_1 = require("./slashAsTool");
89
+ const planResponseRepair_1 = require("./planResponseRepair");
90
+ const nodeFs = __importStar(require("fs"));
91
+ const nodePath = __importStar(require("path"));
92
+ const nodeOs = __importStar(require("os"));
93
+ // ── Pre-compact threshold ──────────────────────────────────────
94
+ // Fire pre_compact hook when history has this many messages
95
+ const COMPACT_THRESHOLD = 40;
96
+ // ── Interrupt / stop state ─────────────────────────────────────
97
+ let currentAbortController = null;
98
+ let executionInterrupted = false;
99
+ function interruptCurrentCall() {
100
+ executionInterrupted = true;
101
+ if (currentAbortController) {
102
+ currentAbortController.abort();
103
+ currentAbortController = null;
104
+ }
105
+ }
106
+ // ── Status emitter — set per-request by server.ts, cleared on close ──
107
+ let _emitStatus = null;
108
+ function setStatusEmitter(fn) { _emitStatus = fn; }
109
+ function emitStatus(action, detail) { _emitStatus?.(action, detail); }
110
+ const TOOL_ACTION = {
111
+ web_search: 'searching', fetch_url: 'searching', deep_research: 'searching', social_research: 'searching',
112
+ fetch_page: 'reading', file_read: 'reading', file_list: 'reading',
113
+ file_write: 'writing',
114
+ run_python: 'coding', run_node: 'coding', shell_exec: 'coding',
115
+ run_powershell: 'coding', code_interpreter_python: 'coding', code_interpreter_node: 'coding',
116
+ open_browser: 'browsing', browser_extract: 'browsing', browser_screenshot: 'browsing',
117
+ browser_click: 'browsing', browser_type: 'browsing',
118
+ };
119
+ function toolStatusDetail(tool, input) {
120
+ if (!input)
121
+ return undefined;
122
+ switch (tool) {
123
+ case 'web_search':
124
+ case 'deep_research':
125
+ case 'social_research':
126
+ return input.query ? String(input.query).slice(0, 60) : undefined;
127
+ case 'run_python':
128
+ case 'code_interpreter_python':
129
+ return 'Python script';
130
+ case 'run_node':
131
+ case 'code_interpreter_node':
132
+ return 'Node script';
133
+ case 'open_browser':
134
+ return input.url ? String(input.url).slice(0, 60) : 'browser';
135
+ case 'browser_extract':
136
+ case 'browser_screenshot':
137
+ case 'fetch_page':
138
+ case 'fetch_url':
139
+ return input.url ? String(input.url).slice(0, 60) : 'page';
140
+ case 'file_read':
141
+ case 'file_write':
142
+ case 'file_list': {
143
+ const p = input.path || input.directory || '';
144
+ return p ? (String(p).split(/[/\\]/).pop() || String(p).slice(0, 40)) : undefined;
145
+ }
146
+ case 'shell_exec':
147
+ case 'run_powershell':
148
+ return input.command ? String(input.command).slice(0, 30) : undefined;
149
+ case 'get_stocks':
150
+ return input.symbol ?? (input.type ? `${input.market ?? ''} ${input.type}`.trim() : 'stocks');
151
+ case 'get_market_data':
152
+ case 'get_company_info':
153
+ return input.symbol ? String(input.symbol) : undefined;
154
+ default:
155
+ if (input.query)
156
+ return String(input.query).slice(0, 60);
157
+ if (input.url)
158
+ return String(input.url).slice(0, 60);
159
+ if (input.path)
160
+ return String(input.path).slice(0, 40);
161
+ if (input.command)
162
+ return String(input.command).slice(0, 30);
163
+ return undefined;
164
+ }
165
+ }
166
+ function getBudgetWarning(budget) {
167
+ const usage = budget.currentIteration / budget.maxIterations;
168
+ const remaining = budget.maxIterations - budget.currentIteration;
169
+ if (usage >= budget.warningThreshold) {
170
+ return `[BUDGET WARNING: Turn ${budget.currentIteration}/${budget.maxIterations}. Only ${remaining} turn(s) left. Provide your final response NOW. Do not start new tool calls.]`;
171
+ }
172
+ if (usage >= budget.cautionThreshold) {
173
+ return `[BUDGET: Turn ${budget.currentIteration}/${budget.maxIterations}. ${remaining} turns left. Start consolidating your work and prepare a response.]`;
174
+ }
175
+ return null;
176
+ }
177
+ let _activeBudget = null;
178
+ function getBudgetState() {
179
+ if (!_activeBudget)
180
+ return null;
181
+ return {
182
+ current: _activeBudget.currentIteration,
183
+ max: _activeBudget.maxIterations,
184
+ remaining: _activeBudget.maxIterations - _activeBudget.currentIteration,
185
+ };
186
+ }
187
+ // ── Token-based preflight compression ─────────────────────────
188
+ function estimateTokens(text) {
189
+ return Math.ceil(text.length / 4);
190
+ }
191
+ function estimateConversationTokens(messages) {
192
+ return messages.reduce((sum, msg) => {
193
+ const content = typeof msg.content === 'string'
194
+ ? msg.content
195
+ : JSON.stringify(msg.content || '');
196
+ return sum + estimateTokens(content) + 4; // 4 tokens per message overhead
197
+ }, 0);
198
+ }
199
+ const MODEL_CONTEXT_LIMITS = {
200
+ 'llama-3.1-8b-instant': 8192,
201
+ 'llama-3.3-70b-versatile': 32768,
202
+ 'gemma-7b-it': 8192,
203
+ 'gemma2-9b-it': 8192,
204
+ 'mixtral-8x7b-32768': 32768,
205
+ 'deepseek-r1-distill-llama-70b': 32768,
206
+ 'qwen-2.5-72b-instruct': 32768,
207
+ 'gemini-2.0-flash': 1048576,
208
+ 'gemini-1.5-flash': 1048576,
209
+ 'gpt-4o': 128000,
210
+ 'claude-sonnet-4-20250514': 200000,
211
+ 'gemini-3-flash': 1048576,
212
+ 'gemini-3.1-pro': 1048576,
213
+ 'gpt-5.3-codex': 200000,
214
+ 'default': 8192,
215
+ };
216
+ function getContextLimit(model) {
217
+ return MODEL_CONTEXT_LIMITS[model] ?? MODEL_CONTEXT_LIMITS['default'];
218
+ }
219
+ async function flushMemoryFromMessages(messages) {
220
+ const userMessages = messages
221
+ .filter(m => m.role === 'user')
222
+ .map(m => String(m.content))
223
+ .join('\n');
224
+ if (userMessages.length > 100) {
225
+ try {
226
+ semanticMemory_1.semanticMemory.add(userMessages.slice(0, 500), 'exchange', ['preflight_compression']);
227
+ console.log('[Context] Memory flushed before compression');
228
+ }
229
+ catch {
230
+ console.log('[Context] Memory flush skipped — extractor unavailable');
231
+ }
232
+ }
233
+ }
234
+ async function preflightCompressionCheck(messages, model, sessionId) {
235
+ const tokenCount = estimateConversationTokens(messages);
236
+ const contextLimit = getContextLimit(model);
237
+ const usage = tokenCount / contextLimit;
238
+ console.log(`[Context] ${tokenCount} tokens / ${contextLimit} limit (${(usage * 100).toFixed(0)}%)`);
239
+ if (usage < 0.5) {
240
+ // Under 50% — no compression needed
241
+ return messages;
242
+ }
243
+ console.log(`[Context] Over 50% — compressing middle messages`);
244
+ // Track parent/child lineage across compressions
245
+ if (sessionId) {
246
+ try {
247
+ (0, sessionMemory_1.createChildSession)(sessionId, 'preflight_compression', messages.length, tokenCount);
248
+ }
249
+ catch {
250
+ console.log('[Context] Session lineage tracking skipped');
251
+ }
252
+ }
253
+ // Step 1: Flush memory before compressing
254
+ await flushMemoryFromMessages(messages);
255
+ // Step 2: Keep first 2 messages (system + first user) and last 10 messages
256
+ const protectedStart = messages.slice(0, 2);
257
+ const protectedEnd = messages.slice(-10);
258
+ const middleMessages = messages.slice(2, -10);
259
+ if (middleMessages.length < 3) {
260
+ return messages; // not enough to compress
261
+ }
262
+ // Step 3: Summarize middle messages into a single system message
263
+ const middleText = middleMessages
264
+ .map(m => `${m.role}: ${String(m.content).substring(0, 200)}`)
265
+ .join('\n');
266
+ const summary = {
267
+ role: 'system',
268
+ content: `[COMPRESSED CONTEXT — ${middleMessages.length} messages summarized]\n` +
269
+ `Previous conversation covered: ${middleText.substring(0, 1000)}\n` +
270
+ `[End compressed context]`,
271
+ };
272
+ const compressed = [...protectedStart, summary, ...protectedEnd];
273
+ const newTokens = estimateConversationTokens(compressed);
274
+ console.log(`[Context] Compressed: ${tokenCount} → ${newTokens} tokens ` +
275
+ `(${messages.length} → ${compressed.length} messages)`);
276
+ return compressed;
277
+ }
278
+ // ── Proactive memory surfacing ─────────────────────────────────
279
+ const SKIP_MEMORY_PATTERNS = [
280
+ /^(hi|hello|hey|thanks|ok|yes|no|sure|bye)\b/i,
281
+ /^.{1,15}$/,
282
+ ];
283
+ async function surfaceRelevantMemories(userMessage) {
284
+ if (SKIP_MEMORY_PATTERNS.some(p => p.test(userMessage.trim())))
285
+ return '';
286
+ const memories = [];
287
+ // 1. Semantic memory search
288
+ try {
289
+ const results = semanticMemory_1.semanticMemory.search(userMessage, 5);
290
+ for (const r of results) {
291
+ memories.push(`[Memory] ${r.text}`);
292
+ }
293
+ }
294
+ catch { }
295
+ // 2. Memory directory files — keyword match
296
+ try {
297
+ const memDir = nodePath.join(process.cwd(), 'workspace', 'memory');
298
+ if (nodeFs.existsSync(memDir)) {
299
+ const files = nodeFs.readdirSync(memDir).filter((f) => f.endsWith('.md'));
300
+ const keywords = userMessage.toLowerCase().split(/\s+/).filter((k) => k.length > 3);
301
+ for (const file of files) {
302
+ try {
303
+ const content = nodeFs.readFileSync(nodePath.join(memDir, file), 'utf8');
304
+ const contentLower = content.toLowerCase();
305
+ const matches = keywords.filter((k) => contentLower.includes(k));
306
+ if (matches.length >= 2) {
307
+ const body = content.split('---').slice(2).join('---').trim();
308
+ if (body.length > 0 && body.length < 500) {
309
+ memories.push(`[Memory] ${body}`);
310
+ }
311
+ }
312
+ }
313
+ catch { }
314
+ }
315
+ }
316
+ }
317
+ catch { }
318
+ if (memories.length === 0)
319
+ return '';
320
+ const unique = [...new Set(memories)].slice(0, 8);
321
+ console.log(`[Memory] Surfaced ${unique.length} memories for: "${userMessage.substring(0, 40)}"`);
322
+ return '\n## Relevant Context from Memory\n' + unique.join('\n') + '\n';
323
+ }
324
+ // ── Template resolver ──────────────────────────────────────────
325
+ // Replaces {{step_N_output}} tokens with actual step outputs
326
+ function resolveTemplates(input, stepOutputs) {
327
+ return input.replace(/\{\{step_(\d+)_output\}\}/g, (_match, n) => {
328
+ const idx = parseInt(n, 10);
329
+ return stepOutputs[idx] ?? `(step ${idx} output unavailable)`;
330
+ });
331
+ }
332
+ // ── SSE stream helpers ─────────────────────────────────────────
333
+ async function streamOpenAIResponse(res, onToken) {
334
+ if (!res.body)
335
+ return;
336
+ const reader = res.body.getReader();
337
+ const decoder = new TextDecoder();
338
+ let buf = '';
339
+ while (true) {
340
+ const { done, value } = await reader.read();
341
+ if (done)
342
+ break;
343
+ buf += decoder.decode(value, { stream: true });
344
+ const lines = buf.split('\n');
345
+ buf = lines.pop() ?? '';
346
+ for (const line of lines) {
347
+ if (!line.startsWith('data: '))
348
+ continue;
349
+ const raw = line.replace('data: ', '').trim();
350
+ if (raw === '[DONE]')
351
+ return;
352
+ try {
353
+ const parsed = JSON.parse(raw);
354
+ const token = parsed?.choices?.[0]?.delta?.content;
355
+ if (token)
356
+ onToken(token);
357
+ }
358
+ catch { }
359
+ }
360
+ }
361
+ }
362
+ async function streamGeminiResponse(res, onToken) {
363
+ // Gemini streaming with ?alt=sse returns SSE events with data: prefix
364
+ if (!res.body)
365
+ return;
366
+ const reader = res.body.getReader();
367
+ const decoder = new TextDecoder();
368
+ let buf = '';
369
+ while (true) {
370
+ const { done, value } = await reader.read();
371
+ if (done)
372
+ break;
373
+ buf += decoder.decode(value, { stream: true });
374
+ const lines = buf.split('\n');
375
+ buf = lines.pop() ?? '';
376
+ for (const line of lines) {
377
+ if (!line.startsWith('data: '))
378
+ continue;
379
+ const raw = line.replace('data: ', '').trim();
380
+ try {
381
+ const parsed = JSON.parse(raw);
382
+ const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text;
383
+ if (text)
384
+ onToken(text);
385
+ }
386
+ catch { }
387
+ }
388
+ }
389
+ }
390
+ // ── Provider endpoint map ──────────────────────────────────────
391
+ const OPENAI_COMPAT_ENDPOINTS = {
392
+ groq: 'https://api.groq.com/openai/v1/chat/completions',
393
+ openrouter: 'https://openrouter.ai/api/v1/chat/completions',
394
+ cerebras: 'https://api.cerebras.ai/v1/chat/completions',
395
+ nvidia: 'https://integrate.api.nvidia.com/v1/chat/completions',
396
+ github: 'https://models.inference.ai.azure.com/v1/chat/completions',
397
+ boa: 'https://api.bayofassets.com/v1/chat/completions',
398
+ };
399
+ function buildHeaders(providerName, apiKey) {
400
+ const headers = {
401
+ 'Content-Type': 'application/json',
402
+ 'Authorization': `Bearer ${apiKey}`,
403
+ };
404
+ if (providerName === 'openrouter') {
405
+ headers['HTTP-Referer'] = 'http://localhost:3000';
406
+ headers['X-Title'] = 'DevOS';
407
+ }
408
+ return headers;
409
+ }
410
+ // ── Phase inference from tool steps ───────────────────────────
411
+ // Groups consecutive steps of the same capability type into phases.
412
+ function inferPhasesFromSteps(steps) {
413
+ const capabilityMap = {
414
+ web_search: 'research', fetch_page: 'research',
415
+ deep_research: 'research', fetch_url: 'research',
416
+ get_stocks: 'research',
417
+ open_browser: 'browsing', browser_click: 'browsing',
418
+ browser_extract: 'browsing', browser_type: 'browsing',
419
+ mouse_move: 'browsing', mouse_click: 'browsing',
420
+ keyboard_type: 'browsing', keyboard_press: 'browsing',
421
+ screenshot: 'browsing', screen_read: 'browsing',
422
+ vision_loop: 'browsing',
423
+ file_write: 'writing', file_read: 'reading',
424
+ file_list: 'reading', shell_exec: 'execution',
425
+ run_python: 'execution', run_node: 'execution',
426
+ system_info: 'execution', notify: 'execution',
427
+ clipboard_read: 'execution', clipboard_write: 'execution',
428
+ window_list: 'execution', window_focus: 'execution',
429
+ app_launch: 'execution', app_close: 'execution',
430
+ watch_folder: 'execution', watch_folder_list: 'execution',
431
+ };
432
+ const phaseNames = {
433
+ research: 'Research & Gather',
434
+ browsing: 'Browse & Extract',
435
+ writing: 'Write & Save',
436
+ reading: 'Read & Analyze',
437
+ execution: 'Execute Tasks',
438
+ delivery: 'Deliver Results',
439
+ };
440
+ const phases = [];
441
+ let currentCap = '';
442
+ let currentTools = [];
443
+ for (const step of steps) {
444
+ const cap = capabilityMap[step.tool] || 'execution';
445
+ if (cap !== currentCap && currentTools.length > 0) {
446
+ phases.push({
447
+ id: `phase_${phases.length + 1}`,
448
+ title: phaseNames[currentCap] || currentCap,
449
+ capabilities: [currentCap],
450
+ tools: [...currentTools],
451
+ });
452
+ currentTools = [];
453
+ }
454
+ currentCap = cap;
455
+ currentTools.push(step.tool);
456
+ }
457
+ if (currentTools.length > 0) {
458
+ phases.push({
459
+ id: `phase_${phases.length + 1}`,
460
+ title: phaseNames[currentCap] || currentCap,
461
+ capabilities: [currentCap],
462
+ tools: currentTools,
463
+ });
464
+ }
465
+ // Always end with a Deliver Results phase
466
+ phases.push({
467
+ id: `phase_${phases.length + 1}`,
468
+ title: 'Deliver Results',
469
+ capabilities: ['delivery'],
470
+ tools: ['respond'],
471
+ });
472
+ return phases;
473
+ }
474
+ // ── Keyword-based plan inference — fallback when LLM unavailable ──────
475
+ // Detects simple single-tool intents from the message text.
476
+ function inferPlanFromKeywords(message) {
477
+ const m = message.toLowerCase();
478
+ // notify
479
+ if (/send\s+(a\s+)?(desktop\s+)?notif|notify\s+me|desktop\s+alert/.test(m)) {
480
+ const msgMatch = message.match(/saying\s+(.+?)(?:\s*$)/i);
481
+ const notifMsg = msgMatch ? msgMatch[1].trim() : message;
482
+ return {
483
+ goal: message, requires_execution: true,
484
+ plan: [{ step: 1, tool: 'notify', input: { message: notifMsg }, description: 'Send notification' }],
485
+ phases: [],
486
+ };
487
+ }
488
+ // file_read — matches "read the file /path/to/file", "read file C:\...", "tell me what it says"
489
+ const fileReadMatch = message.match(/read\s+(?:the\s+)?file\s+([^\s"']+)/i) ||
490
+ message.match(/read\s+([A-Z]:[/\\][^\s"']+)/i) ||
491
+ message.match(/read\s+(\/[^\s"']+\.\w{1,6})/i);
492
+ if (fileReadMatch) {
493
+ const filePath = fileReadMatch[1].trim();
494
+ return {
495
+ goal: message, requires_execution: true,
496
+ plan: [{ step: 1, tool: 'file_read', input: { path: filePath }, description: `Read ${filePath}` }],
497
+ phases: [],
498
+ };
499
+ }
500
+ // file_write — matches "write ... to /path/file"
501
+ const fileWriteMatch = message.match(/write\s+(.+?)\s+to\s+([^\s"']+\.\w{1,6})/i);
502
+ if (fileWriteMatch) {
503
+ const content = fileWriteMatch[1].trim();
504
+ const filePath = fileWriteMatch[2].trim();
505
+ return {
506
+ goal: message, requires_execution: true,
507
+ plan: [{ step: 1, tool: 'file_write', input: { path: filePath, content }, description: `Write to ${filePath}` }],
508
+ phases: [],
509
+ };
510
+ }
511
+ // fetch_url — matches "Fetch https://...", "fetch http://...", "get https://..."
512
+ const fetchUrlMatch = message.match(/(?:fetch|get|open|load)\s+(https?:\/\/[^\s"']+)/i) ||
513
+ message.match(/(https?:\/\/[^\s"']+)/i);
514
+ if (fetchUrlMatch) {
515
+ const url = fetchUrlMatch[1].trim();
516
+ return {
517
+ goal: message, requires_execution: true,
518
+ plan: [{ step: 1, tool: 'fetch_url', input: { url }, description: `Fetch ${url}` }],
519
+ phases: [],
520
+ };
521
+ }
522
+ // web_search / search the web
523
+ if (/search\s+(the\s+)?web|web\s+search|look\s+up|find\s+info/.test(m)) {
524
+ const query = message.replace(/search\s+(the\s+)?web\s+(for\s+)?/i, '').replace(/look\s+up\s+/i, '').trim();
525
+ return {
526
+ goal: message, requires_execution: true,
527
+ plan: [{ step: 1, tool: 'web_search', input: { query: query || message }, description: 'Search' }],
528
+ phases: [],
529
+ };
530
+ }
531
+ // get_stocks / stock gainers
532
+ if (/top\s+(gainers|losers|active)|nse\s+top|bse\s+top|stock\s+(market|data|gainers)|get\s+stocks/.test(m)) {
533
+ const isLosers = /loser/.test(m);
534
+ const market = /bse/.test(m) ? 'BSE' : 'NSE';
535
+ const type = isLosers ? 'losers' : /active/.test(m) ? 'active' : 'gainers';
536
+ return {
537
+ goal: message, requires_execution: true,
538
+ plan: [{ step: 1, tool: 'get_stocks', input: { market, type }, description: `Get ${market} top ${type}` }],
539
+ phases: [],
540
+ };
541
+ }
542
+ // system_info
543
+ if (/system\s+info|hardware\s+info|what.{0,10}(cpu|ram|memory|os|specs)|show\s+system|computer\s+specs/.test(m)) {
544
+ return {
545
+ goal: message, requires_execution: true,
546
+ plan: [{ step: 1, tool: 'system_info', input: {}, description: 'Get system info' }],
547
+ phases: [],
548
+ };
549
+ }
550
+ // run_python / run_node fast-path intentionally removed.
551
+ // These tools require actual executable source code in their input, which we cannot
552
+ // fabricate from a natural-language description. If all LLMs are down we cannot
553
+ // generate code, so we fall through to null and let the caller handle gracefully.
554
+ return null;
555
+ }
556
+ // ── Sprint 5: Planner racing helper ──────────────────────────
557
+ // Fires top-2 available APIs simultaneously; returns first valid JSON string.
558
+ async function racePlannerAPIs(promptText, topN = 2) {
559
+ const cfg = (0, index_1.loadConfig)();
560
+ const candidates = [];
561
+ for (const cp of (cfg.customProviders ?? [])) {
562
+ if (!cp.enabled || !cp.baseUrl)
563
+ continue;
564
+ candidates.push({ provider: 'custom', model: cp.model, key: cp.apiKey, url: cp.baseUrl, tier: cp.tier ?? 99 });
565
+ }
566
+ for (const a of cfg.providers.apis) {
567
+ if (!a.enabled || a.rateLimited)
568
+ continue;
569
+ const k = a.key.startsWith('env:') ? (process.env[a.key.replace('env:', '')] || '') : a.key;
570
+ if (!k || !OPENAI_COMPAT_ENDPOINTS[a.provider])
571
+ continue;
572
+ candidates.push({ provider: a.provider, model: a.model, key: k, url: OPENAI_COMPAT_ENDPOINTS[a.provider], tier: a.tier ?? 50 });
573
+ }
574
+ const pool = candidates.sort((a, b) => a.tier - b.tier).slice(0, topN);
575
+ if (pool.length < 1)
576
+ return null;
577
+ const controllers = pool.map(() => new AbortController());
578
+ const callOne = async (entry, ctrl) => {
579
+ const messages = [{ role: 'user', content: promptText }];
580
+ const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${entry.key}` };
581
+ if (entry.provider !== 'custom') {
582
+ Object.assign(headers, buildHeaders(entry.provider, entry.key));
583
+ }
584
+ const r = await fetch(entry.url, {
585
+ method: 'POST',
586
+ headers,
587
+ body: JSON.stringify({ model: entry.model, messages, stream: false, max_tokens: 2000 }),
588
+ signal: AbortSignal.any([AbortSignal.timeout(45000), ctrl.signal]),
589
+ });
590
+ if (!r.ok)
591
+ throw new Error(`${entry.provider} ${r.status}`);
592
+ const d = await r.json();
593
+ const text = d?.choices?.[0]?.message?.content || '';
594
+ if (!text.trim() || !text.includes('{'))
595
+ throw new Error('no JSON');
596
+ return text;
597
+ };
598
+ const promises = pool.map((entry, i) => callOne(entry, controllers[i]).then(text => {
599
+ controllers.forEach((c, j) => { if (j !== i) {
600
+ try {
601
+ c.abort();
602
+ }
603
+ catch { }
604
+ } });
605
+ return text;
606
+ }));
607
+ try {
608
+ return await Promise.race(promises);
609
+ }
610
+ catch { }
611
+ return null;
612
+ }
613
+ // ── Compaction protection — critical files survive context reset ──
614
+ // When the sliding context window summarizes older messages, we re-inject
615
+ // these files word-for-word as a system message so identity and rules survive.
616
+ const COMPACTION_PROTECTED = [
617
+ 'SOUL.md', // personality + boundaries
618
+ 'STANDING_ORDERS.md', // persistent instructions
619
+ 'LESSONS.md', // failure rules
620
+ 'GOALS.md', // active goals
621
+ 'USER.md', // user profile
622
+ ];
623
+ async function rebuildContextAfterCompaction(contextHistory) {
624
+ const workspaceDir = nodePath.join(process.cwd(), 'workspace');
625
+ const protectedContent = [];
626
+ // Read all protected files
627
+ for (const filename of COMPACTION_PROTECTED) {
628
+ try {
629
+ const filepath = nodePath.join(workspaceDir, filename);
630
+ if (nodeFs.existsSync(filepath)) {
631
+ const content = nodeFs.readFileSync(filepath, 'utf-8');
632
+ if (content.trim()) {
633
+ protectedContent.push(`## ${filename}\n${content.trim()}`);
634
+ }
635
+ }
636
+ }
637
+ catch { }
638
+ }
639
+ // Top 5 instincts by confidence (read directly from workspace/instincts.json)
640
+ let instinctCount = 0;
641
+ try {
642
+ const instinctsPath = nodePath.join(workspaceDir, 'instincts.json');
643
+ if (nodeFs.existsSync(instinctsPath)) {
644
+ const raw = JSON.parse(nodeFs.readFileSync(instinctsPath, 'utf-8'));
645
+ const topInsts = raw
646
+ .filter(i => i.status === 'active' && i.confidence >= 0.7)
647
+ .sort((a, b) => b.confidence - a.confidence)
648
+ .slice(0, 5);
649
+ if (topInsts.length > 0) {
650
+ const instinctText = topInsts
651
+ .map(i => `- ${i.action} (confidence: ${(i.confidence * 100).toFixed(0)}%)`)
652
+ .join('\n');
653
+ protectedContent.push(`## Active Instincts\n${instinctText}`);
654
+ instinctCount = topInsts.length;
655
+ }
656
+ }
657
+ }
658
+ catch { }
659
+ if (protectedContent.length === 0)
660
+ return contextHistory;
661
+ console.log(`[Compaction] Protected ${COMPACTION_PROTECTED.length} files ` +
662
+ `+ ${instinctCount} instincts — re-injected into context`);
663
+ const protectedMessage = {
664
+ role: 'system',
665
+ content: `[PROTECTED CONTEXT — survives compaction]\n\n${protectedContent.join('\n\n---\n\n')}`,
666
+ };
667
+ return [protectedMessage, ...contextHistory];
668
+ }
669
+ // ── STEP 1: planWithLLM ────────────────────────────────────────
670
+ async function planWithLLM(message, history, apiKey, model, provider, memoryContext) {
671
+ // ── Pre-compact hook — fire at multiples of COMPACT_THRESHOLD ─
672
+ // Fires at 40, 80, 120 … to avoid triggering on every message after crossing 40.
673
+ if (history.length >= COMPACT_THRESHOLD && history.length % COMPACT_THRESHOLD === 0) {
674
+ (0, hooks_1.fireHook)('pre_compact', { historyLength: history.length, message }).catch(() => { });
675
+ }
676
+ // ── Vague goal detection — ask for clarification before planning ──
677
+ const VAGUE_PATTERNS = [/\bthe thing\b/i, /\bthe stuff\b/i, /\bthe place\b/i, /\bdo it\b$/i, /\bfix it\b$/i];
678
+ if (VAGUE_PATTERNS.some(p => p.test(message))) {
679
+ return {
680
+ goal: message,
681
+ requires_execution: false,
682
+ plan: [],
683
+ phases: [],
684
+ direct_response: 'I need more detail. What specifically should I do, with what, and where?',
685
+ reason: 'goal_too_vague',
686
+ };
687
+ }
688
+ // ── Recipe engine — YAML workflow definitions ─────────────────
689
+ // Check before LLM planning: if a recipe trigger matches, execute
690
+ // the structured workflow instead of the probabilistic planner.
691
+ const recipes = (0, recipeEngine_1.loadAllRecipes)();
692
+ const recipeMatch = (0, recipeEngine_1.matchRecipe)(message, recipes);
693
+ if (recipeMatch) {
694
+ try {
695
+ const recipeResult = await (0, recipeEngine_1.executeRecipe)(recipeMatch.recipe, recipeMatch.params);
696
+ return {
697
+ goal: message,
698
+ requires_execution: false,
699
+ plan: [],
700
+ phases: [],
701
+ direct_response: recipeResult.output || `Completed recipe: ${recipeMatch.recipe.name}`,
702
+ reason: `recipe:${recipeMatch.recipe.name}`,
703
+ };
704
+ }
705
+ catch (err) {
706
+ console.warn(`[Recipe] Execution failed for ${recipeMatch.recipe.name}: ${err} — falling through to LLM planner`);
707
+ }
708
+ }
709
+ const ALLOWED_TOOLS = [
710
+ 'web_search', 'fetch_page', 'open_browser', 'browser_extract',
711
+ 'browser_click', 'browser_type', 'browser_screenshot', 'browser_scroll', 'browser_get_url',
712
+ 'file_write', 'file_read',
713
+ 'file_list', 'shell_exec', 'run_python', 'run_node',
714
+ 'system_info', 'notify', 'deep_research', 'get_stocks',
715
+ 'get_market_data', 'get_company_info', 'social_research',
716
+ 'mouse_move', 'mouse_click', 'keyboard_type', 'keyboard_press',
717
+ 'screenshot', 'screen_read', 'vision_loop', 'wait',
718
+ 'code_interpreter_python', 'code_interpreter_node',
719
+ 'clipboard_read', 'clipboard_write', 'window_list', 'window_focus',
720
+ 'app_launch', 'app_close',
721
+ 'watch_folder', 'watch_folder_list',
722
+ 'send_file_local', 'receive_file_local',
723
+ 'get_briefing',
724
+ 'respond',
725
+ 'clarify', 'todo', 'cronjob', 'vision_analyze',
726
+ 'voice_speak', 'voice_transcribe', 'voice_clone', 'voice_design',
727
+ 'lookup_skill', 'lookup_tool_schema',
728
+ 'spawn', 'spawn_subagent', 'swarm',
729
+ ...slashAsTool_1.SLASH_MIRROR_TOOL_NAMES,
730
+ ];
731
+ // Sprint 13: append discovered MCP tools
732
+ const mcpToolNames = mcpClient_1.mcpClient.getAllCachedTools().map(t => t.name);
733
+ const allTools = mcpToolNames.length > 0
734
+ ? [...ALLOWED_TOOLS, ...mcpToolNames]
735
+ : ALLOWED_TOOLS;
736
+ // Dynamic tool loading — filter to relevant tools per task category
737
+ // Reduces planner prompt from ~15K to ~3-5K tokens without losing capability.
738
+ // Validation (line ~898) still uses full allTools — filtering is prompt-only.
739
+ const categories = (0, toolRegistry_1.detectToolCategories)(message);
740
+ const categoryTools = (0, toolRegistry_1.getToolsForCategories)(categories);
741
+ // MCP tools always included; ALLOWED_TOOLS filtered by detected category
742
+ const plannerTools = allTools.filter(t => t.startsWith('mcp_') || categoryTools.includes(t));
743
+ console.log(`[Tools] ${plannerTools.length}/${allTools.length} tools loaded for categories: ${categories.join(', ')}`);
744
+ // Load any relevant skills to guide planning
745
+ const relevantSkills = skillLoader_1.skillLoader.findRelevant(message);
746
+ const skillContext = skillLoader_1.skillLoader.formatForPrompt(relevantSkills);
747
+ // Append instinct context to memory (micro-patterns learned from past tool calls)
748
+ const instinctCtx = instinctSystem_1.instinctSystem?.getRelevantInstincts(message) || '';
749
+ const fullMemCtx = (memoryContext || '') + (instinctCtx ? '\n\n' + instinctCtx : '');
750
+ // Build memory section — inject when available
751
+ const memorySection = fullMemCtx.trim()
752
+ ? `\n\nCONVERSATION MEMORY (use to resolve references like "that file", "the report", "it"):\n${fullMemCtx}\n\nWhen the user says "that file", "the report", "the script" etc., use the paths/queries above to resolve them into concrete values in your plan inputs.\n`
753
+ : '';
754
+ // Build learning context — past experiences with similar tasks
755
+ const learningCtx = learningMemory_1.learningMemory.buildLearningContext(message);
756
+ const learningSection = learningCtx ? `\n${learningCtx}\n` : '';
757
+ // Build knowledge context — relevant chunks from user's knowledge base files
758
+ const knowledgeCtxPlanner = knowledgeBase_1.knowledgeBase.buildContext(message);
759
+ const knowledgeSection = knowledgeCtxPlanner
760
+ ? `\n\n${knowledgeCtxPlanner}\n`
761
+ : '';
762
+ // LESSONS.md — permanent failure rules, injected every session
763
+ const lessonsContent = loadLessons();
764
+ const lessonsSection = lessonsContent
765
+ ? `\n\nPERMANENT FAILURE RULES (learned from past task failures — follow strictly):\n${lessonsContent.split('\n').filter(l => /^\d+\./.test(l.trim())).map(l => ` ${l.trim()}`).join('\n')}\n`
766
+ : '';
767
+ // Sprint 21: unified memory recall — only when message references past context
768
+ // Gate prevents unnecessary hybrid-search I/O on routine messages
769
+ let memoryRecallSection = '';
770
+ if ((0, skillLoader_1.needsMemory)(message)) {
771
+ try {
772
+ const recalled = await (0, memoryRecall_1.unifiedMemoryRecall)(message, 5);
773
+ const memoryInjected = (0, memoryRecall_1.buildMemoryInjection)(recalled);
774
+ if (memoryInjected) {
775
+ memoryRecallSection = memoryInjected;
776
+ }
777
+ }
778
+ catch { }
779
+ }
780
+ // N+27: inject distilled facts from past sessions into planner context
781
+ // N+33: also inject smart-sliced Honcho user profile (replaces dumb full-dump)
782
+ let distilledFactsSection = '';
783
+ try {
784
+ const factHits = semanticMemory_1.semanticMemory.search(message, 5, 0.3)
785
+ .filter((r) => r.metadata?.type === 'fact')
786
+ .slice(0, 5);
787
+ if (factHits.length > 0) {
788
+ const factLines = factHits
789
+ .map((r) => `- ${r.text ?? ''}`)
790
+ .filter((l) => l.length > 3)
791
+ .join('\n');
792
+ if (factLines.trim()) {
793
+ distilledFactsSection = `\n\nREMEMBERED CONTEXT (facts distilled from past sessions — use to resolve references and avoid repeating work):\n${factLines}\n`;
794
+ }
795
+ }
796
+ }
797
+ catch { }
798
+ // N+33: smart Honcho profile slice injection (zero LLM cost — regex classifier)
799
+ let honchoProfileSection = '';
800
+ try {
801
+ const { formatForPrompt } = await Promise.resolve().then(() => __importStar(require('./userProfile')));
802
+ honchoProfileSection = await formatForPrompt(message);
803
+ }
804
+ catch { }
805
+ // Resolve the actual Windows username and home directory at runtime
806
+ const _sysUsername = process.env.USERNAME || process.env.USER || nodeOs.userInfo().username || 'User';
807
+ const _sysHomedir = nodeOs.homedir();
808
+ const plannerPrompt = `You are DevOS Planner. Analyze the user request and output a JSON plan.
809
+
810
+ GOAL DECOMPOSITION: Before writing your plan, count the distinct intents in the user message.
811
+ If the message contains 2 or more distinct goals (e.g., "search X AND write a file", "do A then B", "1. … 2. …"), add a "goals" array to your JSON listing each goal as a short phrase (max 8 words each). Your plan MUST cover ALL listed goals — do not silently drop any.
812
+ Single-goal messages: omit "goals" or leave it as an empty array.
813
+
814
+ SYSTEM CONTEXT — use these exact values for all file paths:
815
+ - Windows username: ${_sysUsername}
816
+ - Home directory: ${_sysHomedir}
817
+ - Desktop: ${nodePath.join(_sysHomedir, 'Desktop')}
818
+ - Documents: ${nodePath.join(_sysHomedir, 'Documents')}
819
+ - Downloads: ${nodePath.join(_sysHomedir, 'Downloads')}
820
+ IMPORTANT: NEVER use "C:\\Users\\Aiden" — "Aiden" is the AI assistant's name, NOT the Windows username. Always use "${_sysUsername}" as the username in any path.
821
+
822
+ CRITICAL RULES:
823
+ 1. If the answer is in your training data (capitals, definitions, facts, opinions, advice) → requires_execution: false
824
+ 2. ONLY use tools when you need: live data, file operations, running code, or computer control
825
+ 3. AVAILABLE TOOLS (use ONLY these — name: one-liner):
826
+ ${plannerTools.map(t => ` ${t}: ${toolRegistry_1.TOOL_NAMES_ONLY[t] ?? ''}`).join('\n')}
827
+ For full parameter schema: call lookup_tool_schema({ toolName: "name" })
828
+ Tier-0 (no lookup needed): web_search, notify, lookup_skill, lookup_tool_schema, schedule_reminder, file_read, file_write, respond
829
+ 4. DO NOT invent tools like "identify_top_3", "generate_report", "analyze" — these don't exist
830
+ 5. Processing/analysis happens in your response — NOT as a tool step
831
+ 6. NEVER use placeholders like "{{result}}" or "{output}" — steps must have real concrete inputs
832
+ 7. For multi-step tasks: if step N+1 needs step N's output, use the literal string "PREVIOUS_OUTPUT"
833
+ CRITICAL: Step 1 CANNOT use "PREVIOUS_OUTPUT" — there is no previous step. Step 1 must always have a literal concrete input value (e.g. a real URL, search query, or file path).
834
+ 8. Output ONLY valid JSON — no text before or after
835
+
836
+ SCHEDULER (CRITICAL): You have a real persistent scheduler. When the user asks for a reminder, alarm, or time-delayed action ("remind me in N seconds/minutes/hours", "in N minutes do X", "every day at..."):
837
+ - You MUST call schedule_reminder — this is the ONLY correct path.
838
+ - Params: message (what to say), delaySeconds calculated from the user's request (e.g. "10 minutes" → delaySeconds: 600)
839
+ - For recurring reminders add: recurring: "hourly" | "daily" | "weekly"
840
+ - After scheduling, confirm with the exact fire time (e.g. "Done — I'll remind you at 3:45 PM.")
841
+ - To see pending reminders: schedule_reminder with op: "list"
842
+ - To cancel: schedule_reminder with op: "cancel" and the reminder id
843
+ - STRICTLY FORBIDDEN — these are ALL wrong and must NEVER appear in a reminder plan:
844
+ • Using wait in a loop (e.g. wait(5000) × N) — this blocks the whole system
845
+ • Using run_node or run_python with setTimeout/sleep to simulate a delay
846
+ • Saying "Waiting N seconds..." in a respond step and then firing notify
847
+ • Responding inline with the reminder message instead of scheduling it
848
+ schedule_reminder fires a real desktop notification asynchronously — set it and respond immediately.
849
+
850
+ RUN_AGENT HONESTY: run_agent executes inline — the result comes directly in your next response. NEVER tell the user "your research is being processed", "the agent is working in background", or "results will be ready soon". If you use run_agent, the answer is available immediately in the same response turn.
851
+
852
+ SUBAGENTS (CRITICAL):
853
+ Use spawn_subagent when the user's task has independent parallel sub-questions (e.g., "research X AND summarize Y AND find Z"):
854
+ - Each spawn_subagent call runs an isolated agent with its own context and half your remaining iteration budget
855
+ - Spawn returns the subagent's synthesized answer — it is available immediately, not in the background
856
+ - After spawning, synthesize all results into a unified final response, clearly attributing: "From a parallel research subagent: <result>"
857
+
858
+ When NOT to use spawn_subagent:
859
+ - Simple linear tasks (plan the steps yourself)
860
+ - Single-tool questions (just call the tool)
861
+ - Quick lookups (respond directly)
862
+
863
+ NEVER say "the subagent is working in background" — spawn_subagent is synchronous and returns before your response.
864
+
865
+ WHEN TO USE TOOLS vs NOT:
866
+ ✅ Use tools for:
867
+ - Weather, news, current prices → web_search
868
+ - Opening websites → open_browser
869
+ - Writing/reading files → file_write, file_read
870
+ - Running code → run_python, run_node
871
+ - System info → system_info
872
+ - Research with real sources → deep_research
873
+ - Git repo state (status, branch, commits, changes) → git_status — ALWAYS run the tool, never answer from training data
874
+ - Compound tasks needing multiple steps (fetch + process + save) → run
875
+
876
+ ## When to use the run tool
877
+
878
+ For compound tasks that need multiple steps, prefer run over separate tool calls.
879
+ Write JavaScript that composes the aiden SDK:
880
+
881
+ aiden.web.search(query), aiden.file.write(path, content), aiden.shell.exec(cmd), etc.
882
+
883
+ This collapses what would be 5 LLM turns into 1. Much faster.
884
+
885
+ Example — instead of:
886
+ turn 1: web_search("hn top")
887
+ turn 2: fetch_url(article[0].url)
888
+ turn 3: web_search(related)
889
+ turn 4: file_write(summary)
890
+
891
+ Use run:
892
+ const top = await aiden.web.search("hn top")
893
+ const article = await aiden.web.fetch(top[0].url)
894
+ const related = await aiden.web.search(article.title)
895
+ await aiden.file.write("/tmp/brief.md", ...)
896
+
897
+ ❌ Do NOT use tools for:
898
+ - "What is the capital of X" → just answer
899
+ - "Who is [famous person]" → just answer
900
+ - "Explain X concept" → just answer
901
+ - "What do you think about X" → just answer
902
+ - Any question answerable from training knowledge
903
+
904
+ TOOL INPUT RULES (Tier-0 examples — for all others call lookup_tool_schema first):
905
+ - web_search: { "query": "specific search term" }
906
+ - notify: { "message": "text to show", "title": "optional title" }
907
+ - respond: { "message": "your reply text" }
908
+ - lookup_skill: { "query": "task description" }
909
+ - lookup_tool_schema: { "toolName": "tool_name" } — returns full description for any tool
910
+ - wait: { "ms": 2000 } — ONLY after browser/UI actions. Max 5000ms. NOT for reminders.
911
+
912
+ TOOL DISCOVERY: If you are unsure of a tool's parameters, call lookup_tool_schema FIRST (as step 1 of your plan) with the toolName, then use the returned description to build the real tool step.
913
+
914
+ COMPUTER CONTROL RULES — follow strictly when controlling mouse/keyboard/browser:
915
+ - ALWAYS use open_browser BEFORE keyboard_type or mouse_click on browser
916
+ - ALWAYS add a wait step of 2000ms after open_browser before any interaction
917
+ - For web searches: step 1 = open_browser(url), step 2 = wait(2000), step 3 = keyboard_press(ctrl+l), step 4 = keyboard_type(query), step 5 = keyboard_press(enter)
918
+ - For clicking browser address bar: use keyboard_press(ctrl+l) to focus it first
919
+ - After typing a URL: use keyboard_press(enter) to navigate
920
+ - For vision_loop tasks: set max_steps to at least 5
921
+ - Never assume the browser is already open — always open it first
922
+ - After any mouse_click: add wait(800) to let UI respond
923
+
924
+ URL RULES:
925
+ - Always use COMPLETE URLs — never truncate a URL in a tool input
926
+ - For market-wide queries (gainers, losers, most active) → use get_stocks, NOT web_search
927
+ - For individual stock price / market data → use get_market_data({ "symbol": "RELIANCE" })
928
+ - For company profile, financials, P/E ratio, EPS → use get_company_info({ "symbol": "RELIANCE" })
929
+ - Example: get_stocks({ "market": "NSE", "type": "gainers" })
930
+
931
+ OUTPUT FORMAT (strict JSON only):
932
+ {
933
+ "goal": "exact user request",
934
+ "goals": ["goal 1 short phrase", "goal 2 short phrase"],
935
+ "requires_execution": true,
936
+ "reasoning": "one sentence why",
937
+ "plan": [
938
+ { "step": 1, "tool": "web_search", "input": { "query": "weather London today" }, "description": "Get London weather" }
939
+ ]
940
+ }
941
+
942
+ CODE TOOL RULES — the "code" / "script" field MUST contain executable source, NEVER a description:
943
+ - run_python: { "tool": "run_python", "input": { "code": "def reverse(s):\n return s[::-1]\n\nprint(reverse('hello'))" } }
944
+ NOT: { "input": { "code": "a python script that reverses a string" } }
945
+ - run_node: { "tool": "run_node", "input": { "code": "console.log([1,2,3].map(x => x*2))" } }
946
+ NOT: { "input": { "code": "node script to double array elements" } }
947
+ - shell_exec: { "tool": "shell_exec", "input": { "command": "echo hello" } }
948
+ NOT: { "input": { "command": "a command that prints hello" } }
949
+ If the user says "write a python script that prints fibonacci numbers up to 10", the plan step must contain the actual working Python source code, not the user's English description.
950
+
951
+ If requires_execution is false:
952
+ { "goal": "...", "requires_execution": false, "reasoning": "...", "plan": [], "direct_response": "your answer here" }
953
+
954
+ NOTE: "goals" is only required when 2+ distinct intents are present. Single-goal messages may omit it.
955
+
956
+ THE 'respond' TOOL — use this for ALL conversational messages:
957
+ - 'respond' is ALWAYS a valid plan. When no external tool is needed, plan a single respond step.
958
+ - respond: { "message": "your answer text here" }
959
+ - Use respond for: greetings, capability questions, simple facts from training data, clarifying questions, short answers.
960
+ - Example: user says "hi" → { "goal": "hi", "requires_execution": true, "plan": [{ "step": 1, "tool": "respond", "input": { "message": "Hi! What can I help you with today?" } }] }
961
+
962
+ ACTION GATE RULES — apply BEFORE creating any plan:
963
+ 1. CAPABILITY GATE: If message is "Can you do X?" / "Can you X?" / "Are you able to X?" → plan respond with answer
964
+ 2. EXPLICIT-ASK GATE: ONLY use file_write if user said "write", "save", "create file". ONLY use deep_research if user said "research", "find out", "look up"
965
+ 3. VAGUENESS GATE: If request is AMBIGUOUS, plan a respond step that asks ONE clarifying question:
966
+ - "do marketing" → respond: "What specifically? Copywriting, competitor research, Product Hunt listing, or content calendar?"
967
+ - "check my system" → respond: "What aspect? Hardware specs, running processes, disk space, or network?"
968
+ - "build something" → respond: "What would you like me to build?"
969
+ - Clear requests execute directly: "check NIFTY price" → get_market_data, "write a Python script to X" → run_python
970
+ 4. NEVER create comparison tables, reports, or verdicts unless user explicitly asked for them
971
+ 5. NEVER mention Pega, BlueWinston, Gaude Digital, or any third-party product by name
972
+
973
+ ## SKILL DISCOVERY
974
+
975
+ Before planning any multi-step task (>=2 tools), call lookup_skill with the user's message as the query.
976
+ - If it returns a skill, read the Steps section and follow that tool sequence as your plan.
977
+ - If it returns no match (score < threshold), proceed with normal planning.
978
+ - lookup_skill is Tier 1 — it is fast and free. Never skip it for multi-step tasks.
979
+
980
+ ## Tool Priority Rules (STRICT)
981
+
982
+ TIER 1 (USE FIRST): lookup_skill, respond, web_search, fetch_page, fetch_url, deep_research, get_market_data, get_stocks, get_company_info, social_research, system_info, notify, get_briefing, run_agent
983
+ → ALWAYS try these before anything else
984
+ → If a task CAN be done via API/data tool, use that
985
+
986
+ TIER 2 (USE SECOND): file_write, file_read, file_list, shell_exec, run_powershell, run_python, run_node, code_interpreter_python, code_interpreter_node, git_status, git_commit, git_push, clipboard_read, clipboard_write, spawn_subagent, swarm
987
+ → Use when you need to read/write files, run scripts, or run git commands
988
+
989
+ TIER 3 (USE THIRD): open_browser, browser_click, browser_type, browser_extract, browser_screenshot, window_list, window_focus, app_launch, app_close
990
+ → ONLY when task requires interacting with a website UI
991
+ → NEVER use browser when an API tool can do the same job
992
+ → For other selectors always pass selector: "<css selector>", never guess at element text.
993
+
994
+ BROWSER CHAIN (CRITICAL): When the user wants to CONSUME content — not just see search results — you MUST emit a TWO-STEP plan:
995
+ Step 1: open_browser with the search/query URL
996
+ Step 2: browser_click with target: "first_result"
997
+ browser_click handles site-specific waiting and navigation automatically for: youtube.com, google.com, duckduckgo.com, bing.com.
998
+
999
+ Phrases that REQUIRE the chain (open_browser → browser_click first_result):
1000
+ • "play [song/video/anything]" — open YouTube search → click first result
1001
+ • "watch [anything]"
1002
+ • "open the article about X" / "open the top result"
1003
+ • "read about X" when it implies opening a page, not just searching
1004
+ • "find and play" / "find and read" / "find and open"
1005
+ • Any request where the user clearly wants to land on the content page
1006
+
1007
+ Phrases that do NOT require the chain (open_browser alone is fine):
1008
+ • "search for X" / "search YouTube for X"
1009
+ • "show me search results for X"
1010
+ • "look up X" / "find news about X"
1011
+ • "open youtube" / "go to google.com" (no specific content target)
1012
+
1013
+ When in doubt, chain the click — users want the content, not the search page.
1014
+
1015
+ TIER 4 (LAST RESORT): mouse_move, mouse_click, keyboard_type, keyboard_press, screenshot, screen_read, vision_loop
1016
+ → ONLY when browser fails or for desktop apps with no API
1017
+ → ALWAYS explain WHY lower tiers won't work
1018
+
1019
+ VIOLATIONS (these are WRONG — do not do these):
1020
+ - Using open_browser to check stock price when get_market_data exists
1021
+ - Using screenshot to search when web_search exists
1022
+ - Using browser to get weather when web_search exists
1023
+ - Using vision_loop for any task where a simpler tool works
1024
+
1025
+ FAILURE REPLANNING RULES (when message contains "previous approach failed at"):
1026
+ - Keep new plan to max 2 steps
1027
+ - Use ONLY the specific alternative approach mentioned in the message
1028
+ - DO NOT add web_search, deep_research, file_write, or notify unless directly needed
1029
+ - DO NOT add unrelated analysis or comparison steps
1030
+ ${skillContext}${memorySection}${learningSection}${knowledgeSection}${memoryRecallSection}${distilledFactsSection}${honchoProfileSection}${lessonsSection}${(() => { const s = (0, goalTracker_1.getActiveGoalsSummary)(); return s ? `\n\n## Your Active Goals\n${s}` : ''; })()}
1031
+ Output ONLY valid JSON, nothing else:`;
1032
+ const cleanHistory = history
1033
+ .filter((h) => h.content && String(h.content).trim().length > 0);
1034
+ console.log(`[Planner] History: ${cleanHistory.length} messages (${history.length} raw)`);
1035
+ // ── Sliding context window — keep last 10, summarize older messages ──
1036
+ const RECENT_WINDOW = 10;
1037
+ let contextHistory = cleanHistory;
1038
+ if (cleanHistory.length > RECENT_WINDOW) {
1039
+ const recent = cleanHistory.slice(-RECENT_WINDOW);
1040
+ const older = cleanHistory.slice(Math.max(0, cleanHistory.length - RECENT_WINDOW * 2), cleanHistory.length - RECENT_WINDOW);
1041
+ if (older.length > 0) {
1042
+ try {
1043
+ const summaryInput = older.map((m) => `${m.role}: ${String(m.content).slice(0, 200)}`).join('\n');
1044
+ const summary = await callLLM(`Summarize these messages in 2-3 sentences, keeping key facts and decisions:\n\n${summaryInput}`, '', (0, router_1.getOllamaModelForTask)('executor'), 'ollama').catch(() => null);
1045
+ if (summary) {
1046
+ const compacted = [{ role: 'system', content: `Earlier conversation summary: ${summary}` }, ...recent];
1047
+ contextHistory = await rebuildContextAfterCompaction(compacted);
1048
+ console.log(`[Planner] Context window: summarized ${older.length} older messages`);
1049
+ }
1050
+ else {
1051
+ contextHistory = await rebuildContextAfterCompaction(recent);
1052
+ }
1053
+ }
1054
+ catch {
1055
+ contextHistory = recent;
1056
+ }
1057
+ }
1058
+ }
1059
+ const messages = [
1060
+ { role: 'system', content: plannerPrompt },
1061
+ ...contextHistory.slice(-3).map((h) => ({
1062
+ role: h.role === 'assistant' ? 'assistant' : 'user',
1063
+ content: String(h.content).slice(0, 300),
1064
+ })),
1065
+ { role: 'user', content: message },
1066
+ ];
1067
+ // ── Sprint 6: Task-tiered provider selection ─────────────────
1068
+ // Always use the best reasoning model for planning, regardless of what
1069
+ // the caller passed in. Falls back to caller's values if tiering has nothing.
1070
+ {
1071
+ const tiered = (0, router_1.getModelForTask)('planner');
1072
+ if (tiered.apiKey || tiered.providerName === 'ollama') {
1073
+ apiKey = tiered.apiKey;
1074
+ model = tiered.model;
1075
+ provider = tiered.providerName;
1076
+ console.log(`[Planner] Sprint 6 tiering: using ${tiered.apiName} (${provider}/${model})`);
1077
+ }
1078
+ else if (!apiKey) {
1079
+ // Caller had nothing either — last resort Ollama
1080
+ const cfg = (0, index_1.loadConfig)();
1081
+ apiKey = '';
1082
+ model = cfg.model?.activeModel || 'mistral:7b';
1083
+ provider = 'ollama';
1084
+ }
1085
+ }
1086
+ let curApiKey = apiKey;
1087
+ let curModel = model;
1088
+ let curProvider = provider;
1089
+ let curApiName = provider; // tracks the api entry name (e.g. 'groq-1') for markRateLimited
1090
+ {
1091
+ const tiered = (0, router_1.getModelForTask)('planner');
1092
+ if (tiered.apiKey || tiered.providerName === 'ollama') {
1093
+ curApiName = tiered.apiName;
1094
+ }
1095
+ }
1096
+ let raw = '';
1097
+ let parsed = null;
1098
+ // Cap at 3 cloud attempts — getModelForTask() handles provider rotation automatically
1099
+ // (marks failures → skips rate-limited → picks next tier). Walking all 12+ providers
1100
+ // serially at 5s each caused 60-120s cascade when most were rate-limited.
1101
+ // If all 3 fail, the Ollama fallback below catches it.
1102
+ const _cfg = (0, index_1.loadConfig)();
1103
+ const _customAsApi = (_cfg.customProviders ?? [])
1104
+ .filter((cp) => cp.enabled)
1105
+ .map((cp) => ({ ...cp, provider: 'custom', key: cp.apiKey, rateLimited: false, tier: cp.tier ?? 99 }));
1106
+ const _plannerChain = [
1107
+ ..._cfg.providers.apis.filter((a) => a.enabled && a.provider !== 'ollama'),
1108
+ ..._customAsApi,
1109
+ ].sort((a, b) => (a.tier ?? 99) - (b.tier ?? 99));
1110
+ const _availableCount = _plannerChain.filter((a) => !a.rateLimited).length;
1111
+ const maxPlannerAttempts = _availableCount === 0 ? 0 : Math.min(3, _availableCount);
1112
+ for (let attempt = 0; attempt < maxPlannerAttempts; attempt++) {
1113
+ raw = ''; // reset each attempt so stale values don't bleed through
1114
+ try {
1115
+ // Sprint 5: on first attempt, race top-2 providers simultaneously
1116
+ if (attempt === 0) {
1117
+ const promptText = messages.map(m => `${m.role}: ${m.content}`).join('\n');
1118
+ const raceRaw = await racePlannerAPIs(promptText).catch(() => null);
1119
+ if (raceRaw && raceRaw.trim().length > 0) {
1120
+ raw = raceRaw;
1121
+ console.log('[Planner] Race winner resolved');
1122
+ }
1123
+ }
1124
+ if (!raw) {
1125
+ raw = await callLLM(messages.map(m => `${m.role}: ${m.content}`).join('\n'), curApiKey, curModel, curProvider);
1126
+ }
1127
+ if (!raw || raw.trim().length === 0) {
1128
+ console.warn(`[Planner] Empty response attempt ${attempt + 1} (${curApiName}) — marking and rotating`);
1129
+ try {
1130
+ (0, router_1.markRateLimited)(curApiName);
1131
+ }
1132
+ catch { }
1133
+ }
1134
+ else {
1135
+ const jsonMatch = raw.replace(/```json\s*/g, '').replace(/```\s*/g, '').match(/\{[\s\S]*\}/);
1136
+ if (!jsonMatch) {
1137
+ // Phase 1 — repair: try to salvage plain-text / fenced responses before retrying
1138
+ const repair = (0, planResponseRepair_1.repairPlanResponse)(raw);
1139
+ if (repair.plan) {
1140
+ console.log(`[Planner] Repaired non-JSON response — treating as ${repair.directAnswer ? 'direct answer' : 'recovered plan'}`);
1141
+ parsed = repair.plan;
1142
+ try {
1143
+ (0, router_1.incrementUsage)(curApiName);
1144
+ }
1145
+ catch { }
1146
+ break;
1147
+ }
1148
+ console.warn(`[Planner] No JSON attempt ${attempt + 1}: ${raw.slice(0, 100)}`);
1149
+ }
1150
+ else {
1151
+ parsed = JSON.parse(jsonMatch[0]);
1152
+ try {
1153
+ (0, router_1.incrementUsage)(curApiName);
1154
+ }
1155
+ catch { }
1156
+ try {
1157
+ if (curApiName !== 'ollama')
1158
+ (0, router_1.markHealthy)(curApiName);
1159
+ }
1160
+ catch { }
1161
+ break; // success — exit retry loop
1162
+ }
1163
+ }
1164
+ }
1165
+ catch (e) {
1166
+ console.warn(`[Planner] Attempt ${attempt + 1} error (${curApiName}): ${e.message}`);
1167
+ if (e.message?.includes('timeout') ||
1168
+ e.message?.includes('429') ||
1169
+ e.message?.includes('rate') ||
1170
+ e.message?.includes('aborted')) {
1171
+ try {
1172
+ (0, router_1.markRateLimited)(curApiName);
1173
+ console.log(`[Planner] Marked ${curApiName} as rate limited — will rotate away`);
1174
+ }
1175
+ catch { }
1176
+ }
1177
+ }
1178
+ // Wait before next attempt — helps with rate-limit recovery
1179
+ await new Promise(r => setTimeout(r, 1000));
1180
+ // Rotate to next best planner provider for this attempt
1181
+ try {
1182
+ const tiered = (0, router_1.getModelForTask)('planner');
1183
+ if (tiered.apiKey || tiered.providerName === 'ollama') {
1184
+ curApiKey = tiered.apiKey;
1185
+ curModel = tiered.model;
1186
+ curProvider = tiered.providerName;
1187
+ curApiName = tiered.apiName;
1188
+ console.log(`[Planner] Rotating (tiered) to ${tiered.apiName} (${curProvider}/${curModel})`);
1189
+ }
1190
+ else {
1191
+ const cfg = (0, index_1.loadConfig)();
1192
+ curApiKey = '';
1193
+ curModel = cfg.model?.activeModel || 'mistral:7b';
1194
+ curProvider = 'ollama';
1195
+ curApiName = 'ollama';
1196
+ console.log(`[Planner] No cloud APIs — falling back to Ollama (${curModel})`);
1197
+ }
1198
+ }
1199
+ catch { }
1200
+ }
1201
+ if (!parsed) {
1202
+ // Final guaranteed attempt with Ollama before giving up
1203
+ // Discover which model is actually installed via api/tags
1204
+ try {
1205
+ const cfg = (0, index_1.loadConfig)();
1206
+ let ollamaModel = process.env.OLLAMA_MODEL || cfg.ollama?.model || 'gemma4:e4b';
1207
+ try {
1208
+ const _ollamaBase = (process.env.OLLAMA_HOST ?? 'http://127.0.0.1:11434').replace(/\/$/, '');
1209
+ const tagsRes = await fetch(`${_ollamaBase}/api/tags`, { signal: AbortSignal.timeout(3000) });
1210
+ if (tagsRes.ok) {
1211
+ const tagsData = await tagsRes.json();
1212
+ const firstModel = tagsData?.models?.[0]?.name;
1213
+ if (firstModel) {
1214
+ ollamaModel = firstModel;
1215
+ console.log(`[Planner] Ollama model discovered via api/tags: ${ollamaModel}`);
1216
+ }
1217
+ }
1218
+ }
1219
+ catch { /* Ollama not running — use config model */ }
1220
+ console.log(`[Planner] All cloud attempts failed — final Ollama attempt (${ollamaModel})`);
1221
+ const raw = await callLLM(messages.map(m => `${m.role}: ${m.content}`).join('\n'), '', ollamaModel, 'ollama');
1222
+ if (raw && raw.trim().length > 0) {
1223
+ const jsonMatch = raw.replace(/```json\s*/g, '').replace(/```\s*/g, '').match(/\{[\s\S]*\}/);
1224
+ if (jsonMatch) {
1225
+ parsed = JSON.parse(jsonMatch[0]);
1226
+ console.log('[Planner] Ollama fallback succeeded');
1227
+ }
1228
+ else {
1229
+ // Repair fallback — Ollama often returns plain text for trivial questions
1230
+ const repair = (0, planResponseRepair_1.repairPlanResponse)(raw);
1231
+ if (repair.plan) {
1232
+ parsed = repair.plan;
1233
+ console.log(`[Planner] Ollama fallback repaired — ${repair.directAnswer ? 'direct answer' : 'recovered plan'}`);
1234
+ }
1235
+ }
1236
+ }
1237
+ }
1238
+ catch (e) {
1239
+ console.warn(`[Planner] Ollama fallback failed: ${e.message}`);
1240
+ }
1241
+ }
1242
+ if (!parsed) {
1243
+ // Keyword-based plan generation — when all LLMs fail, infer tool from message
1244
+ const heuristicPlan = inferPlanFromKeywords(message);
1245
+ if (heuristicPlan) {
1246
+ console.log(`[Planner] Keyword-based plan: ${JSON.stringify(heuristicPlan.plan.map(s => s.tool))}`);
1247
+ parsed = heuristicPlan;
1248
+ }
1249
+ }
1250
+ if (!parsed) {
1251
+ console.warn('[Planner] All LLM attempts failed — respond fallback');
1252
+ return {
1253
+ goal: message,
1254
+ requires_execution: true,
1255
+ plan: [{ step: 1, tool: 'respond', input: { message: "I'm not sure how to help with that right now. Could you rephrase your request?" }, description: 'Fallback response' }],
1256
+ phases: [],
1257
+ };
1258
+ }
1259
+ // Guard against null/empty plan object
1260
+ if (!parsed.plan && !parsed.steps) {
1261
+ return {
1262
+ goal: message,
1263
+ requires_execution: false,
1264
+ plan: [],
1265
+ phases: [],
1266
+ direct_response: parsed.direct_response || "I'll answer directly.",
1267
+ };
1268
+ }
1269
+ // Validate tool names — reject hallucinated tools
1270
+ const rawPlan = (parsed.plan || parsed.steps || []);
1271
+ const validatedPlan = rawPlan.filter((s) => {
1272
+ if (!allTools.includes(s.tool)) {
1273
+ console.warn(`[Planner] Rejected invalid tool: ${s.tool}`);
1274
+ return false;
1275
+ }
1276
+ // Reject old-style placeholder inputs
1277
+ const inputStr = JSON.stringify(s.input || s.args || {});
1278
+ if (inputStr.includes('{{') || inputStr.includes('{result') || inputStr.includes('{output')) {
1279
+ console.warn(`[Planner] Rejected placeholder input in: ${s.tool}`);
1280
+ return false;
1281
+ }
1282
+ return true;
1283
+ });
1284
+ const normalizedPlan = validatedPlan.map((s, idx) => ({
1285
+ step: s.step ?? (idx + 1),
1286
+ tool: s.tool || '',
1287
+ input: s.input || s.args || {},
1288
+ description: s.description || '',
1289
+ }));
1290
+ // Fix step ordering — research before write
1291
+ const orderedPlan = fixStepOrdering(normalizedPlan);
1292
+ // Create phased task plan and workspace
1293
+ const phases = inferPhasesFromSteps(orderedPlan);
1294
+ const taskPlan = planTool_1.planTool.create(message, phases);
1295
+ const workspace = new workspaceMemory_1.WorkspaceMemory(taskPlan.id);
1296
+ workspace.write('goal.txt', message);
1297
+ const candidatePlan = {
1298
+ goal: parsed.goal || message,
1299
+ requires_execution: parsed.requires_execution === true && orderedPlan.length > 0,
1300
+ plan: orderedPlan,
1301
+ direct_response: parsed.direct_response,
1302
+ planId: taskPlan.id,
1303
+ workspaceDir: taskPlan.workspaceDir,
1304
+ phases: taskPlan.phases,
1305
+ };
1306
+ // Validate before returning — log warnings, strip hard-invalid steps
1307
+ const validation = validatePlan(candidatePlan);
1308
+ if (validation.warnings.length > 0) {
1309
+ console.warn(`[Planner] Validation warnings:\n ${validation.warnings.join('\n ')}`);
1310
+ // Carry repair log onto the plan so SSE clients can show ↺ repair events
1311
+ const repairWarnings = validation.warnings.filter(w => w.includes('auto-repaired'));
1312
+ if (repairWarnings.length > 0)
1313
+ candidatePlan.repairLog = repairWarnings;
1314
+ }
1315
+ if (!validation.valid) {
1316
+ console.warn(`[Planner] Plan has validation errors:\n ${validation.errors.join('\n ')}`);
1317
+ // One retry — ask the LLM to fix the plan
1318
+ console.log('[Planner] Retrying with validation errors injected into prompt...');
1319
+ const retryMessages = [
1320
+ ...messages,
1321
+ {
1322
+ role: 'assistant',
1323
+ content: raw.slice(0, 500),
1324
+ },
1325
+ {
1326
+ role: 'user',
1327
+ content: `The plan you produced has errors:\n${validation.errors.join('\n')}\n\nFix these issues and output a corrected JSON plan.`,
1328
+ },
1329
+ ];
1330
+ try {
1331
+ const retryRaw = await callLLM(retryMessages.map(m => `${m.role}: ${m.content}`).join('\n'), curApiKey, curModel, curProvider);
1332
+ const retryMatch = retryRaw.replace(/```json\s*/g, '').replace(/```\s*/g, '').match(/\{[\s\S]*\}/);
1333
+ if (retryMatch) {
1334
+ const retryParsed = JSON.parse(retryMatch[0]);
1335
+ const retryRaw2 = (retryParsed.plan || retryParsed.steps || []);
1336
+ const retryValid = retryRaw2.filter((s) => allTools.includes(s.tool));
1337
+ const retryNorm = retryValid.map((s, idx) => ({
1338
+ step: s.step ?? (idx + 1),
1339
+ tool: s.tool || '',
1340
+ input: s.input || s.args || {},
1341
+ description: s.description || '',
1342
+ }));
1343
+ const retryOrdered = fixStepOrdering(retryNorm);
1344
+ if (retryOrdered.length > 0) {
1345
+ candidatePlan.plan = retryOrdered;
1346
+ console.log(`[Planner] Retry succeeded: ${retryOrdered.length} valid steps`);
1347
+ }
1348
+ }
1349
+ }
1350
+ catch (e) {
1351
+ console.warn(`[Planner] Retry failed: ${e.message}`);
1352
+ }
1353
+ }
1354
+ return candidatePlan;
1355
+ }
1356
+ // ── Plan validation ────────────────────────────────────────────
1357
+ // Called after planWithLLM — rejects structurally bad plans before execution.
1358
+ const VALID_TOOLS = [
1359
+ 'web_search', 'fetch_page', 'fetch_url', 'open_browser', 'browser_extract',
1360
+ 'browser_click', 'browser_type', 'browser_screenshot', 'browser_scroll', 'browser_get_url',
1361
+ 'file_write', 'file_read',
1362
+ 'file_list', 'shell_exec', 'run_python', 'run_node', 'run_powershell',
1363
+ 'system_info', 'notify', 'deep_research', 'get_stocks', 'run_agent', 'git_commit',
1364
+ 'git_push', 'get_market_data', 'get_company_info',
1365
+ 'mouse_move', 'mouse_click', 'keyboard_type', 'keyboard_press',
1366
+ 'screenshot', 'screen_read', 'vision_loop', 'wait',
1367
+ 'code_interpreter_python', 'code_interpreter_node',
1368
+ 'clipboard_read', 'clipboard_write', 'window_list', 'window_focus',
1369
+ 'app_launch', 'app_close',
1370
+ 'watch_folder', 'watch_folder_list',
1371
+ 'send_file_local', 'receive_file_local',
1372
+ 'clarify', 'todo', 'cronjob', 'vision_analyze',
1373
+ 'voice_speak', 'voice_transcribe', 'voice_clone', 'voice_design',
1374
+ 'lookup_skill', 'lookup_tool_schema',
1375
+ 'spawn', 'spawn_subagent', 'swarm',
1376
+ ...slashAsTool_1.SLASH_MIRROR_TOOL_NAMES,
1377
+ ];
1378
+ function validatePlan(plan) {
1379
+ const errors = [];
1380
+ const warnings = [];
1381
+ if (!plan.requires_execution || plan.plan.length === 0) {
1382
+ return { valid: true, errors, warnings };
1383
+ }
1384
+ for (const step of plan.plan) {
1385
+ // Check tool name — attempt fuzzy repair before flagging as error
1386
+ if (!VALID_TOOLS.includes(step.tool)) {
1387
+ const repair = (0, toolNameRepair_1.repairToolName)(step.tool, VALID_TOOLS);
1388
+ if (repair) {
1389
+ warnings.push(`Step ${step.step}: auto-repaired tool "${repair.original}" → "${repair.repaired}" (edit distance ${repair.distance})`);
1390
+ console.log(`[ToolRepair] ↺ "${repair.original}" → "${repair.repaired}" (distance ${repair.distance})`);
1391
+ step.tool = repair.repaired; // mutate in-place — plan will execute with correct name
1392
+ }
1393
+ else {
1394
+ errors.push(`Step ${step.step}: unknown tool "${step.tool}"`);
1395
+ continue;
1396
+ }
1397
+ }
1398
+ const input = step.input || {};
1399
+ // Tool-specific required field checks
1400
+ switch (step.tool) {
1401
+ case 'web_search':
1402
+ if (!input.query && !input.topic && !input.command) {
1403
+ errors.push(`Step ${step.step}: web_search requires a "query" field`);
1404
+ }
1405
+ break;
1406
+ case 'deep_research':
1407
+ if (!input.topic && !input.query && !input.command) {
1408
+ errors.push(`Step ${step.step}: deep_research requires a "topic" field`);
1409
+ }
1410
+ break;
1411
+ case 'file_write':
1412
+ if (!input.path && !input.file) {
1413
+ errors.push(`Step ${step.step}: file_write requires a "path" field`);
1414
+ }
1415
+ if (input.content === undefined && input.content !== '') {
1416
+ warnings.push(`Step ${step.step}: file_write has no "content" — will write empty file`);
1417
+ }
1418
+ break;
1419
+ case 'file_read':
1420
+ if (!input.path && !input.file) {
1421
+ errors.push(`Step ${step.step}: file_read requires a "path" field`);
1422
+ }
1423
+ break;
1424
+ case 'open_browser':
1425
+ if (!input.url && !input.command) {
1426
+ errors.push(`Step ${step.step}: open_browser requires a "url" field`);
1427
+ }
1428
+ break;
1429
+ case 'shell_exec':
1430
+ if (!input.command && !input.cmd) {
1431
+ errors.push(`Step ${step.step}: shell_exec requires a "command" field`);
1432
+ }
1433
+ break;
1434
+ case 'run_python':
1435
+ case 'run_node':
1436
+ if (!input.script && !input.code && !input.command) {
1437
+ errors.push(`Step ${step.step}: ${step.tool} requires a "script" field`);
1438
+ }
1439
+ break;
1440
+ case 'fetch_page':
1441
+ case 'fetch_url':
1442
+ if (!input.url && !input.command) {
1443
+ errors.push(`Step ${step.step}: ${step.tool} requires a "url" field`);
1444
+ }
1445
+ break;
1446
+ case 'vision_loop':
1447
+ if (!input.goal) {
1448
+ errors.push(`Step ${step.step}: vision_loop requires a "goal" field`);
1449
+ }
1450
+ break;
1451
+ case 'wait':
1452
+ if (!input.ms && input.ms !== 0) {
1453
+ warnings.push(`Step ${step.step}: wait has no "ms" — will default to 1000ms`);
1454
+ }
1455
+ break;
1456
+ }
1457
+ // Reject residual placeholder patterns that were not caught by planner
1458
+ const inputStr = JSON.stringify(input);
1459
+ if (/\{\{|\{result|\{output|\bPREVIOUS_OUTPUT\b/.test(inputStr) && step.tool !== 'file_write') {
1460
+ if (step.step === 1) {
1461
+ warnings.push(`Step 1: PREVIOUS_OUTPUT is invalid for the first step (no prior output). Provide a literal input.`);
1462
+ }
1463
+ else {
1464
+ warnings.push(`Step ${step.step}: input contains placeholder — may fail at runtime`);
1465
+ }
1466
+ }
1467
+ }
1468
+ return {
1469
+ valid: errors.length === 0,
1470
+ errors,
1471
+ warnings,
1472
+ };
1473
+ }
1474
+ // ── Smart replan on failure ────────────────────────────────────
1475
+ const MAX_REPLANS = 2;
1476
+ async function handleToolFailure(replanState, failedTool, error, userMessage, completedResults, apiKey, model, provider) {
1477
+ const existing = replanState.failedSteps.get(failedTool);
1478
+ if (existing) {
1479
+ existing.attempts++;
1480
+ existing.error = error;
1481
+ }
1482
+ else {
1483
+ replanState.failedSteps.set(failedTool, { error, attempts: 1 });
1484
+ }
1485
+ if (replanState.replanCount >= MAX_REPLANS) {
1486
+ console.log('[Replan] Max replans reached — reporting failure');
1487
+ return null;
1488
+ }
1489
+ replanState.replanCount++;
1490
+ const succeeded = completedResults.filter(r => r.success);
1491
+ const failed = Array.from(replanState.failedSteps.entries());
1492
+ console.log(`[Replan] Replanning (${replanState.replanCount}/${MAX_REPLANS}) after ${failedTool} failed: ${error.slice(0, 80)}`);
1493
+ const replanContext = `Previous approach failed. Use a DIFFERENT strategy.\n\n` +
1494
+ `Original request: ${userMessage}\n\n` +
1495
+ `Already completed:\n` +
1496
+ (succeeded.map(s => `✅ ${s.tool}: ${s.output.substring(0, 100)}`).join('\n') || 'Nothing yet') +
1497
+ `\n\nWhat failed:\n` +
1498
+ failed.map(([tool, f]) => `❌ ${tool}: ${f.error} (tried ${f.attempts}x)`).join('\n') +
1499
+ `\n\nRULES:\n` +
1500
+ `- Do NOT retry ${failedTool} with same approach\n` +
1501
+ `- Use a completely different tool or strategy\n` +
1502
+ `- Build on completed steps — don't redo them\n` +
1503
+ `- If API failed, try different data source\n` +
1504
+ `- If browser failed on a site, try fetch_url instead`;
1505
+ try {
1506
+ const newPlan = await planWithLLM(replanContext, [], apiKey, model, provider);
1507
+ if (newPlan?.plan?.length > 0) {
1508
+ console.log(`[Replan] New plan: ${newPlan.plan.map(s => s.tool).join(' → ')}`);
1509
+ return newPlan.plan;
1510
+ }
1511
+ }
1512
+ catch (e) {
1513
+ console.warn(`[Replan] planWithLLM failed: ${e.message}`);
1514
+ }
1515
+ return null;
1516
+ }
1517
+ // ── Sprint 28: shouldReplan ────────────────────────────────────
1518
+ // After each failed step, ask the LLM: should we replan?
1519
+ async function shouldReplan(originalGoal, completedSteps, failedStep, failureReason, apiKey, model, provider) {
1520
+ const prompt = `You are replanning a failed task.
1521
+
1522
+ Original goal: "${originalGoal}"
1523
+
1524
+ Steps completed so far:
1525
+ ${completedSteps.map((s, i) => `${i + 1}. ${s.tool}: ${s.success ? 'succeeded' : 'failed'}`).join('\n') || 'None'}
1526
+
1527
+ Failed step: ${failedStep.tool}
1528
+ Failure reason: ${failureReason}
1529
+
1530
+ Should I replan with a different approach, or retry the same step?
1531
+
1532
+ Respond in JSON only:
1533
+ {
1534
+ "replan": true/false,
1535
+ "reason": "why",
1536
+ "newApproach": "describe the new approach if replanning, or null"
1537
+ }`;
1538
+ try {
1539
+ const raw = await callLLM(prompt, apiKey, model, provider);
1540
+ const match = raw.match(/\{[\s\S]*\}/);
1541
+ const parsed = JSON.parse(match?.[0] || '{}');
1542
+ return { replan: parsed.replan === true, newApproach: parsed.newApproach || undefined };
1543
+ }
1544
+ catch {
1545
+ return { replan: false };
1546
+ }
1547
+ }
1548
+ // ── STEP 2: executePlan ────────────────────────────────────────
1549
+ // ── validateResultQuality — lightweight output sanity check ──
1550
+ function validateResultQuality(tool, input, output) {
1551
+ if (tool === 'web_search') {
1552
+ if (!output || output === '[]' || output === 'No results') {
1553
+ return { valid: false, reason: 'Empty search results' };
1554
+ }
1555
+ try {
1556
+ const results = typeof output === 'string' ? JSON.parse(output) : output;
1557
+ if (Array.isArray(results) && results.length === 0) {
1558
+ return { valid: false, reason: 'Zero search results' };
1559
+ }
1560
+ }
1561
+ catch { }
1562
+ }
1563
+ if (tool === 'fetch_url' || tool === 'fetch_page') {
1564
+ const text = String(output).toLowerCase();
1565
+ if (text.includes('404') && text.includes('not found')) {
1566
+ return { valid: false, reason: '404 page returned' };
1567
+ }
1568
+ if (text.includes('403') && text.includes('forbidden')) {
1569
+ return { valid: false, reason: '403 forbidden' };
1570
+ }
1571
+ if (text.length < 50) {
1572
+ return { valid: false, reason: 'Suspiciously short page content' };
1573
+ }
1574
+ }
1575
+ if (tool === 'get_market_data') {
1576
+ const text = String(output);
1577
+ if (text.includes('error') || text.includes('failed') || text.includes('null')) {
1578
+ return { valid: false, reason: 'Market data returned error' };
1579
+ }
1580
+ }
1581
+ if (tool === 'file_read') {
1582
+ if (!output || String(output).trim().length === 0) {
1583
+ return { valid: false, reason: 'Empty file content' };
1584
+ }
1585
+ }
1586
+ if (tool === 'run_python' || tool === 'run_node' || tool === 'shell_exec') {
1587
+ const text = String(output).toLowerCase();
1588
+ if (text.includes('traceback') || text.includes('error:') ||
1589
+ text.includes('exception') || text.includes('syntaxerror')) {
1590
+ return { valid: false, reason: 'Code execution error in output' };
1591
+ }
1592
+ }
1593
+ if (tool === 'open_browser') {
1594
+ const text = String(output).toLowerCase();
1595
+ if (text.includes('err_') || text.includes('timed out') ||
1596
+ text.includes('cannot navigate')) {
1597
+ return { valid: false, reason: 'Browser navigation failed' };
1598
+ }
1599
+ }
1600
+ return { valid: true };
1601
+ }
1602
+ // ── LESSONS.md — permanent failure rules ──────────────────────
1603
+ // Auto-appended on task failure. Injected into every planning session.
1604
+ const LESSONS_PATH = nodePath.join(process.cwd(), 'workspace', 'LESSONS.md');
1605
+ const LESSONS_CAP = 50;
1606
+ const LESSONS_SUMMARIZE_AT = 25; // when cap exceeded, summarize oldest N lessons
1607
+ function loadLessons() {
1608
+ try {
1609
+ if (!nodeFs.existsSync(LESSONS_PATH))
1610
+ return '';
1611
+ return nodeFs.readFileSync(LESSONS_PATH, 'utf-8');
1612
+ }
1613
+ catch {
1614
+ return '';
1615
+ }
1616
+ }
1617
+ function appendLesson(lesson) {
1618
+ try {
1619
+ nodeFs.mkdirSync(nodePath.dirname(LESSONS_PATH), { recursive: true });
1620
+ const today = new Date().toISOString().split('T')[0];
1621
+ const newLine = `\n${lesson.startsWith('[') ? lesson : `[${today}] ${lesson}`}`;
1622
+ let content = nodeFs.existsSync(LESSONS_PATH)
1623
+ ? nodeFs.readFileSync(LESSONS_PATH, 'utf-8')
1624
+ : '# LESSONS.md — Permanent Failure Rules\n\n## Rules\n';
1625
+ // Count existing lesson lines (numbered lines in ## Rules section)
1626
+ const lessonLines = content
1627
+ .split('\n')
1628
+ .filter(l => /^\d+\./.test(l.trim()));
1629
+ if (lessonLines.length >= LESSONS_CAP) {
1630
+ // Summarize oldest LESSONS_SUMMARIZE_AT lessons into 5 consolidated rules
1631
+ console.log(`[Lessons] Cap reached (${lessonLines.length}). Summarizing oldest ${LESSONS_SUMMARIZE_AT} lessons.`);
1632
+ const oldest = lessonLines.slice(0, LESSONS_SUMMARIZE_AT);
1633
+ const remaining = lessonLines.slice(LESSONS_SUMMARIZE_AT);
1634
+ const summarized = [
1635
+ `[consolidated] Avoid retrying tools that fail with permission or auth errors — report immediately.`,
1636
+ `[consolidated] When web_search returns empty, rephrase with different keywords before retrying.`,
1637
+ `[consolidated] Do not use error-string outputs as valid data — fall back to alternative tools.`,
1638
+ `[consolidated] When replan is triggered repeatedly for the same goal, stop and report.`,
1639
+ `[consolidated] Browser navigation failures (ERR_/timeout) require a fresh approach, not a retry.`,
1640
+ ];
1641
+ const headerLines = content.split('\n').filter(l => !(/^\d+\./.test(l.trim())));
1642
+ const newRules = [...summarized, ...remaining, lesson.startsWith('[') ? lesson : `[${today}] ${lesson}`];
1643
+ const numbered = newRules.map((r, i) => `${i + 1}. ${r.replace(/^\d+\.\s*/, '')}`);
1644
+ const headerText = headerLines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
1645
+ content = `${headerText}\n\n${numbered.join('\n')}\n`;
1646
+ console.log(`[Lessons] Summarized ${oldest.length} old lessons → 5 rules. Total: ${numbered.length}`);
1647
+ }
1648
+ else {
1649
+ const nextNum = lessonLines.length + 1;
1650
+ content = content.trimEnd() + `\n${nextNum}.${newLine}\n`;
1651
+ }
1652
+ nodeFs.writeFileSync(LESSONS_PATH, content, 'utf-8');
1653
+ console.log(`[Lessons] Appended: ${lesson.slice(0, 80)}`);
1654
+ }
1655
+ catch (e) {
1656
+ console.error('[Lessons] Failed to append lesson:', e.message);
1657
+ }
1658
+ }
1659
+ // ── executeToolWithRetry — step-level retry with exponential backoff ──
1660
+ // Tools that mutate state are excluded from retry to prevent double-execution.
1661
+ const NO_RETRY_TOOLS = new Set([
1662
+ 'shell_exec', 'run_python', 'run_node', 'notify',
1663
+ 'mouse_click', 'keyboard_type', 'keyboard_press',
1664
+ 'app_launch', 'app_close',
1665
+ 'open_browser', 'browser_extract', 'browser_screenshot', 'browser_click', 'browser_type', 'browser_scroll', 'browser_get_url',
1666
+ ]);
1667
+ async function executeToolWithRetry(tool, input, maxRetries = 2) {
1668
+ const retryable = !NO_RETRY_TOOLS.has(tool);
1669
+ const effectiveMax = retryable ? maxRetries : 0;
1670
+ for (let attempt = 0; attempt <= effectiveMax; attempt++) {
1671
+ try {
1672
+ const result = await (0, toolRegistry_1.executeTool)(tool, input);
1673
+ if (result.success) {
1674
+ const quality = validateResultQuality(tool, input, result.output || result);
1675
+ if (!quality.valid) {
1676
+ console.log(`[Quality] ${tool} returned but quality check failed: ${quality.reason}`);
1677
+ if (attempt < effectiveMax) {
1678
+ const delay = Math.min(1000 * Math.pow(2, attempt), 5000);
1679
+ console.log(`[Quality] Retrying ${tool} in ${delay}ms`);
1680
+ await new Promise(r => setTimeout(r, delay));
1681
+ continue;
1682
+ }
1683
+ console.log(`[Quality] ${tool} — accepting low-quality result after ${effectiveMax} retries`);
1684
+ appendLesson(`${tool} produced low-quality output (${quality.reason}) after ${effectiveMax} retries — consider alternative approach for this tool.`);
1685
+ }
1686
+ return result;
1687
+ }
1688
+ if (attempt < effectiveMax) {
1689
+ const delay = Math.min(1000 * Math.pow(2, attempt), 5000);
1690
+ console.log(`[Exec] ${tool} failed, retrying in ${delay}ms (attempt ${attempt + 1}/${effectiveMax})`);
1691
+ await new Promise(r => setTimeout(r, delay));
1692
+ }
1693
+ else {
1694
+ return result;
1695
+ }
1696
+ }
1697
+ catch (error) {
1698
+ if (attempt >= effectiveMax)
1699
+ throw error;
1700
+ const delay = Math.min(1000 * Math.pow(2, attempt), 5000);
1701
+ console.log(`[Exec] ${tool} threw error, retrying in ${delay}ms`);
1702
+ await new Promise(r => setTimeout(r, delay));
1703
+ }
1704
+ }
1705
+ appendLesson(`${tool} failed after ${effectiveMax} retries — avoid this tool or approach for similar tasks.`);
1706
+ return { success: false, output: '', error: 'Max retries exceeded', duration: 0, retries: effectiveMax };
1707
+ }
1708
+ // —— Sprint 8: dependency-group builder ——————————————
1709
+ // Groups consecutive tool steps into batches: parallel-safe tools are
1710
+ // batched together; sequential tools break the batch.
1711
+ const PARALLEL_SAFE = new Set([
1712
+ 'web_search', 'system_info', 'get_stocks', 'get_market_data',
1713
+ 'social_research', 'fetch_url', 'fetch_page', 'get_company_info',
1714
+ 'deep_research', 'code_interpreter_python', 'code_interpreter_node',
1715
+ 'clipboard_read', 'window_list', 'watch_folder_list',
1716
+ 'get_calendar', 'read_email', 'get_natural_events', 'ingest_youtube',
1717
+ ]);
1718
+ const SEQUENTIAL_ONLY = new Set([
1719
+ 'file_write', 'run_python', 'run_node', 'shell_exec',
1720
+ 'open_browser', 'browser_click', 'browser_type', 'browser_extract',
1721
+ 'mouse_move', 'mouse_click', 'keyboard_type', 'keyboard_press',
1722
+ 'screenshot', 'screen_read', 'vision_loop', 'notify', 'wait',
1723
+ 'clipboard_write', 'window_focus', 'app_launch', 'app_close',
1724
+ 'watch_folder',
1725
+ ]);
1726
+ function buildDependencyGroups(steps) {
1727
+ const groups = [];
1728
+ let currentGroup = [];
1729
+ for (const step of steps) {
1730
+ const inputStr = JSON.stringify(step.input || {});
1731
+ const dependsOnPrevious = inputStr.includes('PREVIOUS_OUTPUT') || SEQUENTIAL_ONLY.has(step.tool);
1732
+ if (PARALLEL_SAFE.has(step.tool) && !dependsOnPrevious) {
1733
+ currentGroup.push(step);
1734
+ }
1735
+ else {
1736
+ if (currentGroup.length > 0) {
1737
+ groups.push([...currentGroup]);
1738
+ currentGroup = [];
1739
+ }
1740
+ groups.push([step]);
1741
+ }
1742
+ }
1743
+ if (currentGroup.length > 0)
1744
+ groups.push(currentGroup);
1745
+ return groups;
1746
+ }
1747
+ async function executePlan(plan, onStep, onPhaseChange, existingState, replanApiKey, replanModel, replanProvider) {
1748
+ executionInterrupted = false; // reset on each new plan execution
1749
+ // ── Iteration budget ─────────────────────────────────────────
1750
+ const budget = {
1751
+ maxIterations: Math.max(plan.plan.length + 5, 15),
1752
+ currentIteration: 0,
1753
+ cautionThreshold: 0.7,
1754
+ warningThreshold: 0.9,
1755
+ };
1756
+ _activeBudget = budget;
1757
+ const results = [];
1758
+ const stepOutputs = {};
1759
+ const planStart = Date.now();
1760
+ const replanState = { failedSteps: new Map(), replanCount: 0 };
1761
+ console.log(`[ExecutePlan] Starting: ${plan.plan.length} steps, goal: "${plan.goal.slice(0, 60)}"`);
1762
+ // Workflow tracking — feed the Watch Mode node graph
1763
+ (0, workflowTracker_1.startWorkflow)(plan.goal);
1764
+ (0, workflowTracker_1.addNode)({ id: 'main', agent: 'aiden', label: plan.goal.slice(0, 50), status: 'active', toolCalls: 0, startedAt: Date.now() });
1765
+ // Workspace memory for persisting intermediate artifacts
1766
+ const workspace = plan.planId ? new workspaceMemory_1.WorkspaceMemory(plan.planId) : null;
1767
+ // Initialize or reuse persistent task state (enables crash recovery)
1768
+ const taskId = plan.planId || `task_${Date.now()}`;
1769
+ const state = existingState || taskState_1.taskStateManager.create(taskId, plan.goal, plan.plan.length, plan.planId);
1770
+ // Restore step outputs from already-completed steps so PREVIOUS_OUTPUT works on resume
1771
+ for (const savedStep of state.steps) {
1772
+ if (savedStep.status === 'completed' && savedStep.output) {
1773
+ stepOutputs[savedStep.index] = savedStep.output;
1774
+ }
1775
+ }
1776
+ // Maps each tool to its capability bucket (for phase transition detection)
1777
+ const capabilityMap = {
1778
+ web_search: 'research', fetch_page: 'research',
1779
+ deep_research: 'research', fetch_url: 'research',
1780
+ get_stocks: 'research',
1781
+ open_browser: 'browsing', browser_click: 'browsing',
1782
+ browser_extract: 'browsing', browser_type: 'browsing',
1783
+ mouse_move: 'browsing', mouse_click: 'browsing',
1784
+ keyboard_type: 'browsing', keyboard_press: 'browsing',
1785
+ screenshot: 'browsing', screen_read: 'browsing',
1786
+ vision_loop: 'browsing',
1787
+ file_write: 'writing', file_read: 'reading',
1788
+ file_list: 'reading', shell_exec: 'execution',
1789
+ run_python: 'execution', run_node: 'execution',
1790
+ system_info: 'execution', notify: 'execution',
1791
+ clipboard_read: 'execution', clipboard_write: 'execution',
1792
+ window_list: 'execution', window_focus: 'execution',
1793
+ app_launch: 'execution', app_close: 'execution',
1794
+ watch_folder: 'execution', watch_folder_list: 'execution',
1795
+ };
1796
+ let lastCapability = '';
1797
+ let currentPhaseIdx = 0;
1798
+ const totalPhases = plan.phases?.length || 1;
1799
+ // —— Sprint 8: single-step executor ————————————————————
1800
+ // Called by executePlan for both sequential (group.length===1) and parallel paths.
1801
+ async function executeSingleStep(step, stepOutputs, state, plan, workspace, onStep) {
1802
+ // BUDGET CHECK
1803
+ if (taskState_1.taskStateManager.isOverBudget(state)) {
1804
+ const budgetMsg = `Token budget exceeded (${state.tokenUsage}/${state.tokenLimit}) — task stopped`;
1805
+ console.warn(`[AgentLoop] ${budgetMsg}`);
1806
+ taskState_1.taskStateManager.fail(state, budgetMsg);
1807
+ return { step: step.step, tool: step.tool, input: step.input, success: false, output: '', error: budgetMsg, duration: 0 };
1808
+ }
1809
+ const totalSteps = plan.plan.length;
1810
+ const stepStart = Date.now();
1811
+ console.log(`[Exec] Step ${step.step}/${totalSteps}: ${step.tool} — RUNNING`);
1812
+ console.log(`[ExecutePlan] Step ${step.step}: ${step.tool} — input: ${JSON.stringify(step.input).slice(0, 100)}`);
1813
+ livePulse_1.livePulse.tool('Aiden', step.tool, JSON.stringify(step.input).slice(0, 80));
1814
+ // Validate tool exists
1815
+ if (!toolRegistry_1.TOOLS[step.tool]) {
1816
+ const stepResult = {
1817
+ step: step.step, tool: step.tool, input: step.input,
1818
+ success: false, output: '',
1819
+ error: `Tool "${step.tool}" does not exist. Available: ${Object.keys(toolRegistry_1.TOOLS).slice(0, 8).join(', ')}`,
1820
+ duration: 0,
1821
+ };
1822
+ onStep(step, stepResult);
1823
+ livePulse_1.livePulse.error('Aiden', `Invalid tool: ${step.tool}`);
1824
+ return stepResult;
1825
+ }
1826
+ // Tools that legitimately take zero input
1827
+ const NO_INPUT_TOOLS = ['system_info', 'screenshot', 'get_hardware', 'screen_read', 'vision_loop', 'health_check', 'respond'];
1828
+ if (!NO_INPUT_TOOLS.includes(step.tool)) {
1829
+ if (!step.input || Object.keys(step.input).length === 0) {
1830
+ console.log(`[ExecutePlan] Skipping step ${step.step} (${step.tool}) — empty input`);
1831
+ return { step: step.step, tool: step.tool, input: step.input, success: false, output: '', error: 'empty input', duration: 0 };
1832
+ }
1833
+ }
1834
+ // Resolve PREVIOUS_OUTPUT and {{step_N_output}} tokens
1835
+ let resolvedInput = resolvePreviousOutput(step.input, stepOutputs, step.step);
1836
+ // Mark step started in persistent state
1837
+ taskState_1.taskStateManager.startStep(state, step.step, step.tool, resolvedInput);
1838
+ // Emit status before tool execution
1839
+ emitStatus(TOOL_ACTION[step.tool] ?? 'tooling', toolStatusDetail(step.tool, resolvedInput));
1840
+ // Execute the tool (step-level retry + per-tool timeout)
1841
+ let toolResult = await executeToolWithRetry(step.tool, resolvedInput);
1842
+ // file_write fallback — retry at Desktop if original path failed
1843
+ if (!toolResult.success && step.tool === 'file_write' && resolvedInput.path) {
1844
+ const desktopPath = nodePath.join(nodeOs.homedir(), 'Desktop', nodePath.basename(resolvedInput.path));
1845
+ if (desktopPath !== resolvedInput.path) {
1846
+ livePulse_1.livePulse.error('Aiden', `file_write failed — retrying at Desktop: ${desktopPath}`);
1847
+ const fallback = await (0, toolRegistry_1.executeTool)('file_write', { ...resolvedInput, path: desktopPath });
1848
+ if (fallback.success) {
1849
+ toolResult = { ...fallback, output: fallback.output + ' (saved to Desktop)' };
1850
+ resolvedInput = { ...resolvedInput, path: desktopPath };
1851
+ }
1852
+ }
1853
+ }
1854
+ if (toolResult.retries > 0) {
1855
+ livePulse_1.livePulse.act('Aiden', `${step.tool} succeeded after ${toolResult.retries} retry(s)`);
1856
+ }
1857
+ let stepResult = {
1858
+ step: step.step, tool: step.tool, input: resolvedInput,
1859
+ success: toolResult.success,
1860
+ output: toolResult.output || '',
1861
+ error: toolResult.error,
1862
+ duration: toolResult.duration,
1863
+ };
1864
+ // Persist significant outputs to workspace
1865
+ if (toolResult.success && workspace && toolResult.output.length > 300) {
1866
+ workspace.write(`step_${step.step}_${step.tool}.txt`, toolResult.output);
1867
+ }
1868
+ // Verify file_write actually landed on disk
1869
+ if (toolResult.success && step.tool === 'file_write') {
1870
+ const targetPath = resolvedInput.path || '';
1871
+ if (targetPath && !nodeFs.existsSync(targetPath)) {
1872
+ stepResult.success = false;
1873
+ stepResult.error = `Verification failed: file not found at ${targetPath}`;
1874
+ }
1875
+ }
1876
+ const execStatus = stepResult.success ? 'SUCCESS' : 'FAILED';
1877
+ const execDuration = Date.now() - stepStart;
1878
+ console.log(`[Exec] Step ${step.step}/${totalSteps}: ${step.tool} — ${execStatus} (${execDuration}ms)`);
1879
+ if (!stepResult.success) {
1880
+ console.log(`[Exec] Step ${step.step}: ${step.tool} — FAILED after ${toolResult.retries ?? 0} retries: ${stepResult.error || 'unknown error'}`);
1881
+ }
1882
+ console.log(`[ExecutePlan] Step ${step.step} result: ${stepResult.success ? '✓' : '✗'} ${stepResult.error || stepResult.output?.slice(0, 80) || ''}`);
1883
+ console.log(`[Tool] ${step.tool} (Tier ${(0, toolRegistry_1.getToolTier)(step.tool)}) — ${stepResult.duration}ms`);
1884
+ stepOutputs[step.step] = stepResult.output;
1885
+ (0, workflowTracker_1.updateNode)('main', { currentTool: step.tool, toolCalls: Object.keys(stepOutputs).length, tier: (0, toolRegistry_1.getToolTier)(step.tool), status: 'active' });
1886
+ // Persist step to executions log for crash recovery / audit
1887
+ try {
1888
+ const execDir = nodePath.join(process.cwd(), 'workspace', 'executions');
1889
+ nodeFs.mkdirSync(execDir, { recursive: true });
1890
+ const execFile = nodePath.join(execDir, `exec_${state.id}.json`);
1891
+ const existing = nodeFs.existsSync(execFile)
1892
+ ? JSON.parse(nodeFs.readFileSync(execFile, 'utf8'))
1893
+ : { id: `exec_${state.id}`, goal: plan.goal, steps: [], status: 'in_progress', startedAt: Date.now() };
1894
+ existing.steps = existing.steps.filter((s) => s.step !== step.step);
1895
+ existing.steps.push({
1896
+ step: step.step,
1897
+ tool: step.tool,
1898
+ status: stepResult.success ? 'success' : 'failed',
1899
+ duration: execDuration,
1900
+ timestamp: new Date().toISOString(),
1901
+ error: stepResult.error,
1902
+ });
1903
+ existing.totalDuration = Date.now() - (existing.startedAt || Date.now());
1904
+ nodeFs.writeFileSync(execFile, JSON.stringify(existing, null, 2));
1905
+ }
1906
+ catch { /* non-blocking — never crash the agent loop */ }
1907
+ onStep(step, stepResult);
1908
+ // Audit trail
1909
+ auditTrail_1.auditTrail.record({
1910
+ action: 'tool',
1911
+ tool: step.tool,
1912
+ input: JSON.stringify(step.input).slice(0, 200),
1913
+ output: stepResult.output?.slice(0, 200),
1914
+ durationMs: stepResult.duration,
1915
+ success: stepResult.success,
1916
+ error: stepResult.error,
1917
+ goal: plan.goal,
1918
+ traceId: plan.planId,
1919
+ });
1920
+ // Fire after_tool_call hook (non-blocking) — feeds instinct system
1921
+ (0, hooks_1.fireHook)('after_tool_call', {
1922
+ toolName: step.tool,
1923
+ input: resolvedInput,
1924
+ success: stepResult.success,
1925
+ }).catch(() => { });
1926
+ // Persist step result to task state
1927
+ if (stepResult.success) {
1928
+ taskState_1.taskStateManager.completeStep(state, step.step, stepResult.output, stepResult.duration);
1929
+ livePulse_1.livePulse.done('Aiden', `${step.tool} ✓ ${stepResult.output.slice(0, 60)}`);
1930
+ }
1931
+ else {
1932
+ taskState_1.taskStateManager.failStep(state, step.step, stepResult.error || 'unknown error');
1933
+ livePulse_1.livePulse.error('Aiden', `${step.tool} failed: ${stepResult.error}`);
1934
+ }
1935
+ return stepResult;
1936
+ }
1937
+ // —— Sprint 8: group-based dispatch (parallel where safe) ———————————
1938
+ const groups = buildDependencyGroups(plan.plan);
1939
+ console.log(`[ExecutePlan] Dependency groups: ${groups.map(g => g.length === 1 ? g[0].tool : `[${g.map(s => s.tool).join('+')}]`).join(' → ')}`);
1940
+ if ((0, parallelExecutor_1.hasParallelism)(groups))
1941
+ console.log(`[ExecutePlan] Parallel execution enabled — ${groups.filter(g => g.length > 1).length} concurrent batch(es) detected`);
1942
+ let _gi = 0;
1943
+ while (_gi < groups.length) {
1944
+ const group = groups[_gi++];
1945
+ // Phase-transition detection — use first step of each group
1946
+ const thisCap = capabilityMap[group[0].tool] || 'execution';
1947
+ if (thisCap !== lastCapability && lastCapability !== '') {
1948
+ if (plan.planId) {
1949
+ planTool_1.planTool.advancePhase(plan.planId, `Completed ${lastCapability}`);
1950
+ currentPhaseIdx++;
1951
+ const nextPhase = planTool_1.planTool.getCurrentPhase(plan.planId);
1952
+ if (nextPhase && onPhaseChange) {
1953
+ onPhaseChange(nextPhase, currentPhaseIdx, totalPhases);
1954
+ }
1955
+ }
1956
+ }
1957
+ lastCapability = thisCap;
1958
+ // Skip already-completed steps (crash recovery idempotency)
1959
+ const unskipped = group.filter(s => !taskState_1.taskStateManager.isStepCompleted(state, s.step));
1960
+ for (const s of group) {
1961
+ if (taskState_1.taskStateManager.isStepCompleted(state, s.step)) {
1962
+ console.log(`[AgentLoop] Step ${s.step} (${s.tool}) already completed — skipping`);
1963
+ const savedStep = state.steps.find(ss => ss.index === s.step);
1964
+ if (savedStep?.output)
1965
+ stepOutputs[s.step] = savedStep.output;
1966
+ }
1967
+ }
1968
+ if (unskipped.length === 0)
1969
+ continue;
1970
+ if (unskipped.length === 1) {
1971
+ // —— Sequential single step ————————————————
1972
+ const step = unskipped[0];
1973
+ // ── Budget: increment before execution ────────────────────────
1974
+ budget.currentIteration++;
1975
+ if (budget.currentIteration >= budget.maxIterations) {
1976
+ console.log('[Budget] Exhausted — forcing final response');
1977
+ const summary = results.filter(s => s.success)
1978
+ .map(s => `✓ ${s.tool}: ${String(s.output).substring(0, 100)}`).join('\n');
1979
+ results.push({
1980
+ step: step.step, tool: 'budget_exhausted', input: {},
1981
+ success: false, output: `I've reached my iteration limit. Here's what I completed:\n\n${summary}\n\nLet me know if you need me to continue.`,
1982
+ error: 'iteration budget exhausted', duration: 0,
1983
+ });
1984
+ break;
1985
+ }
1986
+ const stepResult = await executeSingleStep(step, stepOutputs, state, plan, workspace, onStep);
1987
+ stepOutputs[step.step] = stepResult.output;
1988
+ // ── Budget: append pressure warning to result output ──────────
1989
+ const budgetWarning = getBudgetWarning(budget);
1990
+ if (budgetWarning) {
1991
+ stepResult.output = stepResult.output + '\n\n' + budgetWarning;
1992
+ }
1993
+ results.push(stepResult);
1994
+ // ── Interrupt check ────────────────────────────────────────────
1995
+ if (executionInterrupted) {
1996
+ console.log('[AgentLoop] Execution interrupted by user — stopping early');
1997
+ break;
1998
+ }
1999
+ // ── Smart replan on failure ────────────────────────────────────
2000
+ if (!stepResult.success) {
2001
+ // Resolve credentials: prefer explicit params, then route through getNextAvailableAPI
2002
+ let _rpKey = replanApiKey || '';
2003
+ let _rpModel = replanModel || '';
2004
+ let _rpProvider = replanProvider || '';
2005
+ if (!_rpKey && !_rpModel) {
2006
+ try {
2007
+ const _next = (0, router_1.getNextAvailableAPI)();
2008
+ if (_next) {
2009
+ _rpKey = _next.entry.key.startsWith('env:')
2010
+ ? (process.env[_next.entry.key.replace('env:', '')] || '')
2011
+ : _next.entry.key;
2012
+ _rpModel = _next.entry.model;
2013
+ _rpProvider = _next.entry.provider;
2014
+ }
2015
+ }
2016
+ catch { }
2017
+ }
2018
+ if (_rpKey || _rpProvider === 'ollama') {
2019
+ const newSteps = await handleToolFailure(replanState, step.tool, stepResult.error || 'unknown error', plan.goal, results, _rpKey, _rpModel, _rpProvider);
2020
+ if (newSteps && newSteps.length > 0) {
2021
+ livePulse_1.livePulse.act('Aiden', `Replanning with different strategy (${replanState.replanCount}/${MAX_REPLANS})`);
2022
+ auditTrail_1.auditTrail.record({
2023
+ action: 'system',
2024
+ tool: 'replan',
2025
+ input: `Failed: ${step.tool}`,
2026
+ output: `New plan: ${newSteps.map(s => s.tool).join(' → ')}`,
2027
+ durationMs: 0,
2028
+ success: true,
2029
+ goal: plan.goal,
2030
+ traceId: plan.planId,
2031
+ });
2032
+ const newGroups = buildDependencyGroups(newSteps);
2033
+ groups.splice(_gi, groups.length - _gi, ...newGroups);
2034
+ console.log(`[Replan] Spliced ${newGroups.length} new group(s) into execution from position ${_gi}`);
2035
+ }
2036
+ else if (replanState.replanCount >= MAX_REPLANS) {
2037
+ const failedList = Array.from(replanState.failedSteps.entries())
2038
+ .map(([tool, f]) => `- ${tool}: ${f.error}`)
2039
+ .join('\n');
2040
+ console.log(`[Replan] All ${MAX_REPLANS} replans exhausted for goal: "${plan.goal.slice(0, 60)}"`);
2041
+ appendLesson(`Replan exhausted (${MAX_REPLANS} attempts) for goal: "${plan.goal.slice(0, 80)}". Failed tools: ${Array.from(replanState.failedSteps.keys()).join(', ')}.`);
2042
+ results.push({
2043
+ step: step.step + 1, tool: 'replan_exhausted', input: {},
2044
+ success: false, output: '',
2045
+ error: `Tried ${MAX_REPLANS + 1} different approaches:\n${failedList}\n\nWould you like me to try a different approach?`,
2046
+ duration: 0,
2047
+ });
2048
+ }
2049
+ }
2050
+ }
2051
+ }
2052
+ else {
2053
+ // —— Parallel group ———————————————————————
2054
+ // Chunk oversized groups so we never exceed MAX_PARALLEL concurrent calls
2055
+ const chunks = unskipped.length > parallelExecutor_1.MAX_PARALLEL ? (0, parallelExecutor_1.chunkSteps)(unskipped, parallelExecutor_1.MAX_PARALLEL) : [unskipped];
2056
+ for (const chunk of chunks) {
2057
+ // ── Budget: one increment per parallel chunk ───────────────────
2058
+ budget.currentIteration++;
2059
+ livePulse_1.livePulse.act('Aiden', `Running ${chunk.length} steps in parallel: ${chunk.map(s => s.tool).join(', ')}`);
2060
+ // Emit parallel metadata onto workflow nodes before dispatch
2061
+ for (const s of chunk) {
2062
+ (0, workflowTracker_1.updateNode)(`step_${s.step}`, { parallel: true, groupSize: chunk.length });
2063
+ }
2064
+ const settled = await Promise.allSettled(chunk.map(step => executeSingleStep(step, stepOutputs, state, plan, workspace, onStep)));
2065
+ for (let i = 0; i < chunk.length; i++) {
2066
+ const s = chunk[i];
2067
+ const result = settled[i];
2068
+ if (result.status === 'fulfilled') {
2069
+ stepOutputs[s.step] = result.value.output;
2070
+ results.push(result.value);
2071
+ }
2072
+ else {
2073
+ const errResult = {
2074
+ step: s.step, tool: s.tool, input: s.input,
2075
+ success: false, output: '', error: String(result.reason), duration: 0,
2076
+ };
2077
+ results.push(errResult);
2078
+ taskState_1.taskStateManager.failStep(state, s.step, errResult.error || 'parallel rejected');
2079
+ livePulse_1.livePulse.error('Aiden', `${s.tool} parallel rejected: ${result.reason}`);
2080
+ }
2081
+ }
2082
+ }
2083
+ }
2084
+ }
2085
+ // Complete final phase
2086
+ if (plan.planId) {
2087
+ planTool_1.planTool.advancePhase(plan.planId, 'All steps completed');
2088
+ }
2089
+ // Finalize task state
2090
+ const allSucceeded = results.every(r => r.success);
2091
+ if (allSucceeded) {
2092
+ taskState_1.taskStateManager.complete(state);
2093
+ }
2094
+ else {
2095
+ const failed = results.filter(r => !r.success).map(r => r.tool).join(', ');
2096
+ taskState_1.taskStateManager.fail(state, failed ? `Steps failed: ${failed}` : 'Incomplete execution');
2097
+ }
2098
+ // Workflow tracking — close the node graph
2099
+ (0, workflowTracker_1.updateNode)('main', { status: allSucceeded ? 'completed' : 'failed', completedAt: Date.now() });
2100
+ (0, workflowTracker_1.completeWorkflow)(allSucceeded ? 'completed' : 'failed');
2101
+ // Record experience for self-learning
2102
+ const filesCreatedInPlan = results
2103
+ .filter(r => r.tool === 'file_write' && r.success && r.input?.path)
2104
+ .map(r => r.input.path)
2105
+ .filter(Boolean);
2106
+ learningMemory_1.learningMemory.record({
2107
+ task: plan.goal,
2108
+ success: allSucceeded,
2109
+ steps: results.map(r => r.tool),
2110
+ duration: Date.now() - planStart,
2111
+ tokenUsage: state.tokenUsage,
2112
+ filesCreated: filesCreatedInPlan,
2113
+ errorMessage: !allSucceeded
2114
+ ? results.find(r => !r.success)?.error
2115
+ : undefined,
2116
+ });
2117
+ // Self-teaching — generate/update SKILL.md for this tool sequence
2118
+ const executedTools = results.map(r => r.tool);
2119
+ const totalDuration = results.reduce((s, r) => s + (r.duration || 0), 0);
2120
+ const anyFailed = results.some(r => !r.success);
2121
+ if (allSucceeded && executedTools.length > 0) {
2122
+ // GrowthEngine — record success for gap-resolution tracking
2123
+ growthEngine_1.growthEngine.logSuccess(plan.goal, executedTools);
2124
+ try {
2125
+ const next = (0, router_1.getNextAvailableAPI)();
2126
+ if (next) {
2127
+ const key = next.entry.key.startsWith('env:')
2128
+ ? (process.env[next.entry.key.replace('env:', '')] || '')
2129
+ : next.entry.key;
2130
+ skillTeacher_1.skillTeacher.recordSuccess(plan.goal, executedTools, totalDuration, callLLM, key, next.entry.model, next.entry.provider).catch(() => { });
2131
+ }
2132
+ }
2133
+ catch { }
2134
+ }
2135
+ else if (anyFailed) {
2136
+ // GrowthEngine — record failure with full error context
2137
+ const firstError = results.find(r => !r.success)?.error ?? 'Unknown error';
2138
+ growthEngine_1.growthEngine.logFailure(plan.goal, firstError, executedTools);
2139
+ skillTeacher_1.skillTeacher.recordFailure(plan.goal, executedTools);
2140
+ }
2141
+ // Execution summary
2142
+ const successCount = results.filter(r => r.success).length;
2143
+ const execTotalMs = Date.now() - planStart;
2144
+ console.log(`[Exec] Complete: ${successCount}/${results.length} steps succeeded in ${execTotalMs}ms`);
2145
+ // Finalize executions log
2146
+ try {
2147
+ const execFile = nodePath.join(process.cwd(), 'workspace', 'executions', `exec_${state.id}.json`);
2148
+ if (nodeFs.existsSync(execFile)) {
2149
+ const log = JSON.parse(nodeFs.readFileSync(execFile, 'utf8'));
2150
+ log.status = allSucceeded ? 'completed' : 'failed';
2151
+ log.totalDuration = execTotalMs;
2152
+ nodeFs.writeFileSync(execFile, JSON.stringify(log, null, 2));
2153
+ }
2154
+ }
2155
+ catch { /* non-blocking */ }
2156
+ return results;
2157
+ }
2158
+ // ── Step ordering fixer ────────────────────────────────────────
2159
+ // Ensures research/fetch steps always run before file_write steps.
2160
+ // Prevents file_write from executing before deep_research has data.
2161
+ function fixStepOrdering(steps) {
2162
+ const researchTools = ['web_search', 'deep_research', 'fetch_url', 'fetch_page'];
2163
+ const writeTools = ['file_write'];
2164
+ const research = steps.filter(s => researchTools.includes(s.tool));
2165
+ const writes = steps.filter(s => writeTools.includes(s.tool));
2166
+ const others = steps.filter(s => !researchTools.includes(s.tool) && !writeTools.includes(s.tool));
2167
+ // Order: research → other → write — re-number steps
2168
+ return [...research, ...others, ...writes]
2169
+ .map((s, i) => ({ ...s, step: i + 1 }));
2170
+ }
2171
+ // Resolve PREVIOUS_OUTPUT and {{step_N_output}} in step inputs
2172
+ function resolvePreviousOutput(input, stepOutputs, currentStep) {
2173
+ const resolved = {};
2174
+ const lastOutput = stepOutputs[currentStep - 1] || '';
2175
+ // Step 1 with PREVIOUS_OUTPUT = planner bug. Log a warning and substitute with
2176
+ // empty string so the tool fails with a clear "no input" error rather than
2177
+ // passing the literal placeholder text to the API.
2178
+ if (currentStep === 1) {
2179
+ const inputStr = JSON.stringify(input);
2180
+ if (inputStr.includes('PREVIOUS_OUTPUT')) {
2181
+ console.warn('[Planner] Step 1 used PREVIOUS_OUTPUT — no previous output exists. Substituting empty string.');
2182
+ }
2183
+ }
2184
+ for (const [key, value] of Object.entries(input)) {
2185
+ if (typeof value === 'string') {
2186
+ resolved[key] = value
2187
+ .replace(/PREVIOUS_OUTPUT/g, lastOutput)
2188
+ .replace(/\{\{step_(\d+)_output\}\}/g, (_, n) => stepOutputs[parseInt(n, 10)] || '');
2189
+ }
2190
+ else {
2191
+ resolved[key] = value;
2192
+ }
2193
+ }
2194
+ return resolved;
2195
+ }
2196
+ // ── STEP 3: respondWithResults ────────────────────────────────
2197
+ function responderSystem(userName, date) {
2198
+ return (0, aidenPersonality_1.AIDEN_RESPONDER_SYSTEM)(userName, date);
2199
+ }
2200
+ async function respondWithResults(originalMessage, plan, results, history, userName, apiKey, model, providerName, onToken, sessionId, goals) {
2201
+ const date = new Date().toLocaleDateString('en-US', {
2202
+ weekday: 'long', month: 'long', day: 'numeric', year: 'numeric',
2203
+ });
2204
+ // Load skill guidance for the response
2205
+ const responseSkills = skillLoader_1.skillLoader.findRelevant(originalMessage, 2);
2206
+ const responseSkillContext = responseSkills.length > 0
2207
+ ? `\nSkill guidance for this response:\n${responseSkills.map(s => `- ${s.name}: ${s.description}`).join('\n')}\n`
2208
+ : '';
2209
+ // Selective skill injection — simple messages (< 15 words, no tool keywords) get no skills;
2210
+ // complex messages get only the relevant subset (already filtered by findRelevant above).
2211
+ // Replaces the old loadAll() dump that injected all ~96 skills into every prompt.
2212
+ const capabilitiesSection = (0, skillLoader_1.isSimpleMessage)(originalMessage)
2213
+ ? ''
2214
+ : (responseSkills.length > 0
2215
+ ? `Relevant skills for this task: ${responseSkills.map(s => `${s.name} (${s.description})`).join(', ')}\n\n`
2216
+ : '');
2217
+ // Knowledge context — relevant chunks from user's uploaded files
2218
+ const knowledgeCtxResponder = knowledgeBase_1.knowledgeBase.buildContext(originalMessage || '');
2219
+ const knowledgeResponderSection = knowledgeCtxResponder
2220
+ ? `\nRELEVANT KNOWLEDGE FROM YOUR FILES:\n${knowledgeCtxResponder}\n`
2221
+ : '';
2222
+ // ── Depth scoring: detect research tasks and force deep analysis ──
2223
+ const isResearch = results.some(r => r.tool === 'deep_research' ||
2224
+ r.tool === 'run_agent' ||
2225
+ (r.tool === 'web_search' && results.length > 1));
2226
+ const depthInstruction = isResearch
2227
+ ? `\n\nRESEARCH RESPONSE REQUIREMENTS:
2228
+ - Minimum 500 words
2229
+ - Must include: Overview, Comparison (table or structured list), Key findings, Trends, Recommendation
2230
+ - Compare entities explicitly: "X is better than Y for Z because..."
2231
+ - Extract specific facts and numbers from the research data
2232
+ - End with a clear Verdict or Recommendation section
2233
+ - DO NOT just summarize — ANALYZE and provide INSIGHTS`
2234
+ : '';
2235
+ // Phase 1: multi-goal numbered output instruction
2236
+ const _goalsToUse = goals && goals.length >= 2 ? goals : (plan.goals && plan.goals.length >= 2 ? plan.goals : null);
2237
+ const multiGoalInstruction = _goalsToUse
2238
+ ? `\n\nMULTI-GOAL RESPONSE — the user had ${_goalsToUse.length} distinct goals:\n${_goalsToUse.map((g, i) => `${i + 1}. ${g}`).join('\n')}\nStructure your response with numbered sections (1., 2., …) that match each goal above. Do not skip any goal.`
2239
+ : '';
2240
+ const executionSummary = results.length
2241
+ ? results.map((r, i) => `Step ${i + 1} [${r.tool}]: ${r.success ? r.output.slice(0, 500) : 'FAILED — ' + r.error}`).join('\n\n')
2242
+ : '';
2243
+ // Inject conversation memory only when the message references past context
2244
+ // (reduces prompt size for routine messages — "hi", "thanks", etc.)
2245
+ const memCtx = (0, skillLoader_1.needsMemory)(originalMessage) ? conversationMemory_1.conversationMemory.buildContext() : '';
2246
+ const memSection = memCtx
2247
+ ? `\nCONVERSATION HISTORY:\n${memCtx}\n\nIf the user asks what we worked on, what was researched, or references previous work — answer from this history.\n`
2248
+ : '';
2249
+ // Entity graph — 1-line summary only (never dump full graph into prompt)
2250
+ const entityStats = entityGraph_1.entityGraph.getStats();
2251
+ const entitySummary = entityStats.nodes > 0
2252
+ ? `You know ${entityStats.nodes} entities across your work.\n\n`
2253
+ : '';
2254
+ // Build a tool-results context block for the system prompt
2255
+ const toolResultsContext = results.length
2256
+ ? results.map(r => `[${r.tool} result]: ${r.success ? r.output.slice(0, 1000) : 'FAILED: ' + r.error}`).join('\n')
2257
+ : '';
2258
+ const systemWithResults = toolResultsContext
2259
+ ? `${capabilitiesSection}${entitySummary}${responderSystem(userName, date)}${responseSkillContext}${knowledgeResponderSection}${multiGoalInstruction}
2260
+
2261
+ YOU JUST RAN THESE TOOLS AND GOT THESE RESULTS:
2262
+ ${toolResultsContext}
2263
+
2264
+ CRITICAL RULES FOR YOUR RESPONSE:
2265
+ - Include the ACTUAL output from the tools above in your response
2266
+ - Do NOT say "I ran the tool" — show the RESULT
2267
+ - If run_python returned a number, say that number
2268
+ - If file_read returned text, show that text
2269
+ - If system_info returned hardware data, show the data
2270
+ - Be direct: show the actual output, then provide context if needed
2271
+ - If a tool failed, say it failed and why`
2272
+ : `${capabilitiesSection}${entitySummary}${responderSystem(userName, date)}${responseSkillContext}${knowledgeResponderSection}${multiGoalInstruction}`;
2273
+ const userContent = executionSummary
2274
+ ? `User asked: "${originalMessage}"\n\nReal execution results:\n${executionSummary}\n\nRespond naturally based on these real results only. Show the actual output, not a description of it.${depthInstruction}${memSection}`
2275
+ : `${originalMessage}${memSection}`;
2276
+ let messages = [
2277
+ { role: 'system', content: systemWithResults },
2278
+ ...history.slice(-6),
2279
+ { role: 'user', content: userContent },
2280
+ ];
2281
+ messages = await preflightCompressionCheck(messages, model, sessionId);
2282
+ messages = (0, messageValidator_1.sanitizeMessages)(messages);
2283
+ if (executionInterrupted)
2284
+ return;
2285
+ const _respCtrl = new AbortController();
2286
+ currentAbortController = _respCtrl;
2287
+ try {
2288
+ if (providerName === 'gemini') {
2289
+ const contents = messages
2290
+ .filter(m => m.role !== 'system')
2291
+ .map(m => ({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.content }] }));
2292
+ const system = messages.find(m => m.role === 'system')?.content;
2293
+ const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse&key=${apiKey}`, {
2294
+ method: 'POST',
2295
+ headers: { 'Content-Type': 'application/json' },
2296
+ body: JSON.stringify({
2297
+ contents,
2298
+ systemInstruction: system ? { parts: [{ text: system }] } : undefined,
2299
+ }),
2300
+ signal: AbortSignal.any([AbortSignal.timeout(30000), _respCtrl.signal]),
2301
+ });
2302
+ if (!r.ok) {
2303
+ const errText = await r.text().catch(() => '');
2304
+ if (r.status === 429 || r.status === 503) {
2305
+ try {
2306
+ (0, router_1.markRateLimited)(providerName);
2307
+ }
2308
+ catch { }
2309
+ }
2310
+ const capacityHint = errText.toLowerCase().includes('capacity') || errText.toLowerCase().includes('overloaded') ? ' capacity' : '';
2311
+ throw new Error(`Responder ${r.status}${capacityHint}: ${errText.slice(0, 200)}`);
2312
+ }
2313
+ await streamGeminiResponse(r, onToken);
2314
+ }
2315
+ else if (providerName === 'ollama') {
2316
+ const ollamaMs = Math.min((0, modelDiscovery_1.getOllamaTimeout)(model || ''), 15000); // cap at 15s for chat
2317
+ const _t0 = Date.now();
2318
+ console.log(`[Router] respondWithResults → ollama, model: ${model}, timeout: ${ollamaMs}ms`);
2319
+ const r = await fetch('http://localhost:11434/api/chat', {
2320
+ method: 'POST',
2321
+ headers: { 'Content-Type': 'application/json' },
2322
+ body: JSON.stringify({ model, stream: true, messages }),
2323
+ signal: AbortSignal.any([AbortSignal.timeout(ollamaMs), _respCtrl.signal]),
2324
+ });
2325
+ if (!r.body)
2326
+ throw new Error('Ollama: no response body');
2327
+ const reader = r.body.getReader();
2328
+ const decoder = new TextDecoder();
2329
+ let ollamaTokens = 0;
2330
+ while (true) {
2331
+ const { done, value } = await reader.read();
2332
+ if (done)
2333
+ break;
2334
+ const line = decoder.decode(value);
2335
+ try {
2336
+ const parsed = JSON.parse(line);
2337
+ if (parsed?.message?.content) {
2338
+ onToken(parsed.message.content);
2339
+ ollamaTokens++;
2340
+ }
2341
+ }
2342
+ catch { }
2343
+ }
2344
+ console.log(`[Router] Ollama responded in ${Date.now() - _t0}ms (${ollamaTokens} tokens)`);
2345
+ if (ollamaTokens === 0)
2346
+ throw new Error('Ollama: empty response — no tokens emitted');
2347
+ }
2348
+ else {
2349
+ // OpenAI-compatible
2350
+ const url = OPENAI_COMPAT_ENDPOINTS[providerName] || OPENAI_COMPAT_ENDPOINTS.groq;
2351
+ const r = await fetch(url, {
2352
+ method: 'POST',
2353
+ headers: buildHeaders(providerName, apiKey),
2354
+ body: JSON.stringify({ model, messages, stream: true }),
2355
+ signal: AbortSignal.any([AbortSignal.timeout(30000), _respCtrl.signal]),
2356
+ });
2357
+ if (!r.ok) {
2358
+ const errText = await r.text().catch(() => '');
2359
+ if (r.status === 429 || r.status === 503) {
2360
+ try {
2361
+ (0, router_1.markRateLimited)(providerName);
2362
+ }
2363
+ catch { }
2364
+ }
2365
+ const capacityHint = errText.toLowerCase().includes('capacity') || errText.toLowerCase().includes('overloaded') ? ' capacity' : '';
2366
+ throw new Error(`Responder ${r.status}${capacityHint}: ${errText.slice(0, 200)}`);
2367
+ }
2368
+ await streamOpenAIResponse(r, onToken);
2369
+ }
2370
+ }
2371
+ catch (e) {
2372
+ if (e.name === 'AbortError')
2373
+ return;
2374
+ console.error('[Responder] Error:', e.message);
2375
+ if (e.message?.includes('timeout') ||
2376
+ e.message?.includes('429') ||
2377
+ e.message?.includes('503') ||
2378
+ e.message?.includes('capacity') ||
2379
+ e.message?.includes('overloaded') ||
2380
+ e.message?.includes('rate') ||
2381
+ e.message?.includes('aborted')) {
2382
+ try {
2383
+ (0, router_1.markRateLimited)(providerName);
2384
+ }
2385
+ catch { }
2386
+ }
2387
+ // If cloud provider hit capacity, try next provider in chain before falling to Ollama
2388
+ if (providerName !== 'ollama' && (e.message?.includes('capacity') || e.message?.includes('503') || e.message?.includes('overloaded'))) {
2389
+ const nextCloud = (0, router_1.getModelForTask)('responder');
2390
+ if (nextCloud.providerName !== 'ollama' && nextCloud.apiName !== providerName && nextCloud.apiKey) {
2391
+ console.log(`[Responder] ${providerName} at capacity — trying ${nextCloud.providerName} (${nextCloud.model})`);
2392
+ try {
2393
+ const url = OPENAI_COMPAT_ENDPOINTS[nextCloud.providerName] || OPENAI_COMPAT_ENDPOINTS.groq;
2394
+ const headers = buildHeaders(nextCloud.providerName, nextCloud.apiKey);
2395
+ const r = await fetch(url, {
2396
+ method: 'POST',
2397
+ headers,
2398
+ body: JSON.stringify({ model: nextCloud.model, messages, stream: true }),
2399
+ signal: AbortSignal.timeout(30000),
2400
+ });
2401
+ if (r.ok) {
2402
+ await streamOpenAIResponse(r, onToken);
2403
+ return;
2404
+ }
2405
+ if (r.status === 429 || r.status === 503) {
2406
+ try {
2407
+ (0, router_1.markRateLimited)(nextCloud.apiName);
2408
+ }
2409
+ catch { }
2410
+ }
2411
+ }
2412
+ catch (nextErr) {
2413
+ console.error(`[Responder] ${nextCloud.providerName} fallback also failed: ${nextErr.message}`);
2414
+ }
2415
+ }
2416
+ }
2417
+ // If Ollama was primary and failed/timed out, fall back to best cloud provider
2418
+ if (providerName === 'ollama') {
2419
+ const cloudFallback = (0, router_1.getModelForTask)('responder');
2420
+ if (cloudFallback.providerName !== 'ollama' && cloudFallback.apiKey) {
2421
+ console.log(`[Router] Ollama timeout/error — falling back to ${cloudFallback.providerName} (${cloudFallback.model})`);
2422
+ try {
2423
+ const url = OPENAI_COMPAT_ENDPOINTS[cloudFallback.providerName] || OPENAI_COMPAT_ENDPOINTS.groq;
2424
+ const headers = buildHeaders(cloudFallback.providerName, cloudFallback.apiKey);
2425
+ const r = await fetch(url, {
2426
+ method: 'POST',
2427
+ headers,
2428
+ body: JSON.stringify({ model: cloudFallback.model, messages, stream: true }),
2429
+ signal: AbortSignal.timeout(15000),
2430
+ });
2431
+ if (r.ok) {
2432
+ await streamOpenAIResponse(r, onToken);
2433
+ return;
2434
+ }
2435
+ }
2436
+ catch (fbErr) {
2437
+ console.error(`[Router] Cloud fallback also failed: ${fbErr.message}`);
2438
+ }
2439
+ }
2440
+ }
2441
+ // If the cloud provider failed and we haven't tried Ollama yet, try it
2442
+ let ollamaResponded = false;
2443
+ if (providerName !== 'ollama') {
2444
+ try {
2445
+ // Discover installed model via api/tags
2446
+ const cfg = (0, index_1.loadConfig)();
2447
+ let ollamaModel = process.env.OLLAMA_MODEL || cfg.ollama?.model || 'gemma4:e4b';
2448
+ try {
2449
+ const _ob = (process.env.OLLAMA_HOST ?? 'http://127.0.0.1:11434').replace(/\/$/, '');
2450
+ const tagsRes = await fetch(`${_ob}/api/tags`, { signal: AbortSignal.timeout(3000) });
2451
+ if (tagsRes.ok) {
2452
+ const tagsData = await tagsRes.json();
2453
+ const firstModel = tagsData?.models?.[0]?.name;
2454
+ if (firstModel)
2455
+ ollamaModel = firstModel;
2456
+ }
2457
+ }
2458
+ catch { /* Ollama not running */ }
2459
+ console.log(`[Responder] Cloud provider failed — falling back to Ollama (${ollamaModel})`);
2460
+ const r = await fetch('http://localhost:11434/api/chat', {
2461
+ method: 'POST',
2462
+ headers: { 'Content-Type': 'application/json' },
2463
+ body: JSON.stringify({ model: ollamaModel, stream: true, messages }),
2464
+ signal: AbortSignal.timeout((0, modelDiscovery_1.getOllamaTimeout)(ollamaModel)),
2465
+ });
2466
+ if (r.ok && r.body) {
2467
+ const reader = r.body.getReader();
2468
+ const decoder = new TextDecoder();
2469
+ let tokensEmitted = 0;
2470
+ while (true) {
2471
+ const { done, value } = await reader.read();
2472
+ if (done)
2473
+ break;
2474
+ try {
2475
+ const parsed = JSON.parse(decoder.decode(value));
2476
+ if (parsed?.message?.content) {
2477
+ onToken(parsed.message.content);
2478
+ tokensEmitted++;
2479
+ }
2480
+ }
2481
+ catch { }
2482
+ }
2483
+ if (tokensEmitted > 0) {
2484
+ ollamaResponded = true;
2485
+ }
2486
+ }
2487
+ }
2488
+ catch (ollamaErr) {
2489
+ console.warn(`[Responder] Ollama fallback also failed: ${ollamaErr.message}`);
2490
+ }
2491
+ }
2492
+ if (ollamaResponded)
2493
+ return;
2494
+ // Last resort: return raw tool output if tools ran successfully
2495
+ if (results && results.length > 0 && results.some(r => r.success)) {
2496
+ const successResults = results.filter(r => r.success);
2497
+ const lastResult = successResults[successResults.length - 1];
2498
+ onToken(lastResult.output || 'Here are the results.');
2499
+ return;
2500
+ }
2501
+ // Include error info from failed tools if any
2502
+ if (results && results.length > 0) {
2503
+ const failedResult = results[results.length - 1];
2504
+ if (failedResult.error) {
2505
+ onToken(`Error: ${failedResult.error}`);
2506
+ return;
2507
+ }
2508
+ }
2509
+ const degraded = (0, router_1.enterDegradedMode)(e.message || 'unknown error');
2510
+ onToken(degraded.message);
2511
+ }
2512
+ }
2513
+ // ── Non-streaming LLM helper (used by deepResearch) ──────────
2514
+ async function callLLM(prompt, apiKey, model, providerName, opts) {
2515
+ if (executionInterrupted)
2516
+ return '';
2517
+ const _ctrl = new AbortController();
2518
+ currentAbortController = _ctrl;
2519
+ const messages = [{ role: 'user', content: prompt }];
2520
+ try {
2521
+ if (providerName === 'gemini') {
2522
+ const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`, {
2523
+ method: 'POST',
2524
+ headers: { 'Content-Type': 'application/json' },
2525
+ body: JSON.stringify({
2526
+ contents: [{ role: 'user', parts: [{ text: prompt }] }],
2527
+ generationConfig: { maxOutputTokens: 2000 },
2528
+ }),
2529
+ signal: AbortSignal.any([AbortSignal.timeout(12000), _ctrl.signal]),
2530
+ });
2531
+ if (r.status === 429) {
2532
+ try {
2533
+ (0, router_1.markRateLimited)(providerName);
2534
+ }
2535
+ catch { }
2536
+ throw new Error(`Rate limited (429): ${providerName}`);
2537
+ }
2538
+ if (!r.ok) {
2539
+ throw new Error(`HTTP ${r.status} from ${providerName}`);
2540
+ }
2541
+ const d = await r.json();
2542
+ try {
2543
+ costTracker_1.costTracker.trackUsage(providerName, model, d?.usageMetadata?.promptTokenCount ?? 0, d?.usageMetadata?.candidatesTokenCount ?? 0, opts?.traceId, opts?.isSystem ?? false);
2544
+ }
2545
+ catch { }
2546
+ return d?.candidates?.[0]?.content?.parts?.[0]?.text || '';
2547
+ }
2548
+ else if (providerName === 'ollama') {
2549
+ const r = await fetch('http://localhost:11434/api/chat', {
2550
+ method: 'POST',
2551
+ headers: { 'Content-Type': 'application/json' },
2552
+ body: JSON.stringify({ model: model || 'mistral:7b', stream: false, messages }),
2553
+ signal: AbortSignal.any([AbortSignal.timeout((0, modelDiscovery_1.getOllamaTimeout)(model || '')), _ctrl.signal]),
2554
+ });
2555
+ if (r.status === 429) {
2556
+ try {
2557
+ (0, router_1.markRateLimited)(providerName);
2558
+ }
2559
+ catch { }
2560
+ throw new Error(`Rate limited (429): ${providerName}`);
2561
+ }
2562
+ if (!r.ok) {
2563
+ throw new Error(`HTTP ${r.status} from ${providerName}`);
2564
+ }
2565
+ const d = await r.json();
2566
+ try {
2567
+ costTracker_1.costTracker.trackUsage(providerName, model, d?.prompt_eval_count ?? 0, d?.eval_count ?? 0, opts?.traceId, opts?.isSystem ?? false);
2568
+ }
2569
+ catch { }
2570
+ return d?.message?.content || '';
2571
+ }
2572
+ else if (providerName === 'cloudflare') {
2573
+ // Cloudflare Workers AI — accountId|modelName stored in model field
2574
+ const [accountId, cfModel] = model.split('|');
2575
+ const r = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${cfModel || '@cf/meta/llama-3.1-8b-instruct'}`, {
2576
+ method: 'POST',
2577
+ headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
2578
+ body: JSON.stringify({ messages }),
2579
+ signal: AbortSignal.any([AbortSignal.timeout(20000), _ctrl.signal]),
2580
+ });
2581
+ if (r.status === 429) {
2582
+ try {
2583
+ (0, router_1.markRateLimited)(providerName);
2584
+ }
2585
+ catch { }
2586
+ throw new Error(`Rate limited (429): ${providerName}`);
2587
+ }
2588
+ if (!r.ok)
2589
+ throw new Error(`cloudflare ${r.status}`);
2590
+ const d = await r.json();
2591
+ try {
2592
+ costTracker_1.costTracker.trackUsage(providerName, model, 0, 0, opts?.traceId, opts?.isSystem ?? false);
2593
+ }
2594
+ catch { }
2595
+ return d?.result?.response || '';
2596
+ }
2597
+ else if (providerName === 'custom') {
2598
+ // Custom provider — look up baseUrl from config by matching apiKey
2599
+ const cfgCustom = (0, index_1.loadConfig)();
2600
+ const cp = cfgCustom.customProviders?.find((c) => c.enabled && c.apiKey === apiKey);
2601
+ if (!cp?.baseUrl)
2602
+ throw new Error(`callLLM: no baseUrl for custom provider (model=${model})`);
2603
+ const r = await fetch(cp.baseUrl, {
2604
+ method: 'POST',
2605
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
2606
+ body: JSON.stringify({
2607
+ model,
2608
+ messages: [
2609
+ { role: 'system', content: 'You are Aiden, a local-first personal AI OS. Be concise and direct.' },
2610
+ ...messages,
2611
+ ],
2612
+ stream: false,
2613
+ max_tokens: 2000,
2614
+ }),
2615
+ signal: AbortSignal.any([AbortSignal.timeout(45000), _ctrl.signal]),
2616
+ });
2617
+ if (r.status === 429) {
2618
+ try {
2619
+ (0, router_1.markRateLimited)(providerName);
2620
+ }
2621
+ catch { }
2622
+ throw new Error(`Rate limited (429): custom/${model}`);
2623
+ }
2624
+ if (!r.ok)
2625
+ throw new Error(`HTTP ${r.status} from custom/${model}`);
2626
+ const d = await r.json();
2627
+ try {
2628
+ costTracker_1.costTracker.trackUsage(providerName, model, d?.usage?.prompt_tokens ?? 0, d?.usage?.completion_tokens ?? 0, opts?.traceId, opts?.isSystem ?? false);
2629
+ }
2630
+ catch { }
2631
+ return d?.choices?.[0]?.message?.content || '';
2632
+ }
2633
+ else {
2634
+ // OpenAI-compatible: groq, openrouter, cerebras, nvidia, github
2635
+ const url = OPENAI_COMPAT_ENDPOINTS[providerName] || OPENAI_COMPAT_ENDPOINTS.groq;
2636
+ const headers = buildHeaders(providerName, apiKey);
2637
+ const r = await fetch(url, {
2638
+ method: 'POST',
2639
+ headers,
2640
+ body: JSON.stringify({ model, messages, stream: false, max_tokens: 2000 }),
2641
+ signal: AbortSignal.any([AbortSignal.timeout(12000), _ctrl.signal]),
2642
+ });
2643
+ if (r.status === 429) {
2644
+ try {
2645
+ (0, router_1.markRateLimited)(providerName);
2646
+ }
2647
+ catch { }
2648
+ throw new Error(`Rate limited (429): ${providerName}`);
2649
+ }
2650
+ if (!r.ok) {
2651
+ throw new Error(`HTTP ${r.status} from ${providerName}`);
2652
+ }
2653
+ const d = await r.json();
2654
+ try {
2655
+ costTracker_1.costTracker.trackUsage(providerName, model, d?.usage?.prompt_tokens ?? 0, d?.usage?.completion_tokens ?? 0, opts?.traceId, opts?.isSystem ?? false);
2656
+ }
2657
+ catch { }
2658
+ return d?.choices?.[0]?.message?.content || '';
2659
+ }
2660
+ }
2661
+ catch (e) {
2662
+ if (e.name === 'AbortError')
2663
+ return '';
2664
+ console.error('[callLLM] error:', e.message);
2665
+ return '';
2666
+ }
2667
+ }
2668
+ // ── Deep research: 3-pass LLM-assisted research loop ─────────
2669
+ // Called directly (e.g. from a /api/research endpoint) or as
2670
+ // a high-level entry point when the planner picks deep_research.
2671
+ async function deepResearch(topic, apiKey, model, provider, onProgress) {
2672
+ const allResults = [];
2673
+ let currentQuery = topic;
2674
+ const maxPasses = 7;
2675
+ for (let pass = 1; pass <= maxPasses; pass++) {
2676
+ onProgress(`Pass ${pass}: Searching "${currentQuery}"...`);
2677
+ const searchResult = await (0, toolRegistry_1.executeTool)('web_search', { query: currentQuery });
2678
+ if (!searchResult.success || !searchResult.output)
2679
+ break;
2680
+ allResults.push(`=== Pass ${pass}: ${currentQuery} ===\n${searchResult.output}`);
2681
+ // Reflection: what gaps remain?
2682
+ const reflectionPrompt = `You are researching: "${topic}"
2683
+
2684
+ So far you have found:
2685
+ ${allResults.join('\n\n').slice(0, 3000)}
2686
+
2687
+ Analyze the gaps:
2688
+ 1. What important aspects of "${topic}" are still missing?
2689
+ 2. What contradictions need resolving?
2690
+ 3. What specific follow-up query would fill the biggest gap?
2691
+
2692
+ Respond in JSON:
2693
+ {
2694
+ "gaps": ["gap1", "gap2"],
2695
+ "nextQuery": "specific search query to fill the biggest gap",
2696
+ "complete": true/false
2697
+ }`;
2698
+ const reflection = await callLLM(reflectionPrompt, apiKey, model, provider);
2699
+ let reflectionData = {};
2700
+ try {
2701
+ const match = reflection.match(/\{[\s\S]*\}/);
2702
+ reflectionData = JSON.parse(match?.[0] || '{}');
2703
+ }
2704
+ catch { }
2705
+ if (reflectionData.complete === true || !reflectionData.nextQuery)
2706
+ break;
2707
+ currentQuery = reflectionData.nextQuery;
2708
+ onProgress(`Filling gap: ${reflectionData.gaps?.[0] || currentQuery}`);
2709
+ // Source quality scoring
2710
+ const isHighQuality = searchResult.output.includes('wikipedia') ||
2711
+ searchResult.output.includes('.gov') ||
2712
+ searchResult.output.includes('reuters') ||
2713
+ searchResult.output.includes('bloomberg');
2714
+ if (isHighQuality)
2715
+ onProgress('✓ High-quality source found');
2716
+ }
2717
+ return allResults.join('\n\n');
2718
+ }