@makefinks/daemon 0.1.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.
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/cli.js +22 -0
- package/package.json +79 -0
- package/src/ai/agent-turn-runner.ts +130 -0
- package/src/ai/daemon-ai.ts +403 -0
- package/src/ai/exa-client.ts +21 -0
- package/src/ai/exa-fetch-cache.ts +104 -0
- package/src/ai/model-config.ts +99 -0
- package/src/ai/sanitize-messages.ts +83 -0
- package/src/ai/system-prompt.ts +363 -0
- package/src/ai/tools/fetch-urls.ts +187 -0
- package/src/ai/tools/grounding-manager.ts +94 -0
- package/src/ai/tools/index.ts +52 -0
- package/src/ai/tools/read-file.ts +100 -0
- package/src/ai/tools/render-url.ts +275 -0
- package/src/ai/tools/run-bash.ts +224 -0
- package/src/ai/tools/subagents.ts +195 -0
- package/src/ai/tools/todo-manager.ts +150 -0
- package/src/ai/tools/web-search.ts +91 -0
- package/src/app/App.tsx +711 -0
- package/src/app/components/AppOverlays.tsx +131 -0
- package/src/app/components/AvatarLayer.tsx +51 -0
- package/src/app/components/ConversationPane.tsx +476 -0
- package/src/avatar/DaemonAvatarRenderable.ts +343 -0
- package/src/avatar/daemon-avatar-rig.ts +1165 -0
- package/src/avatar-preview.ts +186 -0
- package/src/cli.ts +26 -0
- package/src/components/ApiKeyInput.tsx +99 -0
- package/src/components/ApiKeyStep.tsx +95 -0
- package/src/components/ApprovalPicker.tsx +109 -0
- package/src/components/ContentBlockView.tsx +141 -0
- package/src/components/DaemonText.tsx +34 -0
- package/src/components/DeviceMenu.tsx +166 -0
- package/src/components/GroundingBadge.tsx +21 -0
- package/src/components/GroundingMenu.tsx +310 -0
- package/src/components/HotkeysPane.tsx +115 -0
- package/src/components/InlineStatusIndicator.tsx +106 -0
- package/src/components/ModelMenu.tsx +411 -0
- package/src/components/OnboardingOverlay.tsx +446 -0
- package/src/components/ProviderMenu.tsx +177 -0
- package/src/components/SessionMenu.tsx +297 -0
- package/src/components/SettingsMenu.tsx +291 -0
- package/src/components/StatusBar.tsx +126 -0
- package/src/components/TokenUsageDisplay.tsx +92 -0
- package/src/components/ToolCallView.tsx +113 -0
- package/src/components/TypingInputBar.tsx +131 -0
- package/src/components/tool-layouts/components.tsx +120 -0
- package/src/components/tool-layouts/defaults.ts +9 -0
- package/src/components/tool-layouts/index.ts +22 -0
- package/src/components/tool-layouts/layouts/bash.ts +110 -0
- package/src/components/tool-layouts/layouts/grounding.tsx +98 -0
- package/src/components/tool-layouts/layouts/index.ts +8 -0
- package/src/components/tool-layouts/layouts/read-file.ts +59 -0
- package/src/components/tool-layouts/layouts/subagent.tsx +118 -0
- package/src/components/tool-layouts/layouts/system-info.ts +8 -0
- package/src/components/tool-layouts/layouts/todo.tsx +139 -0
- package/src/components/tool-layouts/layouts/url-tools.ts +220 -0
- package/src/components/tool-layouts/layouts/web-search.ts +110 -0
- package/src/components/tool-layouts/registry.ts +17 -0
- package/src/components/tool-layouts/types.ts +94 -0
- package/src/hooks/daemon-event-handlers.ts +944 -0
- package/src/hooks/keyboard-handlers.ts +399 -0
- package/src/hooks/menu-navigation.ts +147 -0
- package/src/hooks/use-app-audio-devices-loader.ts +71 -0
- package/src/hooks/use-app-callbacks.ts +202 -0
- package/src/hooks/use-app-context-builder.ts +159 -0
- package/src/hooks/use-app-display-state.ts +162 -0
- package/src/hooks/use-app-menus.ts +51 -0
- package/src/hooks/use-app-model-pricing-loader.ts +45 -0
- package/src/hooks/use-app-model.ts +123 -0
- package/src/hooks/use-app-openrouter-models-loader.ts +44 -0
- package/src/hooks/use-app-openrouter-provider-loader.ts +35 -0
- package/src/hooks/use-app-preferences-bootstrap.ts +212 -0
- package/src/hooks/use-app-sessions.ts +105 -0
- package/src/hooks/use-app-settings.ts +62 -0
- package/src/hooks/use-conversation-manager.ts +163 -0
- package/src/hooks/use-copy-on-select.ts +50 -0
- package/src/hooks/use-daemon-events.ts +396 -0
- package/src/hooks/use-daemon-keyboard.ts +397 -0
- package/src/hooks/use-grounding.ts +46 -0
- package/src/hooks/use-input-history.ts +92 -0
- package/src/hooks/use-menu-keyboard.ts +93 -0
- package/src/hooks/use-playwright-notification.ts +23 -0
- package/src/hooks/use-reasoning-animation.ts +97 -0
- package/src/hooks/use-response-timer.ts +55 -0
- package/src/hooks/use-tool-approval.tsx +202 -0
- package/src/hooks/use-typing-mode.ts +137 -0
- package/src/hooks/use-voice-dependencies-notification.ts +37 -0
- package/src/index.tsx +48 -0
- package/src/scripts/setup-browsers.ts +42 -0
- package/src/state/app-context.tsx +160 -0
- package/src/state/daemon-events.ts +67 -0
- package/src/state/daemon-state.ts +493 -0
- package/src/state/migrations/001-init.ts +33 -0
- package/src/state/migrations/index.ts +8 -0
- package/src/state/model-history-store.ts +45 -0
- package/src/state/runtime-context.ts +21 -0
- package/src/state/session-store.ts +359 -0
- package/src/types/index.ts +405 -0
- package/src/types/theme.ts +52 -0
- package/src/ui/constants.ts +157 -0
- package/src/utils/clipboard.ts +89 -0
- package/src/utils/debug-logger.ts +69 -0
- package/src/utils/formatters.ts +242 -0
- package/src/utils/js-rendering.ts +77 -0
- package/src/utils/markdown-tables.ts +234 -0
- package/src/utils/model-metadata.ts +191 -0
- package/src/utils/openrouter-endpoints.ts +212 -0
- package/src/utils/openrouter-models.ts +205 -0
- package/src/utils/openrouter-pricing.ts +59 -0
- package/src/utils/openrouter-reported-cost.ts +16 -0
- package/src/utils/paste.ts +33 -0
- package/src/utils/preferences.ts +289 -0
- package/src/utils/text-fragment.ts +39 -0
- package/src/utils/tool-output-preview.ts +250 -0
- package/src/utils/voice-dependencies.ts +107 -0
- package/src/utils/workspace-manager.ts +85 -0
- package/src/voice/audio-recorder.ts +579 -0
- package/src/voice/mic-level.ts +35 -0
- package/src/voice/tts/openai-tts-stream.ts +222 -0
- package/src/voice/tts/speech-controller.ts +64 -0
- package/src/voice/tts/tts-player.ts +257 -0
- package/src/voice/voice-input-controller.ts +96 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { getDaemonManager } from "../../state/daemon-state";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 30000; // 30 seconds
|
|
7
|
+
const MAX_OUTPUT_LENGTH = 50000;
|
|
8
|
+
|
|
9
|
+
const DANGEROUS_COMMANDS = [
|
|
10
|
+
"rm",
|
|
11
|
+
"rmdir",
|
|
12
|
+
"mv",
|
|
13
|
+
"kill",
|
|
14
|
+
"killall",
|
|
15
|
+
"pkill",
|
|
16
|
+
"shutdown",
|
|
17
|
+
"reboot",
|
|
18
|
+
"halt",
|
|
19
|
+
"poweroff",
|
|
20
|
+
"init",
|
|
21
|
+
"systemctl",
|
|
22
|
+
"chmod",
|
|
23
|
+
"chown",
|
|
24
|
+
"chgrp",
|
|
25
|
+
"mkfs",
|
|
26
|
+
"fdisk",
|
|
27
|
+
"dd",
|
|
28
|
+
"format",
|
|
29
|
+
"sudo",
|
|
30
|
+
"su",
|
|
31
|
+
"doas",
|
|
32
|
+
"passwd",
|
|
33
|
+
"useradd",
|
|
34
|
+
"userdel",
|
|
35
|
+
"usermod",
|
|
36
|
+
"groupadd",
|
|
37
|
+
"groupdel",
|
|
38
|
+
"visudo",
|
|
39
|
+
"crontab",
|
|
40
|
+
"iptables",
|
|
41
|
+
"ufw",
|
|
42
|
+
"firewall-cmd",
|
|
43
|
+
"mount",
|
|
44
|
+
"umount",
|
|
45
|
+
"fstab",
|
|
46
|
+
"apt-get remove",
|
|
47
|
+
"apt-get purge",
|
|
48
|
+
"apt remove",
|
|
49
|
+
"apt purge",
|
|
50
|
+
"yum remove",
|
|
51
|
+
"yum erase",
|
|
52
|
+
"dnf remove",
|
|
53
|
+
"pacman -R",
|
|
54
|
+
"brew uninstall",
|
|
55
|
+
"npm uninstall -g",
|
|
56
|
+
"pip uninstall",
|
|
57
|
+
"truncate",
|
|
58
|
+
"shred",
|
|
59
|
+
"wipefs",
|
|
60
|
+
">",
|
|
61
|
+
">>",
|
|
62
|
+
"git push --force",
|
|
63
|
+
"git push -f",
|
|
64
|
+
"git reset --hard",
|
|
65
|
+
"git clean -fd",
|
|
66
|
+
"docker rm",
|
|
67
|
+
"docker rmi",
|
|
68
|
+
"docker system prune",
|
|
69
|
+
"kubectl delete",
|
|
70
|
+
"terraform destroy",
|
|
71
|
+
"drop database",
|
|
72
|
+
"drop table",
|
|
73
|
+
"delete from",
|
|
74
|
+
"truncate table",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const DANGEROUS_PATTERNS = [
|
|
78
|
+
/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*|\s).*\//i,
|
|
79
|
+
/\brm\s+-rf?\s/i,
|
|
80
|
+
/\bkill\s+-9\b/i,
|
|
81
|
+
/\bsudo\s/i,
|
|
82
|
+
/\bsu\s+-?\s*$/i,
|
|
83
|
+
/\bchmod\s+[0-7]{3,4}\s/i,
|
|
84
|
+
/\bchown\s/i,
|
|
85
|
+
/\bdd\s+if=/i,
|
|
86
|
+
/>\s*\/dev\//i,
|
|
87
|
+
/\|.*\bsh\b/i,
|
|
88
|
+
/\|.*\bbash\b/i,
|
|
89
|
+
/curl.*\|\s*(ba)?sh/i,
|
|
90
|
+
/wget.*\|\s*(ba)?sh/i,
|
|
91
|
+
/eval\s*\$/i,
|
|
92
|
+
/\$\(.*\)/,
|
|
93
|
+
/`.*`/,
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
function isDangerousCommand(command: string): boolean {
|
|
97
|
+
const normalizedCmd = command.toLowerCase().trim();
|
|
98
|
+
|
|
99
|
+
for (const dangerous of DANGEROUS_COMMANDS) {
|
|
100
|
+
if (dangerous.includes(" ")) {
|
|
101
|
+
if (normalizedCmd.includes(dangerous.toLowerCase())) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
const wordBoundaryRegex = new RegExp(`\\b${dangerous}\\b`, "i");
|
|
106
|
+
if (wordBoundaryRegex.test(command)) {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const pattern of DANGEROUS_PATTERNS) {
|
|
113
|
+
if (pattern.test(command)) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const runBash = tool({
|
|
122
|
+
description:
|
|
123
|
+
"Execute a bash command on the user's system. Use this to run shell commands, scripts, install packages, manage files, or perform any terminal operation. Commands run in the current working directory by default.",
|
|
124
|
+
inputSchema: z.object({
|
|
125
|
+
description: z
|
|
126
|
+
.string()
|
|
127
|
+
.describe(
|
|
128
|
+
"A brief description (5-10 words) of what this command does, so the user understands the purpose."
|
|
129
|
+
),
|
|
130
|
+
command: z
|
|
131
|
+
.string()
|
|
132
|
+
.describe("The bash command to execute. Can include pipes, redirects, and chained commands."),
|
|
133
|
+
workdir: z
|
|
134
|
+
.string()
|
|
135
|
+
.optional()
|
|
136
|
+
.describe("Working directory to run the command in. Defaults to current working directory."),
|
|
137
|
+
timeout: z
|
|
138
|
+
.number()
|
|
139
|
+
.optional()
|
|
140
|
+
.default(DEFAULT_TIMEOUT_MS)
|
|
141
|
+
.describe("Timeout in milliseconds. Defaults to 20 seconds."),
|
|
142
|
+
}),
|
|
143
|
+
needsApproval: async ({ command }) => {
|
|
144
|
+
const manager = getDaemonManager();
|
|
145
|
+
const approvalLevel = manager.bashApprovalLevel;
|
|
146
|
+
|
|
147
|
+
if (approvalLevel === "none") {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (approvalLevel === "all") {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return isDangerousCommand(command);
|
|
156
|
+
},
|
|
157
|
+
execute: async ({ command, workdir, timeout }) => {
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
const cwd = workdir || process.cwd();
|
|
160
|
+
let stdout = "";
|
|
161
|
+
let stderr = "";
|
|
162
|
+
let killed = false;
|
|
163
|
+
|
|
164
|
+
const proc = spawn("bash", ["-c", command], {
|
|
165
|
+
cwd,
|
|
166
|
+
env: process.env,
|
|
167
|
+
shell: false,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const timeoutId = setTimeout(() => {
|
|
171
|
+
killed = true;
|
|
172
|
+
proc.kill("SIGKILL");
|
|
173
|
+
}, timeout || DEFAULT_TIMEOUT_MS);
|
|
174
|
+
|
|
175
|
+
proc.stdout.on("data", (data: Buffer) => {
|
|
176
|
+
stdout += data.toString();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
proc.stderr.on("data", (data: Buffer) => {
|
|
180
|
+
stderr += data.toString();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
proc.on("close", (code) => {
|
|
184
|
+
clearTimeout(timeoutId);
|
|
185
|
+
|
|
186
|
+
// Truncate output if too long
|
|
187
|
+
if (stdout.length > MAX_OUTPUT_LENGTH) {
|
|
188
|
+
stdout = stdout.slice(0, MAX_OUTPUT_LENGTH) + "\n... [output truncated]";
|
|
189
|
+
}
|
|
190
|
+
if (stderr.length > MAX_OUTPUT_LENGTH) {
|
|
191
|
+
stderr = stderr.slice(0, MAX_OUTPUT_LENGTH) + "\n... [output truncated]";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (killed) {
|
|
195
|
+
resolve({
|
|
196
|
+
success: false,
|
|
197
|
+
exitCode: null,
|
|
198
|
+
stdout: stdout.trim(),
|
|
199
|
+
stderr: stderr.trim(),
|
|
200
|
+
error: `Command timed out after ${timeout || DEFAULT_TIMEOUT_MS}ms`,
|
|
201
|
+
});
|
|
202
|
+
} else {
|
|
203
|
+
resolve({
|
|
204
|
+
success: code === 0,
|
|
205
|
+
exitCode: code,
|
|
206
|
+
stdout: stdout.trim(),
|
|
207
|
+
stderr: stderr.trim(),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
proc.on("error", (error) => {
|
|
213
|
+
clearTimeout(timeoutId);
|
|
214
|
+
resolve({
|
|
215
|
+
success: false,
|
|
216
|
+
exitCode: null,
|
|
217
|
+
stdout: stdout.trim(),
|
|
218
|
+
stderr: stderr.trim(),
|
|
219
|
+
error: error instanceof Error ? error.message : String(error),
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent tool for delegating tasks.
|
|
3
|
+
* DAEMON can call this tool multiple times in parallel to spawn concurrent subagents.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { tool } from "ai";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
|
9
|
+
import { ToolLoopAgent, stepCountIs } from "ai";
|
|
10
|
+
import { fetchUrls } from "./fetch-urls";
|
|
11
|
+
import { renderUrl } from "./render-url";
|
|
12
|
+
import { webSearch } from "./web-search";
|
|
13
|
+
|
|
14
|
+
import { readFile } from "./read-file";
|
|
15
|
+
import { runBash } from "./run-bash";
|
|
16
|
+
import { todoManager } from "./todo-manager";
|
|
17
|
+
import { buildOpenRouterChatSettings, getSubagentModel } from "../model-config";
|
|
18
|
+
import { isWebSearchAvailable } from "./index";
|
|
19
|
+
import type { ToolSet } from "ai";
|
|
20
|
+
import type { SubagentProgressEmitter } from "../../types";
|
|
21
|
+
import { detectLocalPlaywrightChromium } from "../../utils/js-rendering";
|
|
22
|
+
import { getOpenRouterReportedCost } from "../../utils/openrouter-reported-cost";
|
|
23
|
+
|
|
24
|
+
// OpenRouter client for subagents
|
|
25
|
+
const openrouter = createOpenRouter();
|
|
26
|
+
|
|
27
|
+
// Maximum steps for subagent loops
|
|
28
|
+
const MAX_SUBAGENT_STEPS = 30;
|
|
29
|
+
|
|
30
|
+
let cachedSubagentTools: Promise<ToolSet> | null = null;
|
|
31
|
+
|
|
32
|
+
// Subagent tools (all tools except subagent itself to prevent recursion)
|
|
33
|
+
async function getSubagentTools(): Promise<ToolSet> {
|
|
34
|
+
if (cachedSubagentTools) return cachedSubagentTools;
|
|
35
|
+
|
|
36
|
+
cachedSubagentTools = (async () => {
|
|
37
|
+
const tools: ToolSet = {
|
|
38
|
+
readFile,
|
|
39
|
+
runBash,
|
|
40
|
+
todoManager,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (isWebSearchAvailable()) {
|
|
44
|
+
(tools as ToolSet & { webSearch: typeof webSearch }).webSearch = webSearch;
|
|
45
|
+
(tools as ToolSet & { fetchUrls: typeof fetchUrls }).fetchUrls = fetchUrls;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const jsRendering = await detectLocalPlaywrightChromium();
|
|
49
|
+
if (jsRendering.available) {
|
|
50
|
+
return { ...tools, renderUrl };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return tools;
|
|
54
|
+
})();
|
|
55
|
+
|
|
56
|
+
return cachedSubagentTools;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// System prompt for subagents
|
|
60
|
+
function buildSubagentSystemPrompt(webSearchAvailable: boolean): string {
|
|
61
|
+
const disabledNotice = !webSearchAvailable
|
|
62
|
+
? "\nNOTICE: Web search and URL fetching are DISABLED because EXA_API_KEY is not configured.\n"
|
|
63
|
+
: "";
|
|
64
|
+
|
|
65
|
+
return `
|
|
66
|
+
You are a focused subagent. You have been spawned to complete a specific task by a main agent.${disabledNotice}
|
|
67
|
+
|
|
68
|
+
RULES:
|
|
69
|
+
- Complete the assigned task thoroughly.
|
|
70
|
+
- Use tools as needed to accomplish the task.
|
|
71
|
+
- Do not ask clarifying questions - make reasonable assumptions.
|
|
72
|
+
- Be direct and factual in your response.
|
|
73
|
+
- If you cannot complete the task, explain why clearly.
|
|
74
|
+
- Return a clear, detailed summary of what you found or accomplished.
|
|
75
|
+
- The final summary needs to be self contained and needs to provide enough information to the main agent so it is clear what you have done and what the results are.
|
|
76
|
+
|
|
77
|
+
Today's date: ${new Date().toISOString().split("T")[0]}
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Global emitter that will be set by the daemon-ai module
|
|
82
|
+
let progressEmitter: SubagentProgressEmitter | null = null;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Set the progress emitter for subagent updates.
|
|
86
|
+
* Called by daemon-ai when setting up the response generation.
|
|
87
|
+
*/
|
|
88
|
+
export function setSubagentProgressEmitter(emitter: SubagentProgressEmitter | null): void {
|
|
89
|
+
progressEmitter = emitter;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The subagent tool - spawns a single subagent to complete a task.
|
|
94
|
+
* DAEMON can call this multiple times in parallel for concurrent execution.
|
|
95
|
+
*/
|
|
96
|
+
export const subagent = tool({
|
|
97
|
+
description: `Spawn a subagent to complete a specific task. The subagent has access to available tools (bash, system info, etc.) except spawning more subagents.
|
|
98
|
+
|
|
99
|
+
Call this tool multiple times in parallel to execute tasks concurrently.
|
|
100
|
+
|
|
101
|
+
Use this when you need to:
|
|
102
|
+
- Delegate a research or information-gathering task
|
|
103
|
+
- Run an independent operation while continuing other work
|
|
104
|
+
- Parallelize multiple lookups or checks
|
|
105
|
+
|
|
106
|
+
Provide a concise summary for display and a very specific task description (especially for complex work).`,
|
|
107
|
+
inputSchema: z.object({
|
|
108
|
+
summary: z
|
|
109
|
+
.string()
|
|
110
|
+
.describe("A concise summary with a bit of detail (not just a title). Shown in the UI."),
|
|
111
|
+
task: z.string().describe("A specific, scoped description of what the subagent should accomplish."),
|
|
112
|
+
}),
|
|
113
|
+
execute: async ({ summary, task }, { toolCallId }) => {
|
|
114
|
+
try {
|
|
115
|
+
const tools = await getSubagentTools();
|
|
116
|
+
const webSearchAvailable = isWebSearchAvailable();
|
|
117
|
+
|
|
118
|
+
// Create ephemeral subagent
|
|
119
|
+
const subagent = new ToolLoopAgent({
|
|
120
|
+
model: openrouter.chat(getSubagentModel(), buildOpenRouterChatSettings()),
|
|
121
|
+
instructions: buildSubagentSystemPrompt(webSearchAvailable),
|
|
122
|
+
tools,
|
|
123
|
+
stopWhen: stepCountIs(MAX_SUBAGENT_STEPS),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
let responseText = "";
|
|
127
|
+
let costTotal = 0;
|
|
128
|
+
let hasCost = false;
|
|
129
|
+
|
|
130
|
+
// Stream the subagent response to capture tool calls for UI
|
|
131
|
+
const stream = await subagent.stream({
|
|
132
|
+
messages: [{ role: "user" as const, content: task }],
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
for await (const part of stream.fullStream) {
|
|
136
|
+
if (part.type === "text-delta") {
|
|
137
|
+
responseText += part.text;
|
|
138
|
+
} else if (part.type === "finish-step") {
|
|
139
|
+
const reportedCost = getOpenRouterReportedCost(part.providerMetadata);
|
|
140
|
+
if (reportedCost !== undefined) {
|
|
141
|
+
costTotal += reportedCost;
|
|
142
|
+
hasCost = true;
|
|
143
|
+
}
|
|
144
|
+
} else if (part.type === "tool-call") {
|
|
145
|
+
// Emit tool call event for UI update
|
|
146
|
+
// Use setImmediate to yield to the event loop and allow React to re-render
|
|
147
|
+
setImmediate(() => {
|
|
148
|
+
progressEmitter?.onSubagentToolCall(toolCallId, part.toolName, part.input);
|
|
149
|
+
});
|
|
150
|
+
} else if (part.type === "tool-result") {
|
|
151
|
+
// Emit tool result event
|
|
152
|
+
const success =
|
|
153
|
+
typeof part.output === "object" && part.output !== null && "success" in part.output
|
|
154
|
+
? Boolean((part.output as { success?: unknown }).success)
|
|
155
|
+
: true;
|
|
156
|
+
setImmediate(() => {
|
|
157
|
+
progressEmitter?.onSubagentToolResult(toolCallId, part.toolName, success);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const streamUsage = await stream.usage;
|
|
163
|
+
if (streamUsage) {
|
|
164
|
+
progressEmitter?.onSubagentUsage({
|
|
165
|
+
promptTokens: streamUsage.inputTokens ?? 0,
|
|
166
|
+
completionTokens: streamUsage.outputTokens ?? 0,
|
|
167
|
+
totalTokens: streamUsage.totalTokens ?? 0,
|
|
168
|
+
reasoningTokens: streamUsage.outputTokenDetails?.reasoningTokens ?? 0,
|
|
169
|
+
cachedInputTokens: streamUsage.inputTokenDetails?.cacheReadTokens ?? 0,
|
|
170
|
+
cost: hasCost ? costTotal : undefined,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Emit completion
|
|
175
|
+
progressEmitter?.onSubagentComplete(toolCallId, true);
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
success: true,
|
|
179
|
+
summary,
|
|
180
|
+
response: responseText || "Task completed but no text response generated.",
|
|
181
|
+
};
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
184
|
+
|
|
185
|
+
// Emit failure
|
|
186
|
+
progressEmitter?.onSubagentComplete(toolCallId, false);
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
success: false,
|
|
190
|
+
summary,
|
|
191
|
+
response: `Error: ${errorMessage}`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { TodoItem, TodoStatus } from "../../types";
|
|
4
|
+
|
|
5
|
+
// Session-based todo storage
|
|
6
|
+
const todoSessions = new Map<string, TodoItem[]>();
|
|
7
|
+
|
|
8
|
+
// Default session for single-user mode
|
|
9
|
+
const DEFAULT_SESSION = "default";
|
|
10
|
+
|
|
11
|
+
function getTodos(sessionId: string = DEFAULT_SESSION): TodoItem[] {
|
|
12
|
+
if (!todoSessions.has(sessionId)) {
|
|
13
|
+
todoSessions.set(sessionId, []);
|
|
14
|
+
}
|
|
15
|
+
return todoSessions.get(sessionId)!;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function setTodos(sessionId: string, todos: TodoItem[]): void {
|
|
19
|
+
todoSessions.set(sessionId, todos);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatTodoList(todos: TodoItem[]): string {
|
|
23
|
+
if (todos.length === 0) {
|
|
24
|
+
return "No todos.";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const statusIcon: Record<TodoItem["status"], string> = {
|
|
28
|
+
pending: "[pending]",
|
|
29
|
+
in_progress: "[in_progress]",
|
|
30
|
+
completed: "[completed]",
|
|
31
|
+
cancelled: "[cancelled]",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const lines = todos.map((todo, index) => {
|
|
35
|
+
return `${index + 1}. ${statusIcon[todo.status]} ${todo.content}`;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Schema for a single todo item in the write action
|
|
42
|
+
const todoItemSchema = z.object({
|
|
43
|
+
content: z.string().describe("The todo item description"),
|
|
44
|
+
status: z
|
|
45
|
+
.enum(["pending", "in_progress", "completed", "cancelled"])
|
|
46
|
+
.default("pending")
|
|
47
|
+
.describe("Status of the todo"),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const todoManager = tool({
|
|
51
|
+
description: `Manage a todo list to plan and track your actions.
|
|
52
|
+
|
|
53
|
+
Actions:
|
|
54
|
+
- write: Replace the entire todo list. Each item can have its own status, so you can write the full list AND set one task to in_progress in a single call.
|
|
55
|
+
- update: Update a single todo's status by index (1-based)
|
|
56
|
+
- list: Show current todos
|
|
57
|
+
|
|
58
|
+
Example write with status:
|
|
59
|
+
{ "action": "write", "todos": [{ "content": "First task", "status": "in_progress" }, { "content": "Second task", "status": "pending" }] }`,
|
|
60
|
+
inputSchema: z.object({
|
|
61
|
+
action: z.enum(["write", "update", "list"]).describe("The action to perform"),
|
|
62
|
+
todos: z.array(todoItemSchema).optional().describe("Array of todo items (required for 'write')"),
|
|
63
|
+
index: z.number().optional().describe("1-based index of the todo to update (required for 'update')"),
|
|
64
|
+
status: z
|
|
65
|
+
.enum(["pending", "in_progress", "completed", "cancelled"])
|
|
66
|
+
.optional()
|
|
67
|
+
.describe("New status for the todo (used with 'update')"),
|
|
68
|
+
sessionId: z.string().optional().describe("Session ID for isolation. Defaults to 'default'."),
|
|
69
|
+
}),
|
|
70
|
+
execute: async ({ action, todos: newTodos, index, status, sessionId }) => {
|
|
71
|
+
const session = sessionId || DEFAULT_SESSION;
|
|
72
|
+
|
|
73
|
+
switch (action) {
|
|
74
|
+
case "write": {
|
|
75
|
+
if (!newTodos || newTodos.length === 0) {
|
|
76
|
+
setTodos(session, []);
|
|
77
|
+
return {
|
|
78
|
+
success: true,
|
|
79
|
+
message: "Cleared all todos",
|
|
80
|
+
list: "No todos.",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const items: TodoItem[] = newTodos.map((t) => ({
|
|
84
|
+
content: t.content,
|
|
85
|
+
status: t.status || "pending",
|
|
86
|
+
}));
|
|
87
|
+
setTodos(session, items);
|
|
88
|
+
return {
|
|
89
|
+
success: true,
|
|
90
|
+
message: `Set ${items.length} todos`,
|
|
91
|
+
list: formatTodoList(items),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
case "update": {
|
|
96
|
+
if (index === undefined) {
|
|
97
|
+
return {
|
|
98
|
+
success: false,
|
|
99
|
+
error: "Index is required for 'update'",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const todos = getTodos(session);
|
|
103
|
+
const idx = index - 1; // Convert to 0-based
|
|
104
|
+
if (idx < 0 || idx >= todos.length) {
|
|
105
|
+
return {
|
|
106
|
+
success: false,
|
|
107
|
+
error: `Invalid index ${index}. Valid range: 1-${todos.length}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const todo = todos[idx]!;
|
|
111
|
+
if (status) {
|
|
112
|
+
todo.status = status;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
success: true,
|
|
116
|
+
message: `Updated #${index}: ${todo.content} -> ${todo.status}`,
|
|
117
|
+
list: formatTodoList(todos),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
case "list": {
|
|
122
|
+
const todos = getTodos(session);
|
|
123
|
+
return {
|
|
124
|
+
success: true,
|
|
125
|
+
count: todos.length,
|
|
126
|
+
pending: todos.filter((t) => t.status === "pending").length,
|
|
127
|
+
inProgress: todos.filter((t) => t.status === "in_progress").length,
|
|
128
|
+
completed: todos.filter((t) => t.status === "completed").length,
|
|
129
|
+
list: formatTodoList(todos),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
default:
|
|
134
|
+
return {
|
|
135
|
+
success: false,
|
|
136
|
+
error: `Unknown action: ${action}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Export a function to clear all sessions (useful for testing)
|
|
143
|
+
export function clearAllTodoSessions(): void {
|
|
144
|
+
todoSessions.clear();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Export function to get current todos for UI display
|
|
148
|
+
export function getCurrentTodos(sessionId: string = DEFAULT_SESSION): TodoItem[] {
|
|
149
|
+
return [...getTodos(sessionId)];
|
|
150
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getExaClient } from "../exa-client";
|
|
4
|
+
|
|
5
|
+
const RecencyEnum = z.enum(["day", "week", "month", "year"]);
|
|
6
|
+
|
|
7
|
+
function recencyToStartDate(recency: z.infer<typeof RecencyEnum>): string {
|
|
8
|
+
const now = new Date();
|
|
9
|
+
switch (recency) {
|
|
10
|
+
case "day":
|
|
11
|
+
now.setDate(now.getDate() - 1);
|
|
12
|
+
break;
|
|
13
|
+
case "week":
|
|
14
|
+
now.setDate(now.getDate() - 7);
|
|
15
|
+
break;
|
|
16
|
+
case "month":
|
|
17
|
+
now.setMonth(now.getMonth() - 1);
|
|
18
|
+
break;
|
|
19
|
+
case "year":
|
|
20
|
+
now.setFullYear(now.getFullYear() - 1);
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
return now.toISOString();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const webSearch = tool({
|
|
27
|
+
description:
|
|
28
|
+
"Searches the web for information. Returns metadata (title, URL, published date) for relevant web pages. Use this to discover URLs, then use fetchUrls to read contents.",
|
|
29
|
+
inputSchema: z.object({
|
|
30
|
+
query: z.string().describe("The search query to find relevant web pages for."),
|
|
31
|
+
numResults: z
|
|
32
|
+
.number()
|
|
33
|
+
.min(1)
|
|
34
|
+
.max(20)
|
|
35
|
+
.default(10)
|
|
36
|
+
.describe("Number of results to return. Defaults to 10, max 20."),
|
|
37
|
+
recency: RecencyEnum.optional().describe(
|
|
38
|
+
"Filter to recent results: 'day', 'week', 'month', or 'year'. Omit for all time."
|
|
39
|
+
),
|
|
40
|
+
includeDomains: z
|
|
41
|
+
.array(z.string())
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("Limit search to specific domains (e.g., ['arxiv.org', 'github.com'])."),
|
|
44
|
+
}),
|
|
45
|
+
execute: async ({ query, numResults, recency, includeDomains }) => {
|
|
46
|
+
const exaClientResult = getExaClient();
|
|
47
|
+
if ("error" in exaClientResult) {
|
|
48
|
+
return { success: false, error: exaClientResult.error };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const searchOptions: Record<string, unknown> = {
|
|
53
|
+
numResults,
|
|
54
|
+
type: "auto",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (recency) {
|
|
58
|
+
searchOptions.startPublishedDate = recencyToStartDate(recency);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (includeDomains && includeDomains.length > 0) {
|
|
62
|
+
searchOptions.includeDomains = includeDomains;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const rawData = (await exaClientResult.client.search(query, searchOptions)) as unknown as {
|
|
66
|
+
results: Array<{ title?: string; url?: string; publishedDate?: string; [key: string]: unknown }>;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const results = (rawData.results ?? []).map((r) => {
|
|
70
|
+
const result: { title?: string; url?: string; publishedDate?: string } = {};
|
|
71
|
+
if (typeof r.title === "string") result.title = r.title;
|
|
72
|
+
if (typeof r.url === "string") result.url = r.url;
|
|
73
|
+
if (typeof r.publishedDate === "string") {
|
|
74
|
+
result.publishedDate = r.publishedDate;
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
success: true,
|
|
81
|
+
data: { results },
|
|
82
|
+
};
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
error: err.message,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
});
|