@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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/dist/cli.js +22 -0
  4. package/package.json +79 -0
  5. package/src/ai/agent-turn-runner.ts +130 -0
  6. package/src/ai/daemon-ai.ts +403 -0
  7. package/src/ai/exa-client.ts +21 -0
  8. package/src/ai/exa-fetch-cache.ts +104 -0
  9. package/src/ai/model-config.ts +99 -0
  10. package/src/ai/sanitize-messages.ts +83 -0
  11. package/src/ai/system-prompt.ts +363 -0
  12. package/src/ai/tools/fetch-urls.ts +187 -0
  13. package/src/ai/tools/grounding-manager.ts +94 -0
  14. package/src/ai/tools/index.ts +52 -0
  15. package/src/ai/tools/read-file.ts +100 -0
  16. package/src/ai/tools/render-url.ts +275 -0
  17. package/src/ai/tools/run-bash.ts +224 -0
  18. package/src/ai/tools/subagents.ts +195 -0
  19. package/src/ai/tools/todo-manager.ts +150 -0
  20. package/src/ai/tools/web-search.ts +91 -0
  21. package/src/app/App.tsx +711 -0
  22. package/src/app/components/AppOverlays.tsx +131 -0
  23. package/src/app/components/AvatarLayer.tsx +51 -0
  24. package/src/app/components/ConversationPane.tsx +476 -0
  25. package/src/avatar/DaemonAvatarRenderable.ts +343 -0
  26. package/src/avatar/daemon-avatar-rig.ts +1165 -0
  27. package/src/avatar-preview.ts +186 -0
  28. package/src/cli.ts +26 -0
  29. package/src/components/ApiKeyInput.tsx +99 -0
  30. package/src/components/ApiKeyStep.tsx +95 -0
  31. package/src/components/ApprovalPicker.tsx +109 -0
  32. package/src/components/ContentBlockView.tsx +141 -0
  33. package/src/components/DaemonText.tsx +34 -0
  34. package/src/components/DeviceMenu.tsx +166 -0
  35. package/src/components/GroundingBadge.tsx +21 -0
  36. package/src/components/GroundingMenu.tsx +310 -0
  37. package/src/components/HotkeysPane.tsx +115 -0
  38. package/src/components/InlineStatusIndicator.tsx +106 -0
  39. package/src/components/ModelMenu.tsx +411 -0
  40. package/src/components/OnboardingOverlay.tsx +446 -0
  41. package/src/components/ProviderMenu.tsx +177 -0
  42. package/src/components/SessionMenu.tsx +297 -0
  43. package/src/components/SettingsMenu.tsx +291 -0
  44. package/src/components/StatusBar.tsx +126 -0
  45. package/src/components/TokenUsageDisplay.tsx +92 -0
  46. package/src/components/ToolCallView.tsx +113 -0
  47. package/src/components/TypingInputBar.tsx +131 -0
  48. package/src/components/tool-layouts/components.tsx +120 -0
  49. package/src/components/tool-layouts/defaults.ts +9 -0
  50. package/src/components/tool-layouts/index.ts +22 -0
  51. package/src/components/tool-layouts/layouts/bash.ts +110 -0
  52. package/src/components/tool-layouts/layouts/grounding.tsx +98 -0
  53. package/src/components/tool-layouts/layouts/index.ts +8 -0
  54. package/src/components/tool-layouts/layouts/read-file.ts +59 -0
  55. package/src/components/tool-layouts/layouts/subagent.tsx +118 -0
  56. package/src/components/tool-layouts/layouts/system-info.ts +8 -0
  57. package/src/components/tool-layouts/layouts/todo.tsx +139 -0
  58. package/src/components/tool-layouts/layouts/url-tools.ts +220 -0
  59. package/src/components/tool-layouts/layouts/web-search.ts +110 -0
  60. package/src/components/tool-layouts/registry.ts +17 -0
  61. package/src/components/tool-layouts/types.ts +94 -0
  62. package/src/hooks/daemon-event-handlers.ts +944 -0
  63. package/src/hooks/keyboard-handlers.ts +399 -0
  64. package/src/hooks/menu-navigation.ts +147 -0
  65. package/src/hooks/use-app-audio-devices-loader.ts +71 -0
  66. package/src/hooks/use-app-callbacks.ts +202 -0
  67. package/src/hooks/use-app-context-builder.ts +159 -0
  68. package/src/hooks/use-app-display-state.ts +162 -0
  69. package/src/hooks/use-app-menus.ts +51 -0
  70. package/src/hooks/use-app-model-pricing-loader.ts +45 -0
  71. package/src/hooks/use-app-model.ts +123 -0
  72. package/src/hooks/use-app-openrouter-models-loader.ts +44 -0
  73. package/src/hooks/use-app-openrouter-provider-loader.ts +35 -0
  74. package/src/hooks/use-app-preferences-bootstrap.ts +212 -0
  75. package/src/hooks/use-app-sessions.ts +105 -0
  76. package/src/hooks/use-app-settings.ts +62 -0
  77. package/src/hooks/use-conversation-manager.ts +163 -0
  78. package/src/hooks/use-copy-on-select.ts +50 -0
  79. package/src/hooks/use-daemon-events.ts +396 -0
  80. package/src/hooks/use-daemon-keyboard.ts +397 -0
  81. package/src/hooks/use-grounding.ts +46 -0
  82. package/src/hooks/use-input-history.ts +92 -0
  83. package/src/hooks/use-menu-keyboard.ts +93 -0
  84. package/src/hooks/use-playwright-notification.ts +23 -0
  85. package/src/hooks/use-reasoning-animation.ts +97 -0
  86. package/src/hooks/use-response-timer.ts +55 -0
  87. package/src/hooks/use-tool-approval.tsx +202 -0
  88. package/src/hooks/use-typing-mode.ts +137 -0
  89. package/src/hooks/use-voice-dependencies-notification.ts +37 -0
  90. package/src/index.tsx +48 -0
  91. package/src/scripts/setup-browsers.ts +42 -0
  92. package/src/state/app-context.tsx +160 -0
  93. package/src/state/daemon-events.ts +67 -0
  94. package/src/state/daemon-state.ts +493 -0
  95. package/src/state/migrations/001-init.ts +33 -0
  96. package/src/state/migrations/index.ts +8 -0
  97. package/src/state/model-history-store.ts +45 -0
  98. package/src/state/runtime-context.ts +21 -0
  99. package/src/state/session-store.ts +359 -0
  100. package/src/types/index.ts +405 -0
  101. package/src/types/theme.ts +52 -0
  102. package/src/ui/constants.ts +157 -0
  103. package/src/utils/clipboard.ts +89 -0
  104. package/src/utils/debug-logger.ts +69 -0
  105. package/src/utils/formatters.ts +242 -0
  106. package/src/utils/js-rendering.ts +77 -0
  107. package/src/utils/markdown-tables.ts +234 -0
  108. package/src/utils/model-metadata.ts +191 -0
  109. package/src/utils/openrouter-endpoints.ts +212 -0
  110. package/src/utils/openrouter-models.ts +205 -0
  111. package/src/utils/openrouter-pricing.ts +59 -0
  112. package/src/utils/openrouter-reported-cost.ts +16 -0
  113. package/src/utils/paste.ts +33 -0
  114. package/src/utils/preferences.ts +289 -0
  115. package/src/utils/text-fragment.ts +39 -0
  116. package/src/utils/tool-output-preview.ts +250 -0
  117. package/src/utils/voice-dependencies.ts +107 -0
  118. package/src/utils/workspace-manager.ts +85 -0
  119. package/src/voice/audio-recorder.ts +579 -0
  120. package/src/voice/mic-level.ts +35 -0
  121. package/src/voice/tts/openai-tts-stream.ts +222 -0
  122. package/src/voice/tts/speech-controller.ts +64 -0
  123. package/src/voice/tts/tts-player.ts +257 -0
  124. package/src/voice/voice-input-controller.ts +96 -0
@@ -0,0 +1,403 @@
1
+ /**
2
+ * AI integration for DAEMON using Vercel AI SDK.
3
+ * Handles transcription and response generation.
4
+ */
5
+
6
+ import { createOpenAI } from "@ai-sdk/openai";
7
+ import { createOpenRouter } from "@openrouter/ai-sdk-provider";
8
+ import {
9
+ ToolLoopAgent,
10
+ generateText,
11
+ stepCountIs,
12
+ experimental_transcribe as transcribe,
13
+ type ModelMessage,
14
+ } from "ai";
15
+ import { getDaemonTools, isWebSearchAvailable } from "./tools/index";
16
+ import { setSubagentProgressEmitter } from "./tools/subagents";
17
+ import { buildDaemonSystemPrompt, type InteractionMode } from "./system-prompt";
18
+ import { buildOpenRouterChatSettings, getResponseModel, TRANSCRIPTION_MODEL } from "./model-config";
19
+ import { debug } from "../utils/debug-logger";
20
+ import { getWorkspacePath } from "../utils/workspace-manager";
21
+ import { getRuntimeContext } from "../state/runtime-context";
22
+ import { getOpenRouterReportedCost } from "../utils/openrouter-reported-cost";
23
+ import type {
24
+ TokenUsage,
25
+ TranscriptionResult,
26
+ StreamCallbacks,
27
+ ReasoningEffort,
28
+ ToolApprovalRequest,
29
+ ToolApprovalResponse,
30
+ } from "../types";
31
+ import { sanitizeMessagesForInput } from "./sanitize-messages";
32
+
33
+ // Re-export ModelMessage from AI SDK since it's commonly needed by consumers
34
+ export type { ModelMessage } from "ai";
35
+
36
+ // OpenRouter client for AI SDK (response generation)
37
+ const openrouter = createOpenRouter();
38
+
39
+ // OpenAI client for transcription (OpenRouter doesn't support transcription)
40
+ const openai = createOpenAI({});
41
+
42
+ // Maximum steps for the agent loop to prevent infinite loops
43
+ const MAX_AGENT_STEPS = 100;
44
+
45
+ function normalizeStreamError(error: unknown): Error {
46
+ if (error instanceof Error) return error;
47
+ if (error && typeof error === "object" && "message" in error) {
48
+ const message = (error as { message?: unknown }).message;
49
+ if (typeof message === "string") return new Error(message);
50
+ }
51
+ return new Error(String(error));
52
+ }
53
+
54
+ /**
55
+ * Extract the final text content from the last assistant message.
56
+ * In multi-step agent loops, we only want to speak the final response, not intermediate text.
57
+ */
58
+ function extractFinalAssistantText(messages: ModelMessage[]): string {
59
+ // Find the last assistant message
60
+ for (let i = messages.length - 1; i >= 0; i--) {
61
+ const msg = messages[i];
62
+ if (msg?.role === "assistant") {
63
+ const content = msg.content;
64
+ if (Array.isArray(content)) {
65
+ // Find the last text part. In some models/providers, intermediate
66
+ // "thoughts" might be included as separate text blocks before the final answer.
67
+ // We prioritize the last text block in the message for the final response.
68
+ for (let j = content.length - 1; j >= 0; j--) {
69
+ const part = content[j];
70
+ if (
71
+ part &&
72
+ typeof part === "object" &&
73
+ "type" in part &&
74
+ part.type === "text" &&
75
+ "text" in part &&
76
+ typeof part.text === "string"
77
+ ) {
78
+ return part.text;
79
+ }
80
+ }
81
+ // If this assistant message had no text parts, continue searching previous messages
82
+ }
83
+ }
84
+ }
85
+ return "";
86
+ }
87
+
88
+ /**
89
+ * The DAEMON agent instance.
90
+ * Handles the agent loop internally, allowing for multi-step tool usage.
91
+ * Created dynamically to use the current model selection and reasoning effort.
92
+ * @param interactionMode - "text" for terminal output, "voice" for speech-optimized
93
+ * @param reasoningEffort - Optional reasoning effort level for models that support it
94
+ */
95
+ async function createDaemonAgent(
96
+ interactionMode: InteractionMode = "text",
97
+ reasoningEffort?: ReasoningEffort
98
+ ) {
99
+ const modelConfig = buildOpenRouterChatSettings(
100
+ reasoningEffort ? { reasoning: { effort: reasoningEffort } } : undefined
101
+ );
102
+
103
+ const { sessionId } = getRuntimeContext();
104
+ const tools = await getDaemonTools();
105
+
106
+ const workspacePath = sessionId ? getWorkspacePath(sessionId) : undefined;
107
+
108
+ return new ToolLoopAgent({
109
+ model: openrouter.chat(getResponseModel(), modelConfig),
110
+ instructions: buildDaemonSystemPrompt({
111
+ mode: interactionMode,
112
+ webSearchAvailable: isWebSearchAvailable(),
113
+ workspacePath,
114
+ }),
115
+ tools,
116
+ stopWhen: stepCountIs(MAX_AGENT_STEPS),
117
+ prepareStep: async ({ messages }) => ({
118
+ messages: sanitizeMessagesForInput(messages),
119
+ }),
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Transcribe audio using GPT-4o transcribe model via AI SDK.
125
+ * @param audioBuffer - WAV audio buffer to transcribe
126
+ * @param abortSignal - Optional abort signal to cancel the request
127
+ * @returns Transcription result with text
128
+ */
129
+ export async function transcribeAudio(
130
+ audioBuffer: Buffer,
131
+ abortSignal?: AbortSignal
132
+ ): Promise<TranscriptionResult> {
133
+ try {
134
+ const result = await transcribe({
135
+ model: openai.transcription(TRANSCRIPTION_MODEL),
136
+ audio: audioBuffer,
137
+ abortSignal,
138
+ });
139
+
140
+ return {
141
+ text: result.text,
142
+ };
143
+ } catch (error) {
144
+ // Check if this was an abort
145
+ if (error instanceof Error && error.name === "AbortError") {
146
+ throw error; // Re-throw abort errors as-is
147
+ }
148
+ const err = error instanceof Error ? error : new Error(String(error));
149
+ throw new Error(`Transcription failed: ${err.message}`);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Generate a streaming response from DAEMON using the Agent class.
155
+ * The agent handles the tool loop internally.
156
+ *
157
+ * @param userMessage - The transcribed user message
158
+ * @param callbacks - Callbacks for streaming tokens, tool calls, and completion
159
+ * @param conversationHistory - Previous AI SDK messages for context
160
+ * @param interactionMode - "text" for terminal output, "voice" for speech-optimized
161
+ * @param abortSignal - Optional abort signal to cancel the request
162
+ * @param reasoningEffort - Optional reasoning effort level for models that support it
163
+ */
164
+ export async function generateResponse(
165
+ userMessage: string,
166
+ callbacks: StreamCallbacks,
167
+ conversationHistory: ModelMessage[] = [],
168
+ interactionMode: InteractionMode = "text",
169
+ abortSignal?: AbortSignal,
170
+ reasoningEffort?: ReasoningEffort
171
+ ): Promise<void> {
172
+ // Set up subagent progress emitter to forward events to callbacks
173
+ setSubagentProgressEmitter({
174
+ onSubagentToolCall: (toolCallId: string, toolName: string, input?: unknown) => {
175
+ callbacks.onSubagentToolCall?.(toolCallId, toolName, input);
176
+ },
177
+ onSubagentUsage: (usage) => {
178
+ callbacks.onSubagentUsage?.(usage);
179
+ },
180
+ onSubagentToolResult: (toolCallId: string, toolName: string, success: boolean) => {
181
+ callbacks.onSubagentToolResult?.(toolCallId, toolName, success);
182
+ },
183
+ onSubagentComplete: (toolCallId: string, success: boolean) => {
184
+ callbacks.onSubagentComplete?.(toolCallId, success);
185
+ },
186
+ });
187
+
188
+ try {
189
+ // Build messages array with history and new user message
190
+ const messages: ModelMessage[] = [
191
+ ...conversationHistory,
192
+ { role: "user" as const, content: userMessage },
193
+ ];
194
+
195
+ // Stream response from the agent with mode-specific system prompt
196
+ const agent = await createDaemonAgent(interactionMode, reasoningEffort);
197
+
198
+ let currentMessages = messages;
199
+ let fullText = "";
200
+ let streamError: Error | null = null;
201
+ let costTotal = 0;
202
+ let hasCost = false;
203
+ let allResponseMessages: ModelMessage[] = [];
204
+
205
+ const processStream = async (): Promise<void> => {
206
+ const stream = await agent.stream({
207
+ messages: currentMessages,
208
+ });
209
+
210
+ const pendingApprovals: ToolApprovalRequest[] = [];
211
+
212
+ for await (const part of stream.fullStream) {
213
+ if (abortSignal?.aborted) {
214
+ return;
215
+ }
216
+
217
+ if (part.type === "error") {
218
+ const err = normalizeStreamError(part.error);
219
+ streamError = err;
220
+ debug.error("agent-stream-error", {
221
+ message: err.message,
222
+ error: part.error,
223
+ });
224
+ callbacks.onError?.(err);
225
+ } else if (part.type === "abort") {
226
+ return;
227
+ } else if (part.type === "reasoning-delta") {
228
+ callbacks.onReasoningToken?.(part.text);
229
+ } else if (part.type === "text-delta") {
230
+ fullText += part.text;
231
+ callbacks.onToken?.(part.text);
232
+ } else if (part.type === "tool-input-start") {
233
+ callbacks.onToolCallStart?.(part.toolName, part.id);
234
+ } else if (part.type === "tool-call") {
235
+ callbacks.onToolCall?.(part.toolName, part.input, part.toolCallId);
236
+ } else if (part.type === "tool-result") {
237
+ callbacks.onToolResult?.(part.toolName, part.output, part.toolCallId);
238
+ } else if (part.type === "tool-error") {
239
+ callbacks.onToolResult?.(part.toolName, { error: part.error, input: part.input }, part.toolCallId);
240
+ } else if (part.type === "tool-approval-request") {
241
+ const approvalRequest: ToolApprovalRequest = {
242
+ approvalId: part.approvalId,
243
+ toolName: part.toolCall.toolName,
244
+ toolCallId: part.toolCall.toolCallId,
245
+ input: part.toolCall.input,
246
+ };
247
+ pendingApprovals.push(approvalRequest);
248
+ callbacks.onToolApprovalRequest?.(approvalRequest);
249
+ } else if (part.type === "finish-step") {
250
+ if (part.usage && callbacks.onStepUsage) {
251
+ const reportedCost = getOpenRouterReportedCost(part.providerMetadata);
252
+
253
+ if (reportedCost !== undefined) {
254
+ costTotal += reportedCost;
255
+ hasCost = true;
256
+ }
257
+
258
+ callbacks.onStepUsage({
259
+ promptTokens: part.usage.inputTokens ?? 0,
260
+ completionTokens: part.usage.outputTokens ?? 0,
261
+ totalTokens: part.usage.totalTokens ?? 0,
262
+ reasoningTokens: part.usage.outputTokenDetails?.reasoningTokens ?? 0,
263
+ cachedInputTokens: part.usage.inputTokenDetails?.cacheReadTokens ?? 0,
264
+ cost: reportedCost,
265
+ });
266
+ }
267
+ }
268
+ }
269
+
270
+ if (streamError) {
271
+ return;
272
+ }
273
+
274
+ const rawResponseMessages = await stream.response.then((r) => r.messages);
275
+ const responseMessages = sanitizeMessagesForInput(rawResponseMessages);
276
+ allResponseMessages = [...allResponseMessages, ...responseMessages];
277
+ currentMessages = [...currentMessages, ...responseMessages];
278
+
279
+ if (pendingApprovals.length > 0 && callbacks.onAwaitingApprovals) {
280
+ return new Promise<void>((resolve) => {
281
+ callbacks.onAwaitingApprovals!(pendingApprovals, async (responses) => {
282
+ debug.info("tool-approval-responses", { responses, pendingApprovals });
283
+ const approvalMap = new Map(pendingApprovals.map((p) => [p.approvalId, p]));
284
+
285
+ const approvedResponses: Array<{
286
+ type: "tool-approval-response";
287
+ approvalId: string;
288
+ approved: true;
289
+ }> = [];
290
+ const deniedResults: Array<{
291
+ type: "tool-result";
292
+ toolCallId: string;
293
+ toolName: string;
294
+ output: { type: "text"; value: string };
295
+ }> = [];
296
+
297
+ for (const r of responses) {
298
+ const originalRequest = approvalMap.get(r.approvalId);
299
+ if (!originalRequest) continue;
300
+
301
+ if (r.approved) {
302
+ approvedResponses.push({
303
+ type: "tool-approval-response" as const,
304
+ approvalId: r.approvalId,
305
+ approved: true,
306
+ });
307
+ } else {
308
+ // OpenRouter provider doesn't handle execution-denied type properly,
309
+ // so we send a text output that the model can understand
310
+ const denialMessage =
311
+ r.reason ?? "Tool execution was denied by the user. Do not retry this command.";
312
+ deniedResults.push({
313
+ type: "tool-result" as const,
314
+ toolCallId: originalRequest.toolCallId,
315
+ toolName: originalRequest.toolName,
316
+ output: {
317
+ type: "text" as const,
318
+ value: `[DENIED] ${denialMessage}`,
319
+ },
320
+ });
321
+ }
322
+ }
323
+
324
+ // Combine approved and denied into a single tool message so the SDK
325
+ // can execute approved tools and the model sees all results together
326
+ const combinedContent: Array<
327
+ | { type: "tool-approval-response"; approvalId: string; approved: true }
328
+ | {
329
+ type: "tool-result";
330
+ toolCallId: string;
331
+ toolName: string;
332
+ output: { type: "text"; value: string };
333
+ }
334
+ > = [...approvedResponses, ...deniedResults];
335
+
336
+ if (combinedContent.length > 0) {
337
+ debug.info("tool-approval-combined", { combinedContent });
338
+ currentMessages = [...currentMessages, { role: "tool" as const, content: combinedContent }];
339
+ }
340
+
341
+ await processStream();
342
+ resolve();
343
+ });
344
+ });
345
+ }
346
+ };
347
+
348
+ await processStream();
349
+
350
+ if (streamError) {
351
+ return;
352
+ }
353
+
354
+ const finalText = extractFinalAssistantText(allResponseMessages);
355
+
356
+ if (!fullText && allResponseMessages.length === 0) {
357
+ callbacks.onError?.(new Error("Model returned empty response. Check API key and model availability."));
358
+ return;
359
+ }
360
+
361
+ callbacks.onComplete?.(fullText, allResponseMessages, undefined, finalText);
362
+ } catch (error) {
363
+ // Check if this was an abort - don't treat as error
364
+ if (abortSignal?.aborted) {
365
+ return;
366
+ }
367
+ if (error instanceof Error && error.name === "AbortError") {
368
+ return;
369
+ }
370
+ const err = error instanceof Error ? error : new Error(String(error));
371
+ let errorMessage = err.message;
372
+ callbacks.onError?.(new Error(errorMessage));
373
+ } finally {
374
+ // Clean up the subagent progress emitter
375
+ setSubagentProgressEmitter(null);
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Generate a short descriptive title for a session based on the first user message.
381
+ * Uses the currently selected model.
382
+ * @param firstMessage - The first user message in the session
383
+ * @returns A short title (3-6 words) describing the session topic
384
+ */
385
+ export async function generateSessionTitle(firstMessage: string): Promise<string> {
386
+ try {
387
+ const result = await generateText({
388
+ model: openrouter.chat(getResponseModel(), buildOpenRouterChatSettings()),
389
+ system: `You are a title generator. Generate a very short, descriptive title (3-6 words) for a conversation based on the user's first message. The title should capture the main topic or intent. Do not use quotes, punctuation, or prefixes like "Title:". Just output the title text directly.`,
390
+ messages: [
391
+ {
392
+ role: "user",
393
+ content: `Generate a short descriptive title for the following message <message>${firstMessage}</message>`,
394
+ },
395
+ ],
396
+ });
397
+ return result.text.trim() || "New Session";
398
+ } catch (error) {
399
+ const err = error instanceof Error ? error : new Error(String(error));
400
+ debug.error("session-title-generation-failed", { message: err.message });
401
+ return "New Session";
402
+ }
403
+ }
@@ -0,0 +1,21 @@
1
+ import Exa from "exa-js";
2
+
3
+ type ExaClient = InstanceType<typeof Exa>;
4
+
5
+ let cachedClient: ExaClient | null = null;
6
+ let cachedApiKey: string | null = null;
7
+
8
+ export const getExaClient = (): { client: ExaClient } | { error: string } => {
9
+ const apiKey = process.env.EXA_API_KEY;
10
+ if (!apiKey) {
11
+ return { error: "EXA_API_KEY environment variable is not set" };
12
+ }
13
+
14
+ if (cachedClient && cachedApiKey === apiKey) {
15
+ return { client: cachedClient };
16
+ }
17
+
18
+ cachedApiKey = apiKey;
19
+ cachedClient = new Exa(apiKey);
20
+ return { client: cachedClient };
21
+ };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Per-session cache for Exa URL fetches.
3
+ * Used by fetchUrls tool to avoid redundant API calls.
4
+ */
5
+
6
+ import { getExaClient } from "./exa-client";
7
+
8
+ const MAX_CHAR_LIMIT = 50_000;
9
+ const DEFAULT_TTL_MS = 30 * 60 * 1000;
10
+
11
+ interface CachedPage {
12
+ url: string;
13
+ text: string;
14
+ fetchedAt: number;
15
+ ttlMs: number;
16
+ }
17
+
18
+ const cache = new Map<string, CachedPage>();
19
+
20
+ function normalizeUrl(url: string): string {
21
+ try {
22
+ const parsed = new URL(url);
23
+ parsed.host = parsed.host.toLowerCase();
24
+ if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) {
25
+ parsed.pathname = parsed.pathname.slice(0, -1);
26
+ }
27
+ return parsed.toString();
28
+ } catch {
29
+ return url;
30
+ }
31
+ }
32
+
33
+ function isValidCache(entry: CachedPage): boolean {
34
+ return Date.now() - entry.fetchedAt < entry.ttlMs;
35
+ }
36
+
37
+ export function getCachedPage(url: string): CachedPage | null {
38
+ const key = normalizeUrl(url);
39
+ const entry = cache.get(key);
40
+ if (!entry) return null;
41
+ if (!isValidCache(entry)) {
42
+ cache.delete(key);
43
+ return null;
44
+ }
45
+ return entry;
46
+ }
47
+
48
+ export function setCachedPage(url: string, text: string, ttlMs: number = DEFAULT_TTL_MS): void {
49
+ const key = normalizeUrl(url);
50
+ cache.set(key, {
51
+ url,
52
+ text,
53
+ fetchedAt: Date.now(),
54
+ ttlMs,
55
+ });
56
+ }
57
+
58
+ export async function fetchWithCache(
59
+ url: string
60
+ ): Promise<{ text: string; fromCache: boolean } | { error: string }> {
61
+ const cached = getCachedPage(url);
62
+ if (cached) {
63
+ return { text: cached.text, fromCache: true };
64
+ }
65
+
66
+ const exaClientResult = getExaClient();
67
+ if ("error" in exaClientResult) {
68
+ return { error: exaClientResult.error };
69
+ }
70
+
71
+ try {
72
+ const rawData = (await exaClientResult.client.getContents([url], {
73
+ text: { maxCharacters: MAX_CHAR_LIMIT },
74
+ })) as unknown as {
75
+ results?: Array<{
76
+ url?: string;
77
+ text?: string;
78
+ [key: string]: unknown;
79
+ }>;
80
+ };
81
+
82
+ const first = rawData.results?.[0];
83
+ const fullText = first?.text ?? "";
84
+ const cappedText = fullText.slice(0, MAX_CHAR_LIMIT);
85
+
86
+ setCachedPage(url, cappedText);
87
+
88
+ return { text: cappedText, fromCache: false };
89
+ } catch (error) {
90
+ const err = error instanceof Error ? error : new Error(String(error));
91
+ return { error: err.message };
92
+ }
93
+ }
94
+
95
+ export function clearFetchCache(): void {
96
+ cache.clear();
97
+ }
98
+
99
+ export function getCacheStats(): { size: number; urls: string[] } {
100
+ return {
101
+ size: cache.size,
102
+ urls: Array.from(cache.keys()),
103
+ };
104
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Centralized model configuration for DAEMON.
3
+ */
4
+
5
+ import type { OpenRouterChatSettings } from "@openrouter/ai-sdk-provider";
6
+ import type { ModelOption } from "../types";
7
+
8
+ // Available models for selection (OpenRouter format)
9
+ export const AVAILABLE_MODELS: ModelOption[] = [
10
+ { id: "x-ai/grok-4.1-fast", name: "Grok 4.1 Fast" },
11
+ { id: "z-ai/glm-4.7", name: "GLM 4.7" },
12
+ { id: "minimax/minimax-m2.1", name: "Minimax M2.1" },
13
+ { id: "google/gemini-3-flash-preview", name: "Gemini 3 Flash" },
14
+ { id: "google/gemini-3-pro-preview", name: "Gemini 3 Pro" },
15
+ { id: "openai/gpt-5.2", name: "GPT 5.2" },
16
+ { id: "moonshotai/kimi-k2-thinking", name: "Kimi K2 Thinking" },
17
+ { id: "openai/gpt-oss-120b:exacto", name: "GPT-OSS-120" },
18
+ { id: "mistralai/devstral-2512:free", name: "Mistral Devstral" },
19
+ { id: "nvidia/nemotron-3-nano-30b-a3b:free", name: "Nemotron 3 Nano" },
20
+ ];
21
+
22
+ // Default model ID
23
+ export const DEFAULT_MODEL_ID = "openai/gpt-oss-120b:exacto";
24
+
25
+ // Current selected model (mutable)
26
+ let currentModelId = DEFAULT_MODEL_ID;
27
+ let currentOpenRouterProviderTag: string | undefined;
28
+
29
+ /**
30
+ * Get the current response model ID.
31
+ */
32
+ export function getResponseModel(): string {
33
+ return currentModelId;
34
+ }
35
+
36
+ /**
37
+ * Get the current OpenRouter inference provider tag (slug) for routing.
38
+ * When undefined, OpenRouter will choose automatically.
39
+ */
40
+ export function getOpenRouterProviderTag(): string | undefined {
41
+ return currentOpenRouterProviderTag;
42
+ }
43
+
44
+ /**
45
+ * Set the OpenRouter inference provider tag (slug) for routing.
46
+ * Use `undefined` to revert to automatic provider selection.
47
+ */
48
+ export function setOpenRouterProviderTag(providerTag: string | undefined): void {
49
+ const normalized =
50
+ typeof providerTag === "string" && providerTag.trim().length > 0 ? providerTag.trim() : undefined;
51
+ currentOpenRouterProviderTag = normalized;
52
+ }
53
+
54
+ /**
55
+ * Set the current response model ID.
56
+ */
57
+ export function setResponseModel(modelId: string): void {
58
+ if (!modelId) return;
59
+ if (modelId !== currentModelId) {
60
+ currentModelId = modelId;
61
+ // Always reset provider when switching to a DIFFERENT model
62
+ currentOpenRouterProviderTag = undefined;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Get the current subagent model ID (same as main agent).
68
+ */
69
+ export function getSubagentModel(): string {
70
+ return currentModelId;
71
+ }
72
+
73
+ /**
74
+ * Build OpenRouter chat settings that apply globally (e.g. provider routing),
75
+ * optionally merged with call-specific settings (e.g. reasoning effort).
76
+ */
77
+ export function buildOpenRouterChatSettings(
78
+ overrides?: OpenRouterChatSettings
79
+ ): OpenRouterChatSettings | undefined {
80
+ const settings: OpenRouterChatSettings = {
81
+ usage: {
82
+ include: true,
83
+ },
84
+ ...(currentOpenRouterProviderTag
85
+ ? {
86
+ provider: {
87
+ order: [currentOpenRouterProviderTag],
88
+ allow_fallbacks: false,
89
+ },
90
+ }
91
+ : {}),
92
+ ...(overrides ?? {}),
93
+ };
94
+
95
+ return Object.keys(settings).length > 0 ? settings : undefined;
96
+ }
97
+
98
+ // Transcription model (OpenAI)
99
+ export const TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe-2025-12-15";