@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,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";
|