daemora 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +666 -0
  2. package/SOUL.md +104 -0
  3. package/config/hooks.json +14 -0
  4. package/config/mcp.json +145 -0
  5. package/package.json +86 -0
  6. package/skills/.gitkeep +0 -0
  7. package/skills/apple-notes.md +193 -0
  8. package/skills/apple-reminders.md +189 -0
  9. package/skills/camsnap.md +162 -0
  10. package/skills/coding.md +14 -0
  11. package/skills/documents.md +13 -0
  12. package/skills/email.md +13 -0
  13. package/skills/gif-search.md +196 -0
  14. package/skills/healthcheck.md +225 -0
  15. package/skills/image-gen.md +147 -0
  16. package/skills/model-usage.md +182 -0
  17. package/skills/obsidian.md +207 -0
  18. package/skills/pdf.md +211 -0
  19. package/skills/research.md +13 -0
  20. package/skills/skill-creator.md +142 -0
  21. package/skills/spotify.md +149 -0
  22. package/skills/summarize.md +230 -0
  23. package/skills/things.md +199 -0
  24. package/skills/tmux.md +204 -0
  25. package/skills/trello.md +183 -0
  26. package/skills/video-frames.md +202 -0
  27. package/skills/weather.md +127 -0
  28. package/src/a2a/A2AClient.js +136 -0
  29. package/src/a2a/A2AServer.js +316 -0
  30. package/src/a2a/AgentCard.js +79 -0
  31. package/src/agents/SubAgentManager.js +369 -0
  32. package/src/agents/Supervisor.js +192 -0
  33. package/src/channels/BaseChannel.js +104 -0
  34. package/src/channels/DiscordChannel.js +288 -0
  35. package/src/channels/EmailChannel.js +172 -0
  36. package/src/channels/GoogleChatChannel.js +316 -0
  37. package/src/channels/HttpChannel.js +26 -0
  38. package/src/channels/LineChannel.js +168 -0
  39. package/src/channels/SignalChannel.js +186 -0
  40. package/src/channels/SlackChannel.js +329 -0
  41. package/src/channels/TeamsChannel.js +272 -0
  42. package/src/channels/TelegramChannel.js +347 -0
  43. package/src/channels/WhatsAppChannel.js +219 -0
  44. package/src/channels/index.js +198 -0
  45. package/src/cli.js +1267 -0
  46. package/src/config/agentProfiles.js +120 -0
  47. package/src/config/channels.js +32 -0
  48. package/src/config/default.js +206 -0
  49. package/src/config/models.js +123 -0
  50. package/src/config/permissions.js +167 -0
  51. package/src/core/AgentLoop.js +446 -0
  52. package/src/core/Compaction.js +143 -0
  53. package/src/core/CostTracker.js +116 -0
  54. package/src/core/EventBus.js +46 -0
  55. package/src/core/Task.js +67 -0
  56. package/src/core/TaskQueue.js +206 -0
  57. package/src/core/TaskRunner.js +226 -0
  58. package/src/daemon/DaemonManager.js +301 -0
  59. package/src/hooks/HookRunner.js +230 -0
  60. package/src/index.js +482 -0
  61. package/src/mcp/MCPAgentRunner.js +112 -0
  62. package/src/mcp/MCPClient.js +186 -0
  63. package/src/mcp/MCPManager.js +412 -0
  64. package/src/models/ModelRouter.js +180 -0
  65. package/src/safety/AuditLog.js +135 -0
  66. package/src/safety/CircuitBreaker.js +126 -0
  67. package/src/safety/FilesystemGuard.js +169 -0
  68. package/src/safety/GitRollback.js +139 -0
  69. package/src/safety/HumanApproval.js +156 -0
  70. package/src/safety/InputSanitizer.js +72 -0
  71. package/src/safety/PermissionGuard.js +83 -0
  72. package/src/safety/Sandbox.js +70 -0
  73. package/src/safety/SecretScanner.js +100 -0
  74. package/src/safety/SecretVault.js +250 -0
  75. package/src/scheduler/Heartbeat.js +115 -0
  76. package/src/scheduler/Scheduler.js +228 -0
  77. package/src/services/models/outputSchema.js +15 -0
  78. package/src/services/openai.js +25 -0
  79. package/src/services/sessions.js +65 -0
  80. package/src/setup/theme.js +110 -0
  81. package/src/setup/wizard.js +788 -0
  82. package/src/skills/SkillLoader.js +168 -0
  83. package/src/storage/TaskStore.js +69 -0
  84. package/src/systemPrompt.js +526 -0
  85. package/src/tenants/TenantContext.js +19 -0
  86. package/src/tenants/TenantManager.js +379 -0
  87. package/src/tools/ToolRegistry.js +141 -0
  88. package/src/tools/applyPatch.js +144 -0
  89. package/src/tools/browserAutomation.js +223 -0
  90. package/src/tools/createDocument.js +265 -0
  91. package/src/tools/cronTool.js +105 -0
  92. package/src/tools/editFile.js +139 -0
  93. package/src/tools/executeCommand.js +123 -0
  94. package/src/tools/glob.js +67 -0
  95. package/src/tools/grep.js +121 -0
  96. package/src/tools/imageAnalysis.js +120 -0
  97. package/src/tools/index.js +173 -0
  98. package/src/tools/listDirectory.js +47 -0
  99. package/src/tools/manageAgents.js +47 -0
  100. package/src/tools/manageMCP.js +159 -0
  101. package/src/tools/memory.js +478 -0
  102. package/src/tools/messageChannel.js +45 -0
  103. package/src/tools/projectTracker.js +259 -0
  104. package/src/tools/readFile.js +52 -0
  105. package/src/tools/screenCapture.js +112 -0
  106. package/src/tools/searchContent.js +76 -0
  107. package/src/tools/searchFiles.js +75 -0
  108. package/src/tools/sendEmail.js +118 -0
  109. package/src/tools/sendFile.js +63 -0
  110. package/src/tools/textToSpeech.js +161 -0
  111. package/src/tools/transcribeAudio.js +82 -0
  112. package/src/tools/useMCP.js +29 -0
  113. package/src/tools/webFetch.js +150 -0
  114. package/src/tools/webSearch.js +134 -0
  115. package/src/tools/writeFile.js +26 -0
@@ -0,0 +1,446 @@
1
+ import { generateObject } from "ai";
2
+ import { getModelWithFallback } from "../models/ModelRouter.js";
3
+ import { compactIfNeeded, estimateTokens } from "./Compaction.js";
4
+ import { config } from "../config/default.js";
5
+ import eventBus from "./EventBus.js";
6
+ import outputSchema from "../services/models/outputSchema.js";
7
+ import hookRunner from "../hooks/HookRunner.js";
8
+ import secretScanner from "../safety/SecretScanner.js";
9
+ import sandbox from "../safety/Sandbox.js";
10
+ import circuitBreaker from "../safety/CircuitBreaker.js";
11
+ import permissionGuard from "../safety/PermissionGuard.js";
12
+ import supervisor from "../agents/Supervisor.js";
13
+ import gitRollback from "../safety/GitRollback.js";
14
+
15
+ /**
16
+ * Core agent loop — model-agnostic via Vercel AI SDK.
17
+ *
18
+ * Extracted from the original openai.js. This is the brain of the agent:
19
+ * 1. Send messages to model (any provider)
20
+ * 2. If model returns tool_call → execute tool → feed result back → loop
21
+ * 3. If model returns text + finalResponse → return to caller
22
+ * 4. Compaction when approaching context limit
23
+ * 5. Repeat detection, max loop safety, stuck agent recovery
24
+ *
25
+ * @param {object} options
26
+ * @param {Array} options.messages - Conversation history
27
+ * @param {object} options.systemPrompt - System prompt { role, content }
28
+ * @param {object} options.tools - Tool functions map { name: fn }
29
+ * @param {string} [options.modelId] - Model to use (e.g. "openai:gpt-4.1-mini")
30
+ * @param {string} [options.taskId] - Task ID for tracking
31
+ * @returns {{ text: string, messages: Array, cost: object }}
32
+ */
33
+ export async function runAgentLoop({
34
+ messages: msgs,
35
+ systemPrompt,
36
+ tools,
37
+ modelId = null,
38
+ taskId = null,
39
+ approvalMode = "auto", // "auto" | "dangerous-only" | "every-tool"
40
+ channelMeta = null, // passed through to HumanApproval so channel can notify user
41
+ signal = null, // AbortController.signal — hard-kills the loop mid-call
42
+ steerQueue = null, // shared mutable array — push strings here to steer the agent
43
+ apiKeys = {}, // per-tenant API key overlay — passed through to provider factory
44
+ }) {
45
+ const selectedModelId = modelId || config.defaultModel;
46
+ const { model, meta, modelId: resolvedModelId } = getModelWithFallback(selectedModelId, apiKeys);
47
+
48
+ // Build set of known secret values to redact from tool outputs (dynamic — catches tenant keys)
49
+ const _knownSecrets = new Set([
50
+ ...Object.values(apiKeys),
51
+ process.env.OPENAI_API_KEY,
52
+ process.env.ANTHROPIC_API_KEY,
53
+ process.env.GOOGLE_AI_API_KEY,
54
+ ].filter((s) => s && s.length >= 8));
55
+
56
+ function _redactKnownSecrets(text) {
57
+ let out = text;
58
+ for (const secret of _knownSecrets) {
59
+ if (out.includes(secret)) out = out.replaceAll(secret, "[REDACTED:API_KEY]");
60
+ }
61
+ return out;
62
+ }
63
+
64
+ let messages = [systemPrompt, ...msgs];
65
+ let stepCount = 0;
66
+ let writeToolUsed = false; // Track if model actually modified anything
67
+ let loopCount = 0;
68
+ let lastToolCall = null;
69
+ let repeatCount = 0;
70
+ let totalInputTokens = 0;
71
+ let totalOutputTokens = 0;
72
+ let consecutiveErrors = 0;
73
+
74
+ const WRITE_TOOLS = new Set(["writeFile", "editFile", "applyPatch", "executeCommand", "sendEmail", "createDocument", "browserAction", "messageChannel"]);
75
+ let gitSnapshotDone = false; // Only snapshot once per task
76
+ const ACTION_WORDS = /\b(fixed|updated|created|added|modified|changed|removed|deleted|wrote|edited|replaced|refactored|implemented|styled|applied)\b/i;
77
+
78
+ console.log(`\n--- AGENT LOOP STARTED ---`);
79
+ console.log(`Model: ${resolvedModelId}`);
80
+ console.log(`User message: "${msgs[msgs.length - 1]?.content?.slice(0, 120)}"`);
81
+ console.log(`Conversation history: ${msgs.length} message(s)`);
82
+
83
+ while (true) {
84
+ loopCount++;
85
+
86
+ // ── Break point 1: AbortController signal (hard kill, works mid-API-call) ──
87
+ if (signal?.aborted) {
88
+ console.log(`[AgentLoop] Task ${taskId?.slice(0, 8)} aborted via AbortController.`);
89
+ return {
90
+ text: "Agent was stopped by the supervisor.",
91
+ messages: messages.slice(1),
92
+ cost: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, estimatedCost: 0, modelCalls: loopCount, model: resolvedModelId },
93
+ };
94
+ }
95
+
96
+ // ── Break point 2: Supervisor kill flag (checked each iteration) ──────────
97
+ if (supervisor.isKilled(taskId)) {
98
+ console.log(`[AgentLoop] Task ${taskId?.slice(0, 8)} was killed by Supervisor. Stopping.`);
99
+ return {
100
+ text: "Task was stopped by the safety supervisor due to excessive tool usage or a dangerous pattern.",
101
+ messages: messages.slice(1),
102
+ cost: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, estimatedCost: 0, modelCalls: loopCount, model: resolvedModelId },
103
+ };
104
+ }
105
+
106
+ // ── Steering: drain steerQueue between tool calls ────────────────────────
107
+ // Items can be plain strings (supervisor/parent instructions) or
108
+ // objects { type: "user", content } for live follow-up messages injected
109
+ // from the same session while this loop is mid-flight.
110
+ if (steerQueue?.length > 0) {
111
+ while (steerQueue.length > 0) {
112
+ const item = steerQueue.shift();
113
+ if (item && typeof item === "object" && item.type === "user") {
114
+ // User sent a follow-up mid-task — inject as a natural user turn
115
+ console.log(`[AgentLoop] User follow-up injected: "${item.content.slice(0, 80)}"`);
116
+ messages.push({ role: "user", content: item.content });
117
+ } else {
118
+ const text = typeof item === "string" ? item : JSON.stringify(item);
119
+ console.log(`[AgentLoop] Steering instruction received: "${text.slice(0, 80)}"`);
120
+ messages.push({ role: "user", content: `[Supervisor instruction]: ${text}` });
121
+ }
122
+ }
123
+ }
124
+
125
+ if (loopCount > config.maxLoops) {
126
+ console.log(`[WARN] Hit max loop limit (${config.maxLoops}). Forcing agent to stop.`);
127
+ messages.push({
128
+ role: "user",
129
+ content: `You have used ${config.maxLoops} iterations. You must stop now. Summarize what you have done so far. Set type to "text", finalResponse to true, and put your summary in text_content.`,
130
+ });
131
+ }
132
+
133
+ // Compaction check before model call
134
+ messages = await compactIfNeeded(messages, meta, taskId);
135
+
136
+ console.log(`\n[Loop ${loopCount}] Sending ${messages.length} messages (~${estimateTokens(messages)} tokens) to ${resolvedModelId}...`);
137
+
138
+ const startTime = Date.now();
139
+
140
+ try {
141
+ const response = await generateObject({
142
+ model,
143
+ schema: outputSchema,
144
+ messages,
145
+ maxTokens: 4096,
146
+ abortSignal: signal || undefined,
147
+ });
148
+
149
+ const elapsed = Date.now() - startTime;
150
+ consecutiveErrors = 0; // Reset on success
151
+
152
+ // Track token usage
153
+ if (response.usage) {
154
+ totalInputTokens += response.usage.promptTokens || 0;
155
+ totalOutputTokens += response.usage.completionTokens || 0;
156
+ }
157
+
158
+ eventBus.emitEvent("model:called", {
159
+ modelId: resolvedModelId,
160
+ loopCount,
161
+ elapsed,
162
+ inputTokens: response.usage?.promptTokens || 0,
163
+ outputTokens: response.usage?.completionTokens || 0,
164
+ });
165
+
166
+ const parsedOutput = response.object;
167
+
168
+ console.log(
169
+ `[Loop ${loopCount}] Model responded in ${elapsed}ms | type=${parsedOutput.type} | final=${parsedOutput.finalResponse}`
170
+ );
171
+
172
+ // --- Tool call handling ---
173
+ if (parsedOutput.type === "tool_call" && parsedOutput.tool_call) {
174
+ // Save the model's tool call as an assistant message so the conversation is properly structured
175
+ messages.push({ role: "assistant", content: JSON.stringify(parsedOutput) });
176
+
177
+ stepCount++;
178
+ const { tool_name, params } = parsedOutput.tool_call;
179
+
180
+ // Repeat detection
181
+ const currentCall = JSON.stringify({ tool_name, params });
182
+ if (currentCall === lastToolCall) {
183
+ repeatCount++;
184
+ console.log(`[WARN] Same tool call repeated ${repeatCount + 1} times in a row`);
185
+ if (repeatCount >= 2) {
186
+ console.log(`[WARN] Agent stuck repeating "${tool_name}". Forcing it to move on.`);
187
+ messages.push({
188
+ role: "user",
189
+ content: `You are calling ${tool_name} with the same params repeatedly. This is not working. Try a different approach or give the user your final answer. Set type to "text" and finalResponse to true.`,
190
+ });
191
+ lastToolCall = null;
192
+ repeatCount = 0;
193
+ continue;
194
+ }
195
+ } else {
196
+ repeatCount = 0;
197
+ }
198
+ lastToolCall = currentCall;
199
+
200
+ console.log(`[Step ${stepCount}] Tool: ${tool_name}`);
201
+ console.log(`[Step ${stepCount}] Params: ${JSON.stringify(params)}`);
202
+
203
+ eventBus.emitEvent("tool:before", { tool_name, params, stepCount, taskId });
204
+
205
+ // Permission guard check
206
+ const permCheck = permissionGuard.check(tool_name, params);
207
+ if (!permCheck.allowed) {
208
+ console.log(`[Step ${stepCount}] BLOCKED by PermissionGuard: ${permCheck.reason}`);
209
+ eventBus.emitEvent("audit:permission_denied", { tool_name, reason: permCheck.reason, taskId });
210
+ messages.push({
211
+ role: "user",
212
+ content: JSON.stringify({ tool_name, params, output: permCheck.reason }),
213
+ });
214
+ continue;
215
+ }
216
+
217
+ // Circuit breaker check
218
+ if (circuitBreaker.isToolDisabled(tool_name)) {
219
+ console.log(`[Step ${stepCount}] Tool "${tool_name}" temporarily disabled by circuit breaker`);
220
+ messages.push({
221
+ role: "user",
222
+ content: JSON.stringify({
223
+ tool_name, params,
224
+ output: `Tool "${tool_name}" is temporarily disabled due to repeated failures. Try a different approach.`,
225
+ }),
226
+ });
227
+ continue;
228
+ }
229
+
230
+ // Sandbox check for executeCommand
231
+ if (tool_name === "executeCommand" && params[0]) {
232
+ const sandboxResult = sandbox.check(params[0]);
233
+ if (!sandboxResult.safe) {
234
+ console.log(`[Step ${stepCount}] BLOCKED by sandbox: ${sandboxResult.reason}`);
235
+ eventBus.emitEvent("audit:sandbox_blocked", { command: params[0], reason: sandboxResult.reason, taskId });
236
+ messages.push({
237
+ role: "user",
238
+ content: JSON.stringify({
239
+ tool_name, params,
240
+ output: `${sandboxResult.reason}. This command is not allowed for safety reasons.`,
241
+ }),
242
+ });
243
+ continue;
244
+ }
245
+ }
246
+
247
+ // Run PreToolUse hooks
248
+ const hookResult = await hookRunner.preToolUse(tool_name, params, taskId);
249
+ if (hookResult.decision === "block") {
250
+ console.log(`[Step ${stepCount}] BLOCKED by hook: ${hookResult.reason}`);
251
+ eventBus.emitEvent("audit:hook_blocked", { tool_name, reason: hookResult.reason, taskId });
252
+ messages.push({
253
+ role: "user",
254
+ content: JSON.stringify({
255
+ tool_name, params,
256
+ output: `Tool blocked by safety hook: ${hookResult.reason}. Try a different approach.`,
257
+ }),
258
+ });
259
+ continue;
260
+ }
261
+
262
+ // Git snapshot — before the first write tool in this task
263
+ if (!gitSnapshotDone && WRITE_TOOLS.has(tool_name)) {
264
+ gitRollback.snapshot(taskId);
265
+ gitSnapshotDone = true;
266
+ }
267
+
268
+ if (tools[tool_name]) {
269
+ try {
270
+ const toolStart = Date.now();
271
+ const toolOutput = await Promise.resolve(tools[tool_name](...params));
272
+ const toolElapsed = Date.now() - toolStart;
273
+
274
+ const outputStr = typeof toolOutput === "string" ? toolOutput : JSON.stringify(toolOutput);
275
+ const preview = outputStr.slice(0, 300) + (outputStr.length > 300 ? "..." : "");
276
+
277
+ // Track if a write tool was successfully used
278
+ if (WRITE_TOOLS.has(tool_name)) {
279
+ writeToolUsed = true;
280
+ }
281
+
282
+ console.log(`[Step ${stepCount}] Done in ${toolElapsed}ms`);
283
+ console.log(`[Step ${stepCount}] Output: ${preview}`);
284
+
285
+ eventBus.emitEvent("tool:after", {
286
+ tool_name,
287
+ params,
288
+ stepCount,
289
+ taskId,
290
+ duration: toolElapsed,
291
+ outputLength: outputStr.length,
292
+ });
293
+
294
+ // Run PostToolUse hooks
295
+ await hookRunner.postToolUse(tool_name, params, outputStr, taskId);
296
+
297
+ // Scan output for secrets and redact (double layer: static patterns + dynamic tenant keys)
298
+ const safeOutput = _redactKnownSecrets(secretScanner.redactOutput(outputStr));
299
+ const secretsFound = (outputStr.match(/\[REDACTED\]/g) || []).length - (safeOutput.match(/\[REDACTED\]/g) || []).length;
300
+ if (safeOutput !== outputStr) {
301
+ eventBus.emitEvent("audit:secret_detected", { tool_name, taskId, count: Math.max(1, secretsFound) });
302
+ }
303
+
304
+ // Record success for circuit breaker
305
+ circuitBreaker.recordSuccess(taskId);
306
+
307
+ messages.push({
308
+ role: "user",
309
+ content: JSON.stringify({ tool_name, params, output: safeOutput }),
310
+ });
311
+ } catch (error) {
312
+ console.log(`[Step ${stepCount}] FAILED: ${error.message}`);
313
+
314
+ // Record failure for circuit breaker
315
+ circuitBreaker.recordToolFailure(tool_name);
316
+
317
+ eventBus.emitEvent("tool:after", {
318
+ tool_name,
319
+ params,
320
+ stepCount,
321
+ taskId,
322
+ error: error.message,
323
+ });
324
+ messages.push({
325
+ role: "user",
326
+ content: JSON.stringify({
327
+ tool_name,
328
+ params,
329
+ output: `Error executing tool: ${error.message}`,
330
+ }),
331
+ });
332
+ }
333
+ } else {
334
+ console.log(`[Step ${stepCount}] Unknown tool: ${tool_name} — skipping`);
335
+ messages.push({
336
+ role: "user",
337
+ content: JSON.stringify({
338
+ tool_name,
339
+ params,
340
+ output: `Unknown tool: ${tool_name}. Available tools: ${Object.keys(tools).join(", ")}`,
341
+ }),
342
+ });
343
+ }
344
+ continue;
345
+ }
346
+
347
+ // --- Final response handling ---
348
+ if (parsedOutput.finalResponse || parsedOutput.type === "text") {
349
+ if (!parsedOutput.text_content) {
350
+ console.log(`[Loop ${loopCount}] Model signaled done but text_content is null — asking for summary`);
351
+ messages.push({
352
+ role: "user",
353
+ content:
354
+ "Provide a text summary of what you did. Set type to 'text', finalResponse to true, and text_content to your summary.",
355
+ });
356
+ continue;
357
+ }
358
+
359
+ // --- Lazy model safeguard ---
360
+ // SAFEGUARD 1: Model claimed done but used ZERO tools
361
+ // If the user's message is a real request (not just "ok"/"yes"), force tool use.
362
+ if (stepCount === 0 && loopCount <= 2) {
363
+ const lastUserMsg = msgs[msgs.length - 1]?.content?.toLowerCase() || "";
364
+ const isAck = /^(ok|okay|yes|yeah|sure|thanks|thank you|no|nah|k|yep|yup|got it|cool|nice|great|good|alright|👍)\.?$/i.test(lastUserMsg.trim());
365
+ if (!isAck && lastUserMsg.length > 5) {
366
+ console.log(`[Loop ${loopCount}] LAZY MODEL DETECTED — claimed done but used 0 tools. Forcing tool use.`);
367
+ messages.push({
368
+ role: "user",
369
+ content: `You responded without using any tools. You MUST actually use tools (readFile, editFile, writeFile, executeCommand, etc.) to complete the task. Do NOT claim you fixed or changed something without actually doing it. Use your tools NOW to fulfill the request.`,
370
+ });
371
+ continue;
372
+ }
373
+ }
374
+
375
+ // SAFEGUARD 2: Model used only READ tools but claims it modified/fixed something
376
+ // If the response contains action words but no write tool was ever called, push back.
377
+ if (!writeToolUsed && stepCount > 0 && loopCount <= 4 && ACTION_WORDS.test(parsedOutput.text_content)) {
378
+ console.log(`[Loop ${loopCount}] PHANTOM WRITE DETECTED — model claims "${parsedOutput.text_content.slice(0, 80)}..." but only used read tools. Forcing actual writes.`);
379
+ messages.push({
380
+ role: "user",
381
+ content: `You claim to have made changes but you only used read tools — you never called writeFile or editFile to actually modify any file. The files are UNCHANGED. You must use writeFile or editFile to actually make the changes. Do it now.`,
382
+ });
383
+ continue;
384
+ }
385
+
386
+ const cost = {
387
+ inputTokens: totalInputTokens,
388
+ outputTokens: totalOutputTokens,
389
+ estimatedCost:
390
+ (totalInputTokens / 1000) * meta.costPer1kInput +
391
+ (totalOutputTokens / 1000) * meta.costPer1kOutput,
392
+ modelCalls: loopCount,
393
+ model: resolvedModelId,
394
+ };
395
+
396
+ console.log(`\n--- AGENT LOOP FINISHED ---`);
397
+ console.log(`Stats: ${loopCount} loops | ${stepCount} tool calls | ~$${cost.estimatedCost.toFixed(4)}`);
398
+ console.log(
399
+ `Response: "${parsedOutput.text_content.slice(0, 150)}${parsedOutput.text_content.length > 150 ? "..." : ""}"`
400
+ );
401
+
402
+ // Add assistant's final response to conversation history
403
+ messages.push({ role: "assistant", content: parsedOutput.text_content });
404
+
405
+ const conversationMessages = messages.slice(1);
406
+ return { text: parsedOutput.text_content, messages: conversationMessages, cost };
407
+ }
408
+ } catch (error) {
409
+ // Abort signal fires as an error — exit cleanly
410
+ if (signal?.aborted || error.name === "AbortError") {
411
+ console.log(`[Loop ${loopCount}] Aborted mid-call.`);
412
+ return {
413
+ text: "Agent was stopped by the supervisor.",
414
+ messages: messages.slice(1),
415
+ cost: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, estimatedCost: 0, modelCalls: loopCount, model: resolvedModelId },
416
+ };
417
+ }
418
+
419
+ consecutiveErrors++;
420
+ console.log(`[Loop ${loopCount}] Model call failed (${consecutiveErrors}/3): ${error.message}`);
421
+
422
+ // Give up after 3 consecutive failures
423
+ if (consecutiveErrors >= 3) {
424
+ console.log(`[FATAL] 3 consecutive model failures. Stopping.`);
425
+ return {
426
+ text: `I encountered an error while processing your request: ${error.message}`,
427
+ messages: messages.slice(1),
428
+ cost: {
429
+ inputTokens: totalInputTokens,
430
+ outputTokens: totalOutputTokens,
431
+ estimatedCost: 0,
432
+ modelCalls: loopCount,
433
+ model: resolvedModelId,
434
+ },
435
+ };
436
+ }
437
+
438
+ // Retry with a user-role nudge (compatible with all providers)
439
+ messages.push({
440
+ role: "user",
441
+ content: `[System: previous call failed: ${error.message}] Please provide your final answer. Set type to "text" and finalResponse to true.`,
442
+ });
443
+ continue;
444
+ }
445
+ }
446
+ }
@@ -0,0 +1,143 @@
1
+ import { generateText } from "ai";
2
+ import { getCheapModel } from "../models/ModelRouter.js";
3
+ import { writeFileSync, mkdirSync } from "fs";
4
+ import { config } from "../config/default.js";
5
+ import eventBus from "./EventBus.js";
6
+
7
+ /**
8
+ * Context compaction system.
9
+ *
10
+ * When conversation history approaches the model's context window:
11
+ * 1. Estimate token count
12
+ * 2. If over threshold → summarize older messages
13
+ * 3. Prune verbose tool outputs
14
+ * 4. Persist large outputs to disk
15
+ * 5. Continue with compressed context
16
+ */
17
+
18
+ /**
19
+ * Rough token estimate: ~4 chars per token for English text.
20
+ */
21
+ export function estimateTokens(messages) {
22
+ let total = 0;
23
+ for (const msg of messages) {
24
+ const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
25
+ total += Math.ceil(content.length / 4);
26
+ }
27
+ return total;
28
+ }
29
+
30
+ /**
31
+ * Prune a single tool output — truncate if too long.
32
+ */
33
+ function pruneToolOutput(content, maxChars = 5000) {
34
+ if (typeof content !== "string") content = JSON.stringify(content);
35
+ if (content.length <= maxChars) return content;
36
+
37
+ const headSize = Math.floor(maxChars * 0.6);
38
+ const tailSize = Math.floor(maxChars * 0.3);
39
+ return (
40
+ content.slice(0, headSize) +
41
+ `\n\n[... truncated ${content.length - headSize - tailSize} chars ...]\n\n` +
42
+ content.slice(-tailSize)
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Persist a large tool output to disk and return a reference.
48
+ */
49
+ function persistLargeOutput(content, taskId, stepIndex) {
50
+ const dir = `${config.dataDir}/tool-outputs`;
51
+ mkdirSync(dir, { recursive: true });
52
+ const filename = `${taskId || "unknown"}-step${stepIndex}-${Date.now()}.txt`;
53
+ const filePath = `${dir}/${filename}`;
54
+ writeFileSync(filePath, content);
55
+ return `[Output saved to disk: ${filePath} — ${content.length} chars]`;
56
+ }
57
+
58
+ /**
59
+ * Check if compaction is needed and perform it.
60
+ *
61
+ * @param {Array} messages - Current message history
62
+ * @param {object} modelMeta - Model metadata (from models.js) with compactAt threshold
63
+ * @param {string} taskId - Current task ID for file persistence
64
+ * @returns {Array} Possibly compacted messages
65
+ */
66
+ export async function compactIfNeeded(messages, modelMeta, taskId = null) {
67
+ const tokenCount = estimateTokens(messages);
68
+
69
+ if (tokenCount < modelMeta.compactAt) {
70
+ return messages;
71
+ }
72
+
73
+ console.log(
74
+ `[Compaction] Triggered: ~${tokenCount} tokens exceeds threshold ${modelMeta.compactAt}`
75
+ );
76
+ eventBus.emitEvent("compact:triggered", { tokenCount, threshold: modelMeta.compactAt });
77
+
78
+ // Step 1: Identify protected messages (system prompt + last 3 exchanges)
79
+ const systemMsg = messages[0]; // always protect system prompt
80
+ const recentCount = 6; // last 3 user+assistant pairs
81
+ const recentMessages = messages.slice(-recentCount);
82
+ const oldMessages = messages.slice(1, -recentCount);
83
+
84
+ if (oldMessages.length === 0) {
85
+ // Nothing to compact — all messages are recent
86
+ return messages;
87
+ }
88
+
89
+ // Step 2: Prune verbose tool outputs in old messages
90
+ const prunedOld = oldMessages.map((msg, i) => {
91
+ if (msg.role === "developer" || msg.role === "tool") {
92
+ const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
93
+ if (content.length > 50000) {
94
+ return { ...msg, content: persistLargeOutput(content, taskId, i) };
95
+ }
96
+ if (content.length > 5000) {
97
+ return { ...msg, content: pruneToolOutput(content) };
98
+ }
99
+ }
100
+ return msg;
101
+ });
102
+
103
+ // Step 3: Summarize old messages using a cheap model
104
+ try {
105
+ const { model } = getCheapModel();
106
+ const summaryPrompt = `Summarize the following conversation history concisely. Preserve:
107
+ - Key decisions made
108
+ - File paths mentioned and their purpose
109
+ - Task progress and what was accomplished
110
+ - Any errors encountered and how they were resolved
111
+ - User preferences or instructions
112
+
113
+ Conversation to summarize:
114
+ ${prunedOld.map((m) => `[${m.role}]: ${typeof m.content === "string" ? m.content.slice(0, 2000) : JSON.stringify(m.content).slice(0, 2000)}`).join("\n")}`;
115
+
116
+ const { text: summary } = await generateText({
117
+ model,
118
+ messages: [{ role: "user", content: summaryPrompt }],
119
+ maxTokens: 1000,
120
+ });
121
+
122
+ const compactedMessages = [
123
+ systemMsg,
124
+ {
125
+ role: "developer",
126
+ content: `<conversation-summary>\nThe following is a summary of earlier conversation that was compacted to save context space:\n\n${summary}\n</conversation-summary>`,
127
+ },
128
+ ...recentMessages,
129
+ ];
130
+
131
+ const newTokenCount = estimateTokens(compactedMessages);
132
+ console.log(
133
+ `[Compaction] Done: ${tokenCount} → ~${newTokenCount} tokens (saved ~${tokenCount - newTokenCount})`
134
+ );
135
+
136
+ return compactedMessages;
137
+ } catch (error) {
138
+ console.log(`[Compaction] Summarization failed: ${error.message}. Falling back to pruning only.`);
139
+
140
+ // Fallback: just prune tool outputs without summarization
141
+ return [systemMsg, ...prunedOld, ...recentMessages];
142
+ }
143
+ }