iris-chatbot 0.2.4
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 +49 -0
- package/bin/iris.mjs +267 -0
- package/package.json +61 -0
- package/template/LICENSE +21 -0
- package/template/README.md +49 -0
- package/template/eslint.config.mjs +18 -0
- package/template/next.config.ts +7 -0
- package/template/package-lock.json +9193 -0
- package/template/package.json +46 -0
- package/template/postcss.config.mjs +7 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/next.svg +1 -0
- package/template/public/vercel.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/src/app/api/chat/route.ts +2445 -0
- package/template/src/app/api/connections/models/route.ts +255 -0
- package/template/src/app/api/connections/test/route.ts +124 -0
- package/template/src/app/api/local-sync/route.ts +74 -0
- package/template/src/app/api/tool-approval/route.ts +47 -0
- package/template/src/app/favicon.ico +0 -0
- package/template/src/app/globals.css +808 -0
- package/template/src/app/layout.tsx +74 -0
- package/template/src/app/page.tsx +444 -0
- package/template/src/components/ChatView.tsx +1537 -0
- package/template/src/components/Composer.tsx +160 -0
- package/template/src/components/MapView.tsx +244 -0
- package/template/src/components/MessageCard.tsx +955 -0
- package/template/src/components/SearchModal.tsx +72 -0
- package/template/src/components/SettingsModal.tsx +1257 -0
- package/template/src/components/Sidebar.tsx +153 -0
- package/template/src/components/TopBar.tsx +164 -0
- package/template/src/lib/connections.ts +275 -0
- package/template/src/lib/data.ts +324 -0
- package/template/src/lib/db.ts +49 -0
- package/template/src/lib/hooks.ts +76 -0
- package/template/src/lib/local-sync.ts +192 -0
- package/template/src/lib/memory.ts +695 -0
- package/template/src/lib/model-presets.ts +251 -0
- package/template/src/lib/store.ts +36 -0
- package/template/src/lib/tooling/approvals.ts +78 -0
- package/template/src/lib/tooling/providers/anthropic.ts +155 -0
- package/template/src/lib/tooling/providers/ollama.ts +73 -0
- package/template/src/lib/tooling/providers/openai.ts +267 -0
- package/template/src/lib/tooling/providers/openai_compatible.ts +16 -0
- package/template/src/lib/tooling/providers/types.ts +44 -0
- package/template/src/lib/tooling/registry.ts +103 -0
- package/template/src/lib/tooling/runtime.ts +189 -0
- package/template/src/lib/tooling/safety.ts +165 -0
- package/template/src/lib/tooling/tools/apps.ts +108 -0
- package/template/src/lib/tooling/tools/apps_plus.ts +153 -0
- package/template/src/lib/tooling/tools/communication.ts +883 -0
- package/template/src/lib/tooling/tools/files.ts +395 -0
- package/template/src/lib/tooling/tools/music.ts +988 -0
- package/template/src/lib/tooling/tools/notes.ts +461 -0
- package/template/src/lib/tooling/tools/notes_plus.ts +294 -0
- package/template/src/lib/tooling/tools/numbers.ts +175 -0
- package/template/src/lib/tooling/tools/schedule.ts +579 -0
- package/template/src/lib/tooling/tools/system.ts +142 -0
- package/template/src/lib/tooling/tools/web.ts +212 -0
- package/template/src/lib/tooling/tools/workflow.ts +218 -0
- package/template/src/lib/tooling/types.ts +27 -0
- package/template/src/lib/types.ts +309 -0
- package/template/src/lib/utils.ts +108 -0
- package/template/tsconfig.json +34 -0
|
@@ -0,0 +1,2445 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
3
|
+
import type {
|
|
4
|
+
ChatConnectionPayload,
|
|
5
|
+
ChatMessageInput,
|
|
6
|
+
MemoryContextPayload,
|
|
7
|
+
MemoryMusicAlias,
|
|
8
|
+
ChatRequest,
|
|
9
|
+
ChatStreamChunk,
|
|
10
|
+
LocalToolsSettings,
|
|
11
|
+
} from "../../../lib/types";
|
|
12
|
+
import { DEFAULT_LOCAL_TOOLS_SETTINGS } from "../../../lib/types";
|
|
13
|
+
import { GEMINI_OPENAI_BASE_URL, supportsToolsByDefault } from "../../../lib/connections";
|
|
14
|
+
import { createApprovalRequest } from "../../../lib/tooling/approvals";
|
|
15
|
+
import {
|
|
16
|
+
createToolRegistry,
|
|
17
|
+
findToolByName,
|
|
18
|
+
serializeToolOutput,
|
|
19
|
+
toProviderToolSchemas,
|
|
20
|
+
} from "../../../lib/tooling/registry";
|
|
21
|
+
import { createAnthropicSession } from "../../../lib/tooling/providers/anthropic";
|
|
22
|
+
import { streamOllamaChat } from "../../../lib/tooling/providers/ollama";
|
|
23
|
+
import {
|
|
24
|
+
createOpenAISession,
|
|
25
|
+
extractCitationSourcesFromResponse,
|
|
26
|
+
} from "../../../lib/tooling/providers/openai";
|
|
27
|
+
import { createOpenAICompatibleSession } from "../../../lib/tooling/providers/openai_compatible";
|
|
28
|
+
import type {
|
|
29
|
+
ProviderCitationSource,
|
|
30
|
+
ToolLoopMessage,
|
|
31
|
+
ToolProviderSession,
|
|
32
|
+
} from "../../../lib/tooling/providers/types";
|
|
33
|
+
import { requiresApprovalForRisk } from "../../../lib/tooling/safety";
|
|
34
|
+
|
|
35
|
+
export const runtime = "nodejs";
|
|
36
|
+
export const dynamic = "force-dynamic";
|
|
37
|
+
|
|
38
|
+
const MAX_TOOL_STEPS = 48;
|
|
39
|
+
const STREAM_CHUNK_SIZE = 8;
|
|
40
|
+
const LOCAL_TOOL_SYSTEM_INSTRUCTIONS = [
|
|
41
|
+
"You can access the user's local computer through provided tools.",
|
|
42
|
+
"When the user asks for file, app, notes, music, web, calendar, or system actions, call tools directly.",
|
|
43
|
+
"Default to execution instead of clarification. Ask at most one question only when a truly required value is missing.",
|
|
44
|
+
"For iMessage/text requests, use messages_send; if contact matching is ambiguous, ask one concise clarification with candidate names.",
|
|
45
|
+
"For media requests, assume Apple Music unless the user specifies otherwise.",
|
|
46
|
+
"For note creation, write structured markdown with clear headings, bolded key labels, and tables when they improve clarity.",
|
|
47
|
+
"If the user requests multiple actions in one prompt, execute every requested action sequentially before your final response.",
|
|
48
|
+
"Do not stop after completing just one action when additional requested actions remain.",
|
|
49
|
+
"For multi-step requests, prefer a single workflow tool call when available.",
|
|
50
|
+
"When tools are enabled, never say you cannot access the user's device.",
|
|
51
|
+
"After tool results arrive, provide a brief final answer and stop.",
|
|
52
|
+
"Summarize tool activity in plain English and avoid raw JSON, stack traces, or script line numbers.",
|
|
53
|
+
"Respect tool errors and provide clear next steps.",
|
|
54
|
+
].join(" ");
|
|
55
|
+
|
|
56
|
+
const ACTION_VERB_PATTERN =
|
|
57
|
+
"create|make|write|play|set|add|remind|open|send|launch|start|pause|resume|focus";
|
|
58
|
+
const SEQUENCE_MARKER_PATTERN = "first|second|third|fourth|fifth|next|then|finally|lastly";
|
|
59
|
+
const TOOL_INTENT_EXPLICIT_PATTERN =
|
|
60
|
+
/\b(use|run|call)\s+(?:the\s+)?(?:local\s+)?tools?\b|\b(on my (?:mac|computer|device)|locally)\b/i;
|
|
61
|
+
const TOOL_INTENT_LEADING_VERB_PATTERN =
|
|
62
|
+
/^(?:please\s+)?(?:(?:can|could|would)\s+you\s+)?(?:send|text|message|play|pause|resume|skip|next|previous|set|open|launch|focus|create|make|add|remind|search|find|list|move|copy|rename|delete|trash|append|write|read|start|stop)\b/i;
|
|
63
|
+
const TOOL_INTENT_MESSAGING_PATTERN =
|
|
64
|
+
/\b(text|message|imessage|sms|mail|email)\b[\s\S]{0,80}\b(send|text|message|email|mail)\b|\b(send|text|message|email|mail)\b[\s\S]{0,80}\b(text|message|imessage|sms|mail|email)\b/i;
|
|
65
|
+
const TOOL_INTENT_NOTES_PATTERN =
|
|
66
|
+
/\b(note|notes)\b[\s\S]{0,80}\b(create|make|write|append|add|find|search|open|update|jot)\b|\b(create|make|write|append|add|find|search|open|update|jot)\b[\s\S]{0,80}\b(note|notes)\b/i;
|
|
67
|
+
const TOOL_INTENT_MUSIC_PATTERN =
|
|
68
|
+
/\b(music|song|track|album|playlist|apple music)\b[\s\S]{0,80}\b(play|pause|resume|skip|next|previous|volume|set|stop)\b|\b(play|pause|resume|skip|next|previous|volume|set|stop)\b[\s\S]{0,80}\b(music|song|track|album|playlist|apple music)\b/i;
|
|
69
|
+
const TOOL_INTENT_SCHEDULE_PATTERN =
|
|
70
|
+
/\b(calendar|event|reminder|schedule)\b[\s\S]{0,80}\b(create|add|list|show|set|remind|schedule)\b|\b(create|add|list|show|set|remind|schedule)\b[\s\S]{0,80}\b(calendar|event|reminder|schedule)\b/i;
|
|
71
|
+
const TOOL_INTENT_FILES_PATTERN =
|
|
72
|
+
/\b(file|folder|directory|path|document)\b[\s\S]{0,80}\b(list|find|search|move|copy|rename|delete|trash|create|make|mkdir|open)\b|\b(list|find|search|move|copy|rename|delete|trash|create|make|mkdir|open)\b[\s\S]{0,80}\b(file|folder|directory|path|document)\b/i;
|
|
73
|
+
const TOOL_INTENT_APPS_PATTERN =
|
|
74
|
+
/\b(app|application|window|url|website|browser|system)\b[\s\S]{0,80}\b(open|launch|focus|quit|set|mute|unmute|browse)\b|\b(open|launch|focus|quit|set|mute|unmute|browse)\b[\s\S]{0,80}\b(app|application|window|url|website|browser|system)\b/i;
|
|
75
|
+
const TOOL_INTENT_NUMBERS_PATTERN =
|
|
76
|
+
/\b(numbers|spreadsheet|cell)\b[\s\S]{0,80}\b(read|set|update|write)\b|\b(read|set|update|write)\b[\s\S]{0,80}\b(numbers|spreadsheet|cell)\b/i;
|
|
77
|
+
const TOOL_INTENT_WORKFLOW_PATTERN = /\b(workflow|automation)\b[\s\S]{0,80}\b(run|start|execute|create)\b/i;
|
|
78
|
+
|
|
79
|
+
function splitCompoundActionClauses(input: string): string[] {
|
|
80
|
+
const normalized = input.replace(/\s+/g, " ").trim();
|
|
81
|
+
if (!normalized) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const roughParts = normalized.split(
|
|
86
|
+
new RegExp(
|
|
87
|
+
`\\s*(?:,|;|\\n)+\\s*(?=(?:(?:${SEQUENCE_MARKER_PATTERN})\\b|${ACTION_VERB_PATTERN}\\b))|` +
|
|
88
|
+
`\\s+(?:and then|and also|as well as|after that|plus|then|also|next|finally|lastly)\\s+|` +
|
|
89
|
+
`\\s+and\\s+(?=(?:${ACTION_VERB_PATTERN})\\b)`,
|
|
90
|
+
"i",
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return roughParts
|
|
95
|
+
.map((part) =>
|
|
96
|
+
part
|
|
97
|
+
.trim()
|
|
98
|
+
.replace(new RegExp(`^(?:${SEQUENCE_MARKER_PATTERN})\\b[\\s,:-]*`, "i"), "")
|
|
99
|
+
.replace(/^(?:can you|could you|would you|please|hey|hi|yo)\s+/i, "")
|
|
100
|
+
.trim(),
|
|
101
|
+
)
|
|
102
|
+
.filter(Boolean);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildDetectedActionHint(messages: ChatMessageInput[]): string | null {
|
|
106
|
+
const lastUser = [...messages].reverse().find((message) => message.role === "user");
|
|
107
|
+
if (!lastUser?.content) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const clauses = splitCompoundActionClauses(lastUser.content);
|
|
111
|
+
if (clauses.length < 2) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const lines = clauses.map((clause, index) => `${index + 1}. ${clause}`);
|
|
115
|
+
return `Detected user actions:\n${lines.join("\n")}\nExecute each action in order and confirm each result.`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildToolSystemPrompt(system: string | undefined, messages: ChatMessageInput[]): string {
|
|
119
|
+
const base = system?.trim();
|
|
120
|
+
const actionHint = buildDetectedActionHint(messages);
|
|
121
|
+
if (!base) {
|
|
122
|
+
return actionHint
|
|
123
|
+
? `${LOCAL_TOOL_SYSTEM_INSTRUCTIONS}\n\n${actionHint}`
|
|
124
|
+
: LOCAL_TOOL_SYSTEM_INSTRUCTIONS;
|
|
125
|
+
}
|
|
126
|
+
return actionHint
|
|
127
|
+
? `${base}\n\n${LOCAL_TOOL_SYSTEM_INSTRUCTIONS}\n\n${actionHint}`
|
|
128
|
+
: `${base}\n\n${LOCAL_TOOL_SYSTEM_INSTRUCTIONS}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function shouldRouteThroughToolOrchestrator(messages: ChatMessageInput[]): boolean {
|
|
132
|
+
const hasToolIntent = (input: string): boolean => {
|
|
133
|
+
const text = input.replace(/\s+/g, " ").trim();
|
|
134
|
+
if (!text) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (TOOL_INTENT_EXPLICIT_PATTERN.test(text)) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (TOOL_INTENT_LEADING_VERB_PATTERN.test(text)) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
TOOL_INTENT_MESSAGING_PATTERN.test(text) ||
|
|
148
|
+
TOOL_INTENT_NOTES_PATTERN.test(text) ||
|
|
149
|
+
TOOL_INTENT_MUSIC_PATTERN.test(text) ||
|
|
150
|
+
TOOL_INTENT_SCHEDULE_PATTERN.test(text) ||
|
|
151
|
+
TOOL_INTENT_FILES_PATTERN.test(text) ||
|
|
152
|
+
TOOL_INTENT_APPS_PATTERN.test(text) ||
|
|
153
|
+
TOOL_INTENT_NUMBERS_PATTERN.test(text) ||
|
|
154
|
+
TOOL_INTENT_WORKFLOW_PATTERN.test(text)
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const lastUserIndex = (() => {
|
|
159
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
160
|
+
if (messages[index]?.role === "user") {
|
|
161
|
+
return index;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return -1;
|
|
165
|
+
})();
|
|
166
|
+
if (lastUserIndex < 0) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const lastUserText = messages[lastUserIndex]?.content ?? "";
|
|
171
|
+
if (hasToolIntent(lastUserText)) {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const clauses = splitCompoundActionClauses(lastUserText);
|
|
176
|
+
if (clauses.length >= 2 && clauses.some((clause) => hasToolIntent(clause))) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const compactLastUser = lastUserText.replace(/\s+/g, " ").trim();
|
|
181
|
+
const isLikelyClarification = compactLastUser.length > 0 && compactLastUser.length <= 48;
|
|
182
|
+
if (!isLikelyClarification) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let priorToolIntentCount = 0;
|
|
187
|
+
for (let index = lastUserIndex - 1; index >= 0 && priorToolIntentCount < 3; index -= 1) {
|
|
188
|
+
if (messages[index]?.role !== "user") {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (hasToolIntent(messages[index]?.content ?? "")) {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
priorToolIntentCount += 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
type RuntimeConnection = {
|
|
201
|
+
id: string;
|
|
202
|
+
name: string;
|
|
203
|
+
kind: "builtin" | "openai_compatible" | "ollama";
|
|
204
|
+
provider?: "openai" | "anthropic" | "google";
|
|
205
|
+
baseUrl?: string;
|
|
206
|
+
apiKey?: string;
|
|
207
|
+
headers?: Array<{ key: string; value: string }>;
|
|
208
|
+
supportsTools: boolean;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
function headersArrayToRecord(headers: RuntimeConnection["headers"]): Record<string, string> | undefined {
|
|
212
|
+
if (!Array.isArray(headers) || headers.length === 0) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
const out: Record<string, string> = {};
|
|
216
|
+
for (const header of headers) {
|
|
217
|
+
if (!header || typeof header.key !== "string") {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const key = header.key.trim();
|
|
221
|
+
if (!key) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
out[key] = typeof header.value === "string" ? header.value : "";
|
|
225
|
+
}
|
|
226
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function normalizeRuntimeConnection(connection: ChatConnectionPayload): RuntimeConnection {
|
|
230
|
+
const kind =
|
|
231
|
+
connection.kind === "builtin" || connection.kind === "openai_compatible" || connection.kind === "ollama"
|
|
232
|
+
? connection.kind
|
|
233
|
+
: "openai_compatible";
|
|
234
|
+
const provider =
|
|
235
|
+
kind === "builtin" &&
|
|
236
|
+
(connection.provider === "openai" ||
|
|
237
|
+
connection.provider === "anthropic" ||
|
|
238
|
+
connection.provider === "google")
|
|
239
|
+
? connection.provider
|
|
240
|
+
: undefined;
|
|
241
|
+
const baseUrl =
|
|
242
|
+
typeof connection.baseUrl === "string" && connection.baseUrl.trim()
|
|
243
|
+
? connection.baseUrl.trim()
|
|
244
|
+
: kind === "builtin" && provider === "google"
|
|
245
|
+
? GEMINI_OPENAI_BASE_URL
|
|
246
|
+
: kind === "ollama"
|
|
247
|
+
? "http://localhost:11434"
|
|
248
|
+
: undefined;
|
|
249
|
+
const apiKey = typeof connection.apiKey === "string" && connection.apiKey.trim()
|
|
250
|
+
? connection.apiKey.trim()
|
|
251
|
+
: undefined;
|
|
252
|
+
const supportsTools =
|
|
253
|
+
typeof connection.supportsTools === "boolean"
|
|
254
|
+
? connection.supportsTools
|
|
255
|
+
: supportsToolsByDefault({ kind, provider });
|
|
256
|
+
return {
|
|
257
|
+
id: typeof connection.id === "string" && connection.id.trim() ? connection.id.trim() : "ad-hoc",
|
|
258
|
+
name:
|
|
259
|
+
typeof connection.name === "string" && connection.name.trim() ? connection.name.trim() : "Connection",
|
|
260
|
+
kind,
|
|
261
|
+
provider,
|
|
262
|
+
baseUrl,
|
|
263
|
+
apiKey,
|
|
264
|
+
headers: Array.isArray(connection.headers)
|
|
265
|
+
? connection.headers.filter(
|
|
266
|
+
(header): header is { key: string; value: string } =>
|
|
267
|
+
Boolean(header) && typeof header.key === "string" && typeof header.value === "string",
|
|
268
|
+
)
|
|
269
|
+
: [],
|
|
270
|
+
supportsTools,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function resolveRuntimeConnection(body: ChatRequest): RuntimeConnection | null {
|
|
275
|
+
if (body.connection) {
|
|
276
|
+
return normalizeRuntimeConnection(body.connection);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (body.provider) {
|
|
280
|
+
return normalizeRuntimeConnection({
|
|
281
|
+
id: `legacy-${body.provider}`,
|
|
282
|
+
name: body.provider,
|
|
283
|
+
kind: "builtin",
|
|
284
|
+
provider: body.provider,
|
|
285
|
+
apiKey: body.apiKey,
|
|
286
|
+
supportsTools: true,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function requiresApiKey(connection: RuntimeConnection): boolean {
|
|
294
|
+
if (connection.kind === "ollama") {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
if (connection.kind === "openai_compatible") {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
return connection.provider === "openai" || connection.provider === "anthropic" || connection.provider === "google";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function normalizeLocalTools(settings: LocalToolsSettings | undefined): LocalToolsSettings {
|
|
304
|
+
if (!settings) {
|
|
305
|
+
return { ...DEFAULT_LOCAL_TOOLS_SETTINGS };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const normalized: LocalToolsSettings = {
|
|
309
|
+
enabled: Boolean(settings.enabled),
|
|
310
|
+
approvalMode: settings.approvalMode ?? DEFAULT_LOCAL_TOOLS_SETTINGS.approvalMode,
|
|
311
|
+
safetyProfile: settings.safetyProfile ?? DEFAULT_LOCAL_TOOLS_SETTINGS.safetyProfile,
|
|
312
|
+
allowedRoots:
|
|
313
|
+
Array.isArray(settings.allowedRoots) && settings.allowedRoots.length > 0
|
|
314
|
+
? settings.allowedRoots
|
|
315
|
+
: [...DEFAULT_LOCAL_TOOLS_SETTINGS.allowedRoots],
|
|
316
|
+
enableNotes:
|
|
317
|
+
typeof settings.enableNotes === "boolean"
|
|
318
|
+
? settings.enableNotes
|
|
319
|
+
: DEFAULT_LOCAL_TOOLS_SETTINGS.enableNotes,
|
|
320
|
+
enableApps:
|
|
321
|
+
typeof settings.enableApps === "boolean"
|
|
322
|
+
? settings.enableApps
|
|
323
|
+
: DEFAULT_LOCAL_TOOLS_SETTINGS.enableApps,
|
|
324
|
+
enableNumbers:
|
|
325
|
+
typeof settings.enableNumbers === "boolean"
|
|
326
|
+
? settings.enableNumbers
|
|
327
|
+
: DEFAULT_LOCAL_TOOLS_SETTINGS.enableNumbers,
|
|
328
|
+
enableWeb:
|
|
329
|
+
typeof settings.enableWeb === "boolean"
|
|
330
|
+
? settings.enableWeb
|
|
331
|
+
: DEFAULT_LOCAL_TOOLS_SETTINGS.enableWeb,
|
|
332
|
+
enableMusic:
|
|
333
|
+
typeof settings.enableMusic === "boolean"
|
|
334
|
+
? settings.enableMusic
|
|
335
|
+
: DEFAULT_LOCAL_TOOLS_SETTINGS.enableMusic,
|
|
336
|
+
enableCalendar:
|
|
337
|
+
typeof settings.enableCalendar === "boolean"
|
|
338
|
+
? settings.enableCalendar
|
|
339
|
+
: DEFAULT_LOCAL_TOOLS_SETTINGS.enableCalendar,
|
|
340
|
+
enableMail:
|
|
341
|
+
typeof settings.enableMail === "boolean"
|
|
342
|
+
? settings.enableMail
|
|
343
|
+
: DEFAULT_LOCAL_TOOLS_SETTINGS.enableMail,
|
|
344
|
+
enableWorkflow:
|
|
345
|
+
typeof settings.enableWorkflow === "boolean"
|
|
346
|
+
? settings.enableWorkflow
|
|
347
|
+
: DEFAULT_LOCAL_TOOLS_SETTINGS.enableWorkflow,
|
|
348
|
+
enableSystem:
|
|
349
|
+
typeof settings.enableSystem === "boolean"
|
|
350
|
+
? settings.enableSystem
|
|
351
|
+
: DEFAULT_LOCAL_TOOLS_SETTINGS.enableSystem,
|
|
352
|
+
webSearchBackend:
|
|
353
|
+
settings.webSearchBackend ?? DEFAULT_LOCAL_TOOLS_SETTINGS.webSearchBackend,
|
|
354
|
+
dryRun:
|
|
355
|
+
typeof settings.dryRun === "boolean"
|
|
356
|
+
? settings.dryRun
|
|
357
|
+
: DEFAULT_LOCAL_TOOLS_SETTINGS.dryRun,
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const looksLikeLegacyDefaults =
|
|
361
|
+
normalized.enabled === true &&
|
|
362
|
+
normalized.approvalMode === "always_confirm_writes" &&
|
|
363
|
+
normalized.safetyProfile === "balanced" &&
|
|
364
|
+
normalized.allowedRoots.length === DEFAULT_LOCAL_TOOLS_SETTINGS.allowedRoots.length &&
|
|
365
|
+
normalized.allowedRoots.every(
|
|
366
|
+
(value, index) => value === DEFAULT_LOCAL_TOOLS_SETTINGS.allowedRoots[index],
|
|
367
|
+
) &&
|
|
368
|
+
normalized.enableNotes === true &&
|
|
369
|
+
normalized.enableApps === true &&
|
|
370
|
+
normalized.enableNumbers === false &&
|
|
371
|
+
normalized.enableWeb === false &&
|
|
372
|
+
normalized.enableMusic === true &&
|
|
373
|
+
normalized.enableCalendar === false &&
|
|
374
|
+
normalized.enableMail === false &&
|
|
375
|
+
normalized.enableWorkflow === true &&
|
|
376
|
+
normalized.enableSystem === true &&
|
|
377
|
+
normalized.webSearchBackend === "no_key" &&
|
|
378
|
+
normalized.dryRun === false;
|
|
379
|
+
|
|
380
|
+
if (looksLikeLegacyDefaults) {
|
|
381
|
+
normalized.approvalMode = "trusted_auto";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return normalized;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function createToolMessages(messages: ChatMessageInput[], system?: string): ToolLoopMessage[] {
|
|
388
|
+
const turns: ToolLoopMessage[] = [];
|
|
389
|
+
if (system?.trim()) {
|
|
390
|
+
turns.push({ role: "system", content: system.trim() });
|
|
391
|
+
}
|
|
392
|
+
for (const message of messages) {
|
|
393
|
+
turns.push({ role: message.role, content: message.content });
|
|
394
|
+
}
|
|
395
|
+
return turns;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function sanitizeChatMessages(messages: ChatMessageInput[]): ChatMessageInput[] {
|
|
399
|
+
const sanitized: ChatMessageInput[] = [];
|
|
400
|
+
for (const message of messages) {
|
|
401
|
+
if (!message) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const role = message.role;
|
|
405
|
+
if (role !== "user" && role !== "assistant" && role !== "system") {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (typeof message.content !== "string") {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const normalizedContent = message.content.replace(/\r\n/g, "\n");
|
|
412
|
+
if (!normalizedContent.trim()) {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
sanitized.push({
|
|
416
|
+
role,
|
|
417
|
+
content: normalizedContent,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return sanitized;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function sanitizeMemoryContext(
|
|
424
|
+
memory: ChatRequest["memory"],
|
|
425
|
+
): MemoryContextPayload | null {
|
|
426
|
+
if (!memory || typeof memory !== "object" || memory.enabled !== true) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const aliases =
|
|
431
|
+
memory.aliases && typeof memory.aliases === "object"
|
|
432
|
+
? memory.aliases
|
|
433
|
+
: { people: [], music: [] };
|
|
434
|
+
|
|
435
|
+
const people = Array.isArray(aliases.people)
|
|
436
|
+
? aliases.people
|
|
437
|
+
.map((item) => {
|
|
438
|
+
const alias = typeof item?.alias === "string" ? item.alias.trim() : "";
|
|
439
|
+
const target = typeof item?.target === "string" ? item.target.trim() : "";
|
|
440
|
+
if (!alias || !target) {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
return { alias, target };
|
|
444
|
+
})
|
|
445
|
+
.filter((item): item is { alias: string; target: string } => Boolean(item))
|
|
446
|
+
.slice(0, 16)
|
|
447
|
+
: [];
|
|
448
|
+
|
|
449
|
+
const music = Array.isArray(aliases.music)
|
|
450
|
+
? aliases.music
|
|
451
|
+
.map((item) => {
|
|
452
|
+
const alias = typeof item?.alias === "string" ? item.alias.trim() : "";
|
|
453
|
+
const query = typeof item?.query === "string" ? item.query.trim() : "";
|
|
454
|
+
if (!alias || !query) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
const title =
|
|
458
|
+
typeof item?.title === "string" && item.title.trim()
|
|
459
|
+
? item.title.trim()
|
|
460
|
+
: undefined;
|
|
461
|
+
const artist =
|
|
462
|
+
typeof item?.artist === "string" && item.artist.trim()
|
|
463
|
+
? item.artist.trim()
|
|
464
|
+
: undefined;
|
|
465
|
+
return { alias, query, title, artist };
|
|
466
|
+
})
|
|
467
|
+
.filter(
|
|
468
|
+
(
|
|
469
|
+
item,
|
|
470
|
+
): item is {
|
|
471
|
+
alias: string;
|
|
472
|
+
query: string;
|
|
473
|
+
title: string | undefined;
|
|
474
|
+
artist: string | undefined;
|
|
475
|
+
} => Boolean(item),
|
|
476
|
+
)
|
|
477
|
+
.slice(0, 16)
|
|
478
|
+
: [];
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
enabled: true,
|
|
482
|
+
aliases: {
|
|
483
|
+
people,
|
|
484
|
+
music,
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function chunkText(text: string, chunkSize = STREAM_CHUNK_SIZE): string[] {
|
|
490
|
+
if (!text) {
|
|
491
|
+
return [];
|
|
492
|
+
}
|
|
493
|
+
const chunks: string[] = [];
|
|
494
|
+
let buffer = "";
|
|
495
|
+
for (const char of text) {
|
|
496
|
+
buffer += char;
|
|
497
|
+
const atBoundary = char === "\n" || /[\s,.;:!?)]/.test(char);
|
|
498
|
+
if (buffer.length >= chunkSize && atBoundary) {
|
|
499
|
+
chunks.push(buffer);
|
|
500
|
+
buffer = "";
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (buffer) {
|
|
504
|
+
chunks.push(buffer);
|
|
505
|
+
}
|
|
506
|
+
return chunks;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function emitTokenText(
|
|
510
|
+
send: (chunk: ChatStreamChunk) => void,
|
|
511
|
+
text: string,
|
|
512
|
+
onChunk?: (chunkTextValue: string) => void,
|
|
513
|
+
) {
|
|
514
|
+
for (const token of chunkText(text)) {
|
|
515
|
+
send({ type: "token", value: token });
|
|
516
|
+
onChunk?.(token);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function emitThinking(
|
|
521
|
+
send: (chunk: ChatStreamChunk) => void,
|
|
522
|
+
status: "started" | "update" | "done",
|
|
523
|
+
summary?: string,
|
|
524
|
+
) {
|
|
525
|
+
send({
|
|
526
|
+
type: "thinking",
|
|
527
|
+
status,
|
|
528
|
+
summary,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function normalizeWebSourcesEnabled(value: boolean | undefined): boolean {
|
|
533
|
+
return value !== false;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function supportsWebSourcesViaOpenAI(connection: RuntimeConnection): boolean {
|
|
537
|
+
return connection.kind === "builtin" && connection.provider === "openai";
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function mergeCitationSources(
|
|
541
|
+
target: Map<string, ProviderCitationSource>,
|
|
542
|
+
sources: ProviderCitationSource[] | undefined,
|
|
543
|
+
) {
|
|
544
|
+
if (!sources?.length) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
for (const source of sources) {
|
|
548
|
+
if (!source?.url) {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const existing = target.get(source.url);
|
|
552
|
+
if (!existing || (!existing.title && source.title)) {
|
|
553
|
+
target.set(source.url, source);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function sourceFromAnnotation(annotation: unknown): ProviderCitationSource | null {
|
|
559
|
+
if (!annotation || typeof annotation !== "object") {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
const record = annotation as {
|
|
563
|
+
type?: unknown;
|
|
564
|
+
url?: unknown;
|
|
565
|
+
title?: unknown;
|
|
566
|
+
url_citation?: unknown;
|
|
567
|
+
};
|
|
568
|
+
if (record.type !== "url_citation") {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (typeof record.url === "string" && record.url.trim()) {
|
|
573
|
+
return {
|
|
574
|
+
url: record.url.trim(),
|
|
575
|
+
title:
|
|
576
|
+
typeof record.title === "string" && record.title.trim()
|
|
577
|
+
? record.title.trim()
|
|
578
|
+
: undefined,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (record.url_citation && typeof record.url_citation === "object") {
|
|
583
|
+
const nested = record.url_citation as { url?: unknown; title?: unknown };
|
|
584
|
+
if (typeof nested.url === "string" && nested.url.trim()) {
|
|
585
|
+
return {
|
|
586
|
+
url: nested.url.trim(),
|
|
587
|
+
title:
|
|
588
|
+
typeof nested.title === "string" && nested.title.trim()
|
|
589
|
+
? nested.title.trim()
|
|
590
|
+
: undefined,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function extractSourcesFromToolOutput(toolName: string, output: unknown): ProviderCitationSource[] {
|
|
599
|
+
if (!output || typeof output !== "object") {
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
const record = output as Record<string, unknown>;
|
|
603
|
+
const sources: ProviderCitationSource[] = [];
|
|
604
|
+
|
|
605
|
+
if (toolName === "web_search") {
|
|
606
|
+
const rawResults = record.results;
|
|
607
|
+
if (Array.isArray(rawResults)) {
|
|
608
|
+
for (const item of rawResults) {
|
|
609
|
+
if (!item || typeof item !== "object") {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
const result = item as { url?: unknown; title?: unknown };
|
|
613
|
+
if (typeof result.url !== "string" || !result.url.trim()) {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
sources.push({
|
|
617
|
+
url: result.url.trim(),
|
|
618
|
+
title:
|
|
619
|
+
typeof result.title === "string" && result.title.trim()
|
|
620
|
+
? result.title.trim()
|
|
621
|
+
: undefined,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (toolName === "web_open") {
|
|
628
|
+
const rawUrl = record.url;
|
|
629
|
+
if (typeof rawUrl === "string" && rawUrl.trim()) {
|
|
630
|
+
sources.push({
|
|
631
|
+
url: rawUrl.trim(),
|
|
632
|
+
title: "Opened page",
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return sources;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function truncateMessage(message: string, maxLength = 500): string {
|
|
641
|
+
if (message.length <= maxLength) {
|
|
642
|
+
return message;
|
|
643
|
+
}
|
|
644
|
+
const remaining = message.length - maxLength;
|
|
645
|
+
return `${message.slice(0, maxLength)}… (+${remaining} chars truncated)`;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function stripTechnicalToolError(message: string): string {
|
|
649
|
+
return message
|
|
650
|
+
.trim()
|
|
651
|
+
.replace(/^\d+:\d+:\s*/i, "")
|
|
652
|
+
.replace(/^execution error:\s*/i, "")
|
|
653
|
+
.replace(/^error:\s*/i, "")
|
|
654
|
+
.replace(/\s+\(-\d+\)\s*$/, "")
|
|
655
|
+
.replace(/\s+/g, " ");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function humanizeToolErrorMessage(toolName: string, message: string): string {
|
|
659
|
+
const cleaned = stripTechnicalToolError(message);
|
|
660
|
+
const normalized = cleaned.toLowerCase().replace(/[\u2019]/g, "'");
|
|
661
|
+
|
|
662
|
+
if (toolName === "calendar_create_event" && normalized.includes("can't get date")) {
|
|
663
|
+
const requestedDateMatch = cleaned.match(/can't get date "([^"]+)"/i);
|
|
664
|
+
if (requestedDateMatch?.[1]) {
|
|
665
|
+
const parsed = new Date(requestedDateMatch[1]);
|
|
666
|
+
if (!Number.isNaN(parsed.getTime())) {
|
|
667
|
+
const formatted = parsed.toLocaleString("en-US", {
|
|
668
|
+
month: "short",
|
|
669
|
+
day: "numeric",
|
|
670
|
+
year: "numeric",
|
|
671
|
+
hour: "numeric",
|
|
672
|
+
minute: "2-digit",
|
|
673
|
+
});
|
|
674
|
+
return `Calendar could not understand the event time (${formatted}). Try a local format like "Feb 12 at 9:00 AM".`;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return 'Calendar could not understand the date/time. Try a local format like "Feb 12 at 9:00 AM".';
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (normalized.includes("missing required")) {
|
|
681
|
+
const fieldMatch = cleaned.match(/missing required (?:string|numeric) field:\s*([a-zA-Z0-9_]+)/i);
|
|
682
|
+
if (fieldMatch?.[1]) {
|
|
683
|
+
return `Missing required field "${fieldMatch[1]}".`;
|
|
684
|
+
}
|
|
685
|
+
return "Missing required information for this action.";
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (normalized.includes("tool input must be an object")) {
|
|
689
|
+
return "The request format was invalid. Please try again.";
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (normalized.includes("timed out") || normalized.includes("timeout")) {
|
|
693
|
+
return "The app did not respond in time.";
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (normalized.includes("no calendar is available")) {
|
|
697
|
+
return "No calendar is available in the Calendar app.";
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return cleaned || "Unknown tool execution error.";
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function formatToolErrorForUser(toolName: string, error: unknown, fallback: string): string {
|
|
704
|
+
if (!(error instanceof Error)) {
|
|
705
|
+
return fallback;
|
|
706
|
+
}
|
|
707
|
+
return truncateMessage(humanizeToolErrorMessage(toolName, error.message));
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function summarizeApprovalReason(toolName: string, args: Record<string, unknown>): string {
|
|
711
|
+
if (toolName === "mail_send") {
|
|
712
|
+
const to = Array.isArray(args.to) ? args.to.length : 0;
|
|
713
|
+
return `Send email${to > 0 ? ` to ${to} recipient${to === 1 ? "" : "s"}` : ""}.`;
|
|
714
|
+
}
|
|
715
|
+
if (toolName === "messages_send") {
|
|
716
|
+
const to = Array.isArray(args.to) ? args.to.length : 0;
|
|
717
|
+
return `Send text message${to > 0 ? ` to ${to} recipient${to === 1 ? "" : "s"}` : ""}.`;
|
|
718
|
+
}
|
|
719
|
+
if (toolName === "music_set_volume" || toolName === "system_set_volume") {
|
|
720
|
+
const level = typeof args.level === "number" ? args.level : null;
|
|
721
|
+
return `Set volume${level !== null ? ` to ${level}%` : ""}.`;
|
|
722
|
+
}
|
|
723
|
+
if (toolName === "file_delete_to_trash") {
|
|
724
|
+
return "Move item to Trash.";
|
|
725
|
+
}
|
|
726
|
+
if (toolName === "workflow_run") {
|
|
727
|
+
return "Execute multi-step workflow with write/system/external operations.";
|
|
728
|
+
}
|
|
729
|
+
return `Approval required for ${toolName}.`;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function normalizeAliasText(input: string): string {
|
|
733
|
+
return input
|
|
734
|
+
.toLowerCase()
|
|
735
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
736
|
+
.replace(/\s+/g, " ")
|
|
737
|
+
.trim();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function resolveMusicAlias(query: string, aliases: MemoryMusicAlias[] | undefined): MemoryMusicAlias | null {
|
|
741
|
+
if (!Array.isArray(aliases) || aliases.length === 0) {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const normalizedQuery = normalizeAliasText(query);
|
|
746
|
+
if (!normalizedQuery) {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const scored = aliases
|
|
751
|
+
.map((alias) => {
|
|
752
|
+
const aliasText = typeof alias.alias === "string" ? alias.alias.trim() : "";
|
|
753
|
+
const targetQuery = typeof alias.query === "string" ? alias.query.trim() : "";
|
|
754
|
+
if (!aliasText || !targetQuery) {
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const normalizedAlias = normalizeAliasText(aliasText);
|
|
759
|
+
if (!normalizedAlias) {
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
let score = 0;
|
|
764
|
+
if (normalizedAlias === normalizedQuery) {
|
|
765
|
+
score += 120;
|
|
766
|
+
} else if (
|
|
767
|
+
normalizedQuery.startsWith(`${normalizedAlias} `) ||
|
|
768
|
+
normalizedQuery.includes(` ${normalizedAlias}`)
|
|
769
|
+
) {
|
|
770
|
+
score += 75;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const aliasTokens = normalizedAlias.split(" ").filter(Boolean);
|
|
774
|
+
if (aliasTokens.length > 0) {
|
|
775
|
+
const queryTokens = new Set(normalizedQuery.split(" ").filter(Boolean));
|
|
776
|
+
const overlap = aliasTokens.filter((token) => queryTokens.has(token)).length;
|
|
777
|
+
if (overlap === aliasTokens.length) {
|
|
778
|
+
score += 55;
|
|
779
|
+
} else if (overlap >= 2) {
|
|
780
|
+
score += 30;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return score > 0 ? { alias, score } : null;
|
|
785
|
+
})
|
|
786
|
+
.filter((item): item is { alias: MemoryMusicAlias; score: number } => Boolean(item))
|
|
787
|
+
.sort((left, right) => right.score - left.score);
|
|
788
|
+
|
|
789
|
+
if (scored.length === 0) {
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const top = scored[0];
|
|
794
|
+
const runnerUp = scored[1];
|
|
795
|
+
const strongEnough = top.score >= 75;
|
|
796
|
+
const clearWinner = !runnerUp || top.score - runnerUp.score >= 20;
|
|
797
|
+
if (!strongEnough || !clearWinner) {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
return top.alias;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function resolvePersonAlias(
|
|
804
|
+
input: string,
|
|
805
|
+
aliases: MemoryContextPayload["aliases"]["people"] | undefined,
|
|
806
|
+
): string | null {
|
|
807
|
+
if (!Array.isArray(aliases) || aliases.length === 0) {
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const normalizedInput = normalizeAliasText(input);
|
|
812
|
+
if (!normalizedInput) {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const scored = aliases
|
|
817
|
+
.map((alias) => {
|
|
818
|
+
const aliasText = typeof alias?.alias === "string" ? alias.alias.trim() : "";
|
|
819
|
+
const targetText = typeof alias?.target === "string" ? alias.target.trim() : "";
|
|
820
|
+
if (!aliasText || !targetText) {
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
const normalizedAlias = normalizeAliasText(aliasText);
|
|
824
|
+
if (!normalizedAlias) {
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
let score = 0;
|
|
829
|
+
if (normalizedAlias === normalizedInput) {
|
|
830
|
+
score += 120;
|
|
831
|
+
} else if (
|
|
832
|
+
normalizedInput.startsWith(`${normalizedAlias} `) ||
|
|
833
|
+
normalizedInput.includes(` ${normalizedAlias}`)
|
|
834
|
+
) {
|
|
835
|
+
score += 75;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const aliasTokens = normalizedAlias.split(" ").filter(Boolean);
|
|
839
|
+
if (aliasTokens.length > 0) {
|
|
840
|
+
const inputTokens = new Set(normalizedInput.split(" ").filter(Boolean));
|
|
841
|
+
const overlap = aliasTokens.filter((token) => inputTokens.has(token)).length;
|
|
842
|
+
if (overlap === aliasTokens.length) {
|
|
843
|
+
score += 55;
|
|
844
|
+
} else if (overlap >= 2) {
|
|
845
|
+
score += 30;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
return score > 0 ? { target: targetText, score } : null;
|
|
850
|
+
})
|
|
851
|
+
.filter((item): item is { target: string; score: number } => Boolean(item))
|
|
852
|
+
.sort((left, right) => right.score - left.score);
|
|
853
|
+
|
|
854
|
+
if (scored.length === 0) {
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
const top = scored[0];
|
|
858
|
+
const runnerUp = scored[1];
|
|
859
|
+
const strongEnough = top.score >= 75;
|
|
860
|
+
const clearWinner = !runnerUp || top.score - runnerUp.score >= 20;
|
|
861
|
+
if (!strongEnough || !clearWinner) {
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
return top.target;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function parseDirectMessageIntent(params: {
|
|
868
|
+
text: string;
|
|
869
|
+
peopleAliases?: MemoryContextPayload["aliases"]["people"];
|
|
870
|
+
}): { to: string[]; body: string } | null {
|
|
871
|
+
const text = params.text.replace(/\s+/g, " ").trim();
|
|
872
|
+
if (!text) {
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const separators = [
|
|
877
|
+
" that says ",
|
|
878
|
+
" saying ",
|
|
879
|
+
" with message ",
|
|
880
|
+
" saying that ",
|
|
881
|
+
" that ",
|
|
882
|
+
": ",
|
|
883
|
+
" - ",
|
|
884
|
+
];
|
|
885
|
+
const splitRecipientAndBody = (value: string): { recipient: string; body: string } | null => {
|
|
886
|
+
const compact = value.replace(/\s+/g, " ").trim();
|
|
887
|
+
if (!compact) {
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
const lowered = compact.toLowerCase();
|
|
891
|
+
let bestIndex = -1;
|
|
892
|
+
let bestSeparator = "";
|
|
893
|
+
for (const separator of separators) {
|
|
894
|
+
const candidateIndex = lowered.indexOf(separator);
|
|
895
|
+
if (candidateIndex <= 0) {
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (bestIndex === -1 || candidateIndex < bestIndex) {
|
|
899
|
+
bestIndex = candidateIndex;
|
|
900
|
+
bestSeparator = separator;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
if (bestIndex === -1) {
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
const recipient = compact.slice(0, bestIndex).trim();
|
|
907
|
+
const body = compact.slice(bestIndex + bestSeparator.length).trim();
|
|
908
|
+
if (!recipient || !body) {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
return { recipient, body };
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
let recipientChunk = "";
|
|
915
|
+
let body = "";
|
|
916
|
+
const quotedSendMatch = text.match(/^\s*send\s+["'“”](.+?)["'“”]\s+to\s+(.+?)\s*$/i);
|
|
917
|
+
if (quotedSendMatch?.[1] && quotedSendMatch?.[2]) {
|
|
918
|
+
body = quotedSendMatch[1].trim();
|
|
919
|
+
recipientChunk = quotedSendMatch[2].trim();
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (!recipientChunk || !body) {
|
|
923
|
+
const sendToPrefix = text.match(/^\s*send\s+(?:a\s+)?(?:text(?:\s+message)?|message)\s+to\s+(.+)$/i);
|
|
924
|
+
if (sendToPrefix?.[1]) {
|
|
925
|
+
const split = splitRecipientAndBody(sendToPrefix[1]);
|
|
926
|
+
if (split) {
|
|
927
|
+
recipientChunk = split.recipient;
|
|
928
|
+
body = split.body;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (!recipientChunk || !body) {
|
|
934
|
+
const directPrefix = text.match(/^\s*(?:text|message)\s+(.+)$/i);
|
|
935
|
+
if (directPrefix?.[1]) {
|
|
936
|
+
const split = splitRecipientAndBody(directPrefix[1]);
|
|
937
|
+
if (split) {
|
|
938
|
+
recipientChunk = split.recipient;
|
|
939
|
+
body = split.body;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (!recipientChunk || !body) {
|
|
945
|
+
const sendTargetFirst = text.match(/^\s*send\s+(.+?)\s+(?:a\s+)?(?:text(?:\s+message)?|message)\s+(.+)$/i);
|
|
946
|
+
if (sendTargetFirst?.[1] && sendTargetFirst?.[2]) {
|
|
947
|
+
recipientChunk = sendTargetFirst[1].trim();
|
|
948
|
+
body = sendTargetFirst[2].trim();
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (!recipientChunk || !body) {
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
body = body
|
|
957
|
+
.replace(/^says?\s+/i, "")
|
|
958
|
+
.replace(/^saying\s+/i, "")
|
|
959
|
+
.trim()
|
|
960
|
+
.replace(/^['"]|['"]$/g, "")
|
|
961
|
+
.trim();
|
|
962
|
+
if (!body) {
|
|
963
|
+
return null;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const recipients = recipientChunk
|
|
967
|
+
.replace(/^to\s+/i, "")
|
|
968
|
+
.split(/\s*,\s*|\s+and\s+/i)
|
|
969
|
+
.map((value) => value.trim().replace(/[,:;]+$/g, "").replace(/^['"]|['"]$/g, ""))
|
|
970
|
+
.filter(Boolean)
|
|
971
|
+
.slice(0, 3);
|
|
972
|
+
if (recipients.length === 0) {
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const normalizedRecipients = recipients.map((value) => {
|
|
977
|
+
const normalized = normalizeAliasText(value);
|
|
978
|
+
const blocked = normalized === "me" || normalized === "myself" || normalized === "us";
|
|
979
|
+
if (blocked) {
|
|
980
|
+
return "";
|
|
981
|
+
}
|
|
982
|
+
return resolvePersonAlias(value, params.peopleAliases) ?? value;
|
|
983
|
+
}).filter(Boolean);
|
|
984
|
+
|
|
985
|
+
if (normalizedRecipients.length === 0) {
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return {
|
|
990
|
+
to: normalizedRecipients,
|
|
991
|
+
body,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function extractDirectMusicIntent(messages: ChatMessageInput[], aliases?: MemoryMusicAlias[]): {
|
|
996
|
+
query: string;
|
|
997
|
+
title: string | null;
|
|
998
|
+
artist: string | null;
|
|
999
|
+
volume: number | null;
|
|
1000
|
+
} | null {
|
|
1001
|
+
const userMessages = messages.filter((message) => message.role === "user");
|
|
1002
|
+
const lastUser = [...userMessages].reverse()[0];
|
|
1003
|
+
if (!lastUser) {
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
const text = lastUser.content.trim();
|
|
1007
|
+
if (!text) {
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const parsePlayText = (value: string) => {
|
|
1012
|
+
const playMatch = value.match(/play\s+(.+?)(?:\s+at\s+\d{1,3}\s*%|\s+volume|\s*$)/i);
|
|
1013
|
+
if (!playMatch?.[1]) {
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
const raw = playMatch[1]
|
|
1017
|
+
.replace(/\bvia apple music app\b/gi, "")
|
|
1018
|
+
.replace(/\bon apple music\b/gi, "")
|
|
1019
|
+
.trim();
|
|
1020
|
+
if (!raw) {
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
const bySplit = raw.split(/\s+by\s+/i);
|
|
1024
|
+
const hasExplicitArtist = bySplit.length > 1;
|
|
1025
|
+
const title = hasExplicitArtist ? bySplit[0]?.trim() || null : null;
|
|
1026
|
+
const artist = hasExplicitArtist ? bySplit.slice(1).join(" by ").trim() : null;
|
|
1027
|
+
return {
|
|
1028
|
+
query: raw,
|
|
1029
|
+
title,
|
|
1030
|
+
artist,
|
|
1031
|
+
};
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
let parsed = parsePlayText(text);
|
|
1035
|
+
if (!parsed) {
|
|
1036
|
+
const notPlayingFollowup = /not playing|isn['’]?t playing|is not playing|not playing yet/i.test(text);
|
|
1037
|
+
if (!notPlayingFollowup) {
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
1040
|
+
const priorPlayable = [...userMessages].reverse().find((message) =>
|
|
1041
|
+
/play\s+/i.test(message.content),
|
|
1042
|
+
);
|
|
1043
|
+
if (!priorPlayable) {
|
|
1044
|
+
return null;
|
|
1045
|
+
}
|
|
1046
|
+
parsed = parsePlayText(priorPlayable.content);
|
|
1047
|
+
if (!parsed) {
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const query = parsed.query.trim();
|
|
1053
|
+
if (!query) {
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
const resolvedAlias = resolveMusicAlias(query, aliases);
|
|
1057
|
+
|
|
1058
|
+
const volumeMatch = text.match(/(\d{1,3})\s*%/);
|
|
1059
|
+
const volume = volumeMatch ? Math.max(0, Math.min(100, Number(volumeMatch[1]))) : null;
|
|
1060
|
+
return {
|
|
1061
|
+
query: resolvedAlias?.query?.trim() || query,
|
|
1062
|
+
title: resolvedAlias?.title?.trim() || parsed.title,
|
|
1063
|
+
artist: resolvedAlias?.artist?.trim() || parsed.artist,
|
|
1064
|
+
volume: Number.isFinite(volume as number) ? volume : null,
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function titleCase(input: string): string {
|
|
1069
|
+
return input.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function inferNoteTopic(messages: ChatMessageInput[]): { title: string; topic: string } | null {
|
|
1073
|
+
const userMessages = messages.filter((message) => message.role === "user");
|
|
1074
|
+
const lastUser = [...userMessages].reverse()[0];
|
|
1075
|
+
if (!lastUser) {
|
|
1076
|
+
return null;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const text = lastUser.content.trim();
|
|
1080
|
+
const noteMatch = text.match(/\b(?:create|make|write)\b.*\bnote\b(?:\s+about|\s+on|\s+for|\s+titled)?\s+(.+)$/i);
|
|
1081
|
+
if (!noteMatch?.[1]) {
|
|
1082
|
+
return null;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const topic = noteMatch[1]
|
|
1086
|
+
.replace(/[.?!]+$/g, "")
|
|
1087
|
+
.replace(/^['"]|['"]$/g, "")
|
|
1088
|
+
.trim();
|
|
1089
|
+
if (!topic) {
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
return {
|
|
1094
|
+
topic,
|
|
1095
|
+
title: titleCase(topic),
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function inferDetailedNoteFollowup(messages: ChatMessageInput[]): { title: string; topic: string } | null {
|
|
1100
|
+
const userMessages = messages.filter((message) => message.role === "user");
|
|
1101
|
+
const lastUser = [...userMessages].reverse()[0];
|
|
1102
|
+
if (!lastUser) {
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (!/\b(more detail|detailed|in depth|in-depth|comprehensive|expand)\b/i.test(lastUser.content)) {
|
|
1107
|
+
return null;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
for (let index = userMessages.length - 2; index >= 0; index -= 1) {
|
|
1111
|
+
const candidate = inferNoteTopic(userMessages.slice(0, index + 1));
|
|
1112
|
+
if (candidate) {
|
|
1113
|
+
return candidate;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function shouldBypassDirectFastPathForCompoundRequest(input: string): boolean {
|
|
1121
|
+
const text = input.toLowerCase().replace(/\s+/g, " ").trim();
|
|
1122
|
+
if (!text) {
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
const clauses = splitCompoundActionClauses(text);
|
|
1126
|
+
if (clauses.length < 2) {
|
|
1127
|
+
return false;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const domainPatterns: RegExp[] = [
|
|
1131
|
+
/\b(play|music|song|track|volume)\b/i,
|
|
1132
|
+
/\b(note|notes|apple notes|write down|jot)\b/i,
|
|
1133
|
+
/\b(remind|reminder|calendar|event|schedule)\b/i,
|
|
1134
|
+
/\b(open|launch|focus app|application)\b/i,
|
|
1135
|
+
/\b(email|mail|message|text)\b/i,
|
|
1136
|
+
/\b(file|folder|directory|move|copy|delete|trash)\b/i,
|
|
1137
|
+
];
|
|
1138
|
+
const matchedDomains = new Set<number>();
|
|
1139
|
+
for (const clause of clauses) {
|
|
1140
|
+
domainPatterns.forEach((pattern, index) => {
|
|
1141
|
+
if (pattern.test(clause)) {
|
|
1142
|
+
matchedDomains.add(index);
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
return matchedDomains.size >= 2;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function sanitizeReminderTitle(input: string): string {
|
|
1150
|
+
return input
|
|
1151
|
+
.replace(/^(?:at|for)\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)\b/i, "")
|
|
1152
|
+
.replace(/^\d{1,2}(?::\d{2})?\s*(?:am|pm)\b/i, "")
|
|
1153
|
+
.replace(
|
|
1154
|
+
new RegExp(
|
|
1155
|
+
`\\s+(?:and|then|also|plus|next|finally|lastly|after that)\\s+(?=(?:${ACTION_VERB_PATTERN})\\b)[\\s\\S]*$`,
|
|
1156
|
+
"i",
|
|
1157
|
+
),
|
|
1158
|
+
"",
|
|
1159
|
+
)
|
|
1160
|
+
.replace(
|
|
1161
|
+
new RegExp(`\\s*(?:,|;)\\s*(?=(?:${SEQUENCE_MARKER_PATTERN}|${ACTION_VERB_PATTERN})\\b)[\\s\\S]*$`, "i"),
|
|
1162
|
+
"",
|
|
1163
|
+
)
|
|
1164
|
+
.replace(/\s+(?:at|for)\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)\b(?:\s+(?:today|tomorrow))?$/i, "")
|
|
1165
|
+
.replace(/\s+(?:for|at)\s+(?:today|tomorrow|next\s+\w+|this\s+\w+|upcoming\s+\w+)$/i, "")
|
|
1166
|
+
.replace(/^(?:that|to|for|about|i have to)\s+/i, "")
|
|
1167
|
+
.replace(/[.?!]+$/g, "")
|
|
1168
|
+
.trim();
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function buildDetailedNoteBody(topic: string): string {
|
|
1172
|
+
const normalizedTopic = topic.trim();
|
|
1173
|
+
const topicLower = normalizedTopic.toLowerCase();
|
|
1174
|
+
|
|
1175
|
+
if (/\bfood allerg(y|ies)\b/i.test(topicLower)) {
|
|
1176
|
+
return [
|
|
1177
|
+
"## Overview",
|
|
1178
|
+
"**Food allergies** are immune-system reactions to specific foods. Reactions can range from mild symptoms to severe, life-threatening anaphylaxis, so prevention and a clear response plan matter.",
|
|
1179
|
+
"",
|
|
1180
|
+
"## Snapshot",
|
|
1181
|
+
"| Area | Key Points |",
|
|
1182
|
+
"| --- | --- |",
|
|
1183
|
+
"| Definition | Immune response triggered by specific foods. |",
|
|
1184
|
+
"| Highest Risk | Rapid-onset breathing trouble, throat swelling, fainting. |",
|
|
1185
|
+
"| Priority | Strict avoidance + clear emergency action plan. |",
|
|
1186
|
+
"",
|
|
1187
|
+
"## Common Triggers",
|
|
1188
|
+
"- Peanuts and tree nuts (almonds, cashews, walnuts, pistachios)",
|
|
1189
|
+
"- Milk and egg",
|
|
1190
|
+
"- Wheat and soy",
|
|
1191
|
+
"- Fish and shellfish",
|
|
1192
|
+
"- Sesame",
|
|
1193
|
+
"",
|
|
1194
|
+
"## Typical Symptoms",
|
|
1195
|
+
"- Skin: itching, hives, flushing, swelling",
|
|
1196
|
+
"- GI: nausea, vomiting, cramps, diarrhea",
|
|
1197
|
+
"- Respiratory: cough, wheeze, throat tightness, trouble breathing",
|
|
1198
|
+
"- Cardiovascular/neuro: dizziness, fainting, confusion",
|
|
1199
|
+
"",
|
|
1200
|
+
"## Red Flags (Emergency)",
|
|
1201
|
+
"- Trouble breathing or swallowing",
|
|
1202
|
+
"- Swelling of tongue/lips/throat",
|
|
1203
|
+
"- Repeated vomiting after exposure",
|
|
1204
|
+
"- Sudden dizziness or fainting",
|
|
1205
|
+
"",
|
|
1206
|
+
"## Action Plan",
|
|
1207
|
+
"1. **Avoid** known trigger foods and cross-contact.",
|
|
1208
|
+
"2. **Read labels** every time (ingredients can change).",
|
|
1209
|
+
"3. **Carry prescribed emergency medication** (e.g., epinephrine) if indicated.",
|
|
1210
|
+
"4. **Use emergency medication immediately** for severe symptoms.",
|
|
1211
|
+
"5. **Call emergency services** after epinephrine use.",
|
|
1212
|
+
"",
|
|
1213
|
+
"## Daily Prevention Checklist",
|
|
1214
|
+
"- Confirm safe meal options before eating out.",
|
|
1215
|
+
"- Keep ingredient lists for frequently used products.",
|
|
1216
|
+
"- Inform school/work/family about allergy and emergency steps.",
|
|
1217
|
+
"- Keep meds accessible and not expired.",
|
|
1218
|
+
"",
|
|
1219
|
+
"## Medical Follow-up",
|
|
1220
|
+
"- Confirm diagnosis with an allergist (history + testing).",
|
|
1221
|
+
"- Review whether oral food challenge is appropriate.",
|
|
1222
|
+
"- Recheck plan annually or after any serious reaction.",
|
|
1223
|
+
"",
|
|
1224
|
+
"## Notes",
|
|
1225
|
+
"**This is an educational summary, not personal medical advice.** Use clinician guidance for diagnosis and treatment decisions.",
|
|
1226
|
+
].join("\n");
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
return [
|
|
1230
|
+
"## Overview",
|
|
1231
|
+
`This note captures a detailed, practical view of **${normalizedTopic}**.`,
|
|
1232
|
+
"",
|
|
1233
|
+
"## Snapshot",
|
|
1234
|
+
"| Dimension | Summary |",
|
|
1235
|
+
"| --- | --- |",
|
|
1236
|
+
`| Objective | Define a concrete, measurable outcome for ${normalizedTopic}. |`,
|
|
1237
|
+
"| Constraints | Account for time, ownership, and available resources. |",
|
|
1238
|
+
"| Success Signal | Use evidence-based checkpoints and review cadence. |",
|
|
1239
|
+
"",
|
|
1240
|
+
"## Core Concepts",
|
|
1241
|
+
`- **What ${normalizedTopic} is** and why it matters`,
|
|
1242
|
+
`- **Main factors** that influence outcomes in ${normalizedTopic}`,
|
|
1243
|
+
`- **Common misconceptions** that cause mistakes`,
|
|
1244
|
+
"",
|
|
1245
|
+
"## Practical Framework",
|
|
1246
|
+
"1. **Clarify the objective** and success criteria.",
|
|
1247
|
+
"2. **Identify constraints** (time, resources, stakeholders).",
|
|
1248
|
+
"3. **Break work into small, testable steps.**",
|
|
1249
|
+
"4. **Track results and adjust** based on evidence.",
|
|
1250
|
+
"",
|
|
1251
|
+
"## Risks and Failure Modes",
|
|
1252
|
+
"- Ambiguous goals or undefined ownership",
|
|
1253
|
+
"- Inconsistent execution over time",
|
|
1254
|
+
"- Missing feedback loops and measurement",
|
|
1255
|
+
"",
|
|
1256
|
+
"## Implementation Checklist",
|
|
1257
|
+
"- Define clear next actions for this week",
|
|
1258
|
+
"- Set a simple review cadence",
|
|
1259
|
+
"- Document decisions and assumptions",
|
|
1260
|
+
"- Capture open questions for follow-up",
|
|
1261
|
+
"",
|
|
1262
|
+
"## Next Steps",
|
|
1263
|
+
`If needed, expand this into a deeper version with examples, templates, and references tailored to **${normalizedTopic}**.`,
|
|
1264
|
+
].join("\n");
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
async function tryDirectAutomationFastPath(params: {
|
|
1268
|
+
localTools: LocalToolsSettings;
|
|
1269
|
+
messages: ChatMessageInput[];
|
|
1270
|
+
memory?: MemoryContextPayload | null;
|
|
1271
|
+
send: (chunk: ChatStreamChunk) => void;
|
|
1272
|
+
signal?: AbortSignal;
|
|
1273
|
+
}): Promise<boolean> {
|
|
1274
|
+
if (!params.localTools.enabled) {
|
|
1275
|
+
return false;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const tools = createToolRegistry(params.localTools);
|
|
1279
|
+
const lastUser = [...params.messages].reverse().find((message) => message.role === "user");
|
|
1280
|
+
const lastUserText = lastUser?.content.trim() ?? "";
|
|
1281
|
+
if (shouldBypassDirectFastPathForCompoundRequest(lastUserText)) {
|
|
1282
|
+
return false;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const parseReminderIntent = (text: string): { title: string; due?: string } | null => {
|
|
1286
|
+
if (!/\b(reminder|remind me)\b/i.test(text)) {
|
|
1287
|
+
return null;
|
|
1288
|
+
}
|
|
1289
|
+
const weekdays = [
|
|
1290
|
+
"sunday",
|
|
1291
|
+
"monday",
|
|
1292
|
+
"tuesday",
|
|
1293
|
+
"wednesday",
|
|
1294
|
+
"thursday",
|
|
1295
|
+
"friday",
|
|
1296
|
+
"saturday",
|
|
1297
|
+
] as const;
|
|
1298
|
+
const weekdayIndex = Object.fromEntries(weekdays.map((day, index) => [day, index])) as Record<
|
|
1299
|
+
(typeof weekdays)[number],
|
|
1300
|
+
number
|
|
1301
|
+
>;
|
|
1302
|
+
const normalizedText = text.replace(/\bthis\s+upcoming\s+/gi, "upcoming ");
|
|
1303
|
+
const timeMatch = normalizedText.match(/\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i);
|
|
1304
|
+
const weekdayMatch = normalizedText.match(
|
|
1305
|
+
/\b(?:(this|next|upcoming)\s+)?(sunday|monday|tuesday|wednesday|thursday|friday|saturday)\b/i,
|
|
1306
|
+
);
|
|
1307
|
+
const hasTomorrow = /\btomorrow\b/i.test(normalizedText);
|
|
1308
|
+
const hasToday = /\btoday\b/i.test(normalizedText);
|
|
1309
|
+
|
|
1310
|
+
const toIsoLocal = (value: Date) => {
|
|
1311
|
+
const yyyy = value.getFullYear();
|
|
1312
|
+
const mm = String(value.getMonth() + 1).padStart(2, "0");
|
|
1313
|
+
const dd = String(value.getDate()).padStart(2, "0");
|
|
1314
|
+
const hh = String(value.getHours()).padStart(2, "0");
|
|
1315
|
+
const min = String(value.getMinutes()).padStart(2, "0");
|
|
1316
|
+
return `${yyyy}-${mm}-${dd}T${hh}:${min}:00`;
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1319
|
+
const resolveDueBaseDate = () => {
|
|
1320
|
+
const base = new Date();
|
|
1321
|
+
if (hasTomorrow) {
|
|
1322
|
+
base.setDate(base.getDate() + 1);
|
|
1323
|
+
return base;
|
|
1324
|
+
}
|
|
1325
|
+
if (hasToday) {
|
|
1326
|
+
return base;
|
|
1327
|
+
}
|
|
1328
|
+
if (!weekdayMatch?.[2]) {
|
|
1329
|
+
return null;
|
|
1330
|
+
}
|
|
1331
|
+
const modifier = weekdayMatch[1]?.toLowerCase();
|
|
1332
|
+
const targetDay = weekdayMatch[2].toLowerCase() as (typeof weekdays)[number];
|
|
1333
|
+
const todayDay = base.getDay();
|
|
1334
|
+
let delta = (weekdayIndex[targetDay] - todayDay + 7) % 7;
|
|
1335
|
+
|
|
1336
|
+
if (modifier === "next") {
|
|
1337
|
+
if (delta === 0) {
|
|
1338
|
+
delta = 7;
|
|
1339
|
+
}
|
|
1340
|
+
} else if (modifier === "this") {
|
|
1341
|
+
if (delta === 0 && !timeMatch) {
|
|
1342
|
+
delta = 0;
|
|
1343
|
+
}
|
|
1344
|
+
} else if (delta === 0) {
|
|
1345
|
+
delta = 7;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
base.setDate(base.getDate() + delta);
|
|
1349
|
+
return base;
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
let due: string | undefined;
|
|
1353
|
+
const dueBaseDate = resolveDueBaseDate();
|
|
1354
|
+
if (timeMatch) {
|
|
1355
|
+
const hoursRaw = Number(timeMatch[1]);
|
|
1356
|
+
const minutesRaw = Number(timeMatch[2] ?? "0");
|
|
1357
|
+
const meridiem = timeMatch[3].toLowerCase();
|
|
1358
|
+
let hours24 = hoursRaw % 12;
|
|
1359
|
+
if (meridiem === "pm") {
|
|
1360
|
+
hours24 += 12;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
if (dueBaseDate) {
|
|
1364
|
+
dueBaseDate.setHours(hours24, minutesRaw, 0, 0);
|
|
1365
|
+
due = toIsoLocal(dueBaseDate);
|
|
1366
|
+
} else {
|
|
1367
|
+
const base = new Date();
|
|
1368
|
+
base.setHours(hours24, minutesRaw, 0, 0);
|
|
1369
|
+
if (base.getTime() < Date.now() - 60_000) {
|
|
1370
|
+
base.setDate(base.getDate() + 1);
|
|
1371
|
+
}
|
|
1372
|
+
due = toIsoLocal(base);
|
|
1373
|
+
}
|
|
1374
|
+
} else if (dueBaseDate) {
|
|
1375
|
+
due = toIsoLocal(dueBaseDate);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const dateStopPattern =
|
|
1379
|
+
"\\s+(?:at\\s+.+|today|tomorrow|this\\s+upcoming\\s+(?:sunday|monday|tuesday|wednesday|thursday|friday|saturday)|this\\s+(?:sunday|monday|tuesday|wednesday|thursday|friday|saturday)|next\\s+(?:sunday|monday|tuesday|wednesday|thursday|friday|saturday)|upcoming\\s+(?:sunday|monday|tuesday|wednesday|thursday|friday|saturday)|sunday|monday|tuesday|wednesday|thursday|friday|saturday|$)";
|
|
1380
|
+
const aboutMatch = normalizedText.match(/\babout\s+(.+)$/i);
|
|
1381
|
+
const reminderToMatch = normalizedText.match(
|
|
1382
|
+
/\breminder\b(?:\s+for\s+[^,.;!?]+|\s+at\s+[^,.;!?]+)?\s+to\s+(.+)$/i,
|
|
1383
|
+
);
|
|
1384
|
+
const toMatch = normalizedText.match(
|
|
1385
|
+
new RegExp(`\\bremind me(?:\\s+to)?\\s+(.+?)(?:${dateStopPattern})`, "i"),
|
|
1386
|
+
);
|
|
1387
|
+
const reminderDateThenTitleMatch = normalizedText.match(
|
|
1388
|
+
/\breminder\b(?:\s+for\s+(?:this\s+upcoming|this|next|upcoming)?\s*(?:today|tomorrow|sunday|monday|tuesday|wednesday|thursday|friday|saturday))\s+(?:for|about)\s+(.+?)(?:\s+at\s+.+|$)/i,
|
|
1389
|
+
);
|
|
1390
|
+
const reminderForMatch = normalizedText.match(
|
|
1391
|
+
new RegExp(
|
|
1392
|
+
`\\breminder(?:\\s+that)?\\s+(.+?)(?:\\s+for\\s+\\d{1,2}(?::\\d{2})?\\s*(?:am|pm)|${dateStopPattern})`,
|
|
1393
|
+
"i",
|
|
1394
|
+
),
|
|
1395
|
+
);
|
|
1396
|
+
const forAboutMatch = normalizedText.match(
|
|
1397
|
+
/\breminder\b(?:\s+for\s+\d{1,2}(?::\d{2})?\s*(?:am|pm))?(?:\s+(?:tomorrow|today|this\s+upcoming\s+(?:sunday|monday|tuesday|wednesday|thursday|friday|saturday)|this\s+(?:sunday|monday|tuesday|wednesday|thursday|friday|saturday)|next\s+(?:sunday|monday|tuesday|wednesday|thursday|friday|saturday)|upcoming\s+(?:sunday|monday|tuesday|wednesday|thursday|friday|saturday)))?\s+about\s+(.+)$/i,
|
|
1398
|
+
);
|
|
1399
|
+
const rawTitle =
|
|
1400
|
+
aboutMatch?.[1]?.trim() ||
|
|
1401
|
+
reminderToMatch?.[1]?.trim() ||
|
|
1402
|
+
reminderDateThenTitleMatch?.[1]?.trim() ||
|
|
1403
|
+
forAboutMatch?.[1]?.trim() ||
|
|
1404
|
+
toMatch?.[1]?.trim() ||
|
|
1405
|
+
reminderForMatch?.[1]?.trim() ||
|
|
1406
|
+
"Reminder";
|
|
1407
|
+
const title = sanitizeReminderTitle(
|
|
1408
|
+
rawTitle
|
|
1409
|
+
.replace(/^(that|i have to|to|for)\s+/i, "")
|
|
1410
|
+
.trim(),
|
|
1411
|
+
);
|
|
1412
|
+
|
|
1413
|
+
return { title: title || "Reminder", due };
|
|
1414
|
+
};
|
|
1415
|
+
|
|
1416
|
+
// Fast path 1: reminders
|
|
1417
|
+
const reminderIntent = parseReminderIntent(lastUserText);
|
|
1418
|
+
if (params.localTools.enableCalendar && reminderIntent) {
|
|
1419
|
+
const reminderTool = findToolByName(tools, "reminder_create");
|
|
1420
|
+
if (reminderTool) {
|
|
1421
|
+
if (requiresApprovalForRisk(reminderTool.risk, params.localTools.approvalMode)) {
|
|
1422
|
+
return false;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
const { title, due } = reminderIntent;
|
|
1426
|
+
const dueDateOnly = due?.match(/^\d{4}-\d{2}-\d{2}/)?.[0] ?? due;
|
|
1427
|
+
|
|
1428
|
+
const callId = "fastpath_reminder_create";
|
|
1429
|
+
params.send({
|
|
1430
|
+
type: "tool_call",
|
|
1431
|
+
callId,
|
|
1432
|
+
name: reminderTool.name,
|
|
1433
|
+
args: { title, due },
|
|
1434
|
+
});
|
|
1435
|
+
params.send({
|
|
1436
|
+
type: "tool_progress",
|
|
1437
|
+
callId,
|
|
1438
|
+
name: reminderTool.name,
|
|
1439
|
+
status: "started",
|
|
1440
|
+
message: "Creating reminder.",
|
|
1441
|
+
});
|
|
1442
|
+
try {
|
|
1443
|
+
const output = await reminderTool.execute(
|
|
1444
|
+
{ title, due },
|
|
1445
|
+
{ localTools: params.localTools, signal: params.signal },
|
|
1446
|
+
);
|
|
1447
|
+
params.send({
|
|
1448
|
+
type: "tool_result",
|
|
1449
|
+
callId,
|
|
1450
|
+
name: reminderTool.name,
|
|
1451
|
+
ok: true,
|
|
1452
|
+
result: output,
|
|
1453
|
+
});
|
|
1454
|
+
params.send({
|
|
1455
|
+
type: "token",
|
|
1456
|
+
value: `**Done.** Created reminder "${title}"${dueDateOnly ? ` for ${dueDateOnly}` : ""}.`,
|
|
1457
|
+
});
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
const message = formatToolErrorForUser(
|
|
1460
|
+
reminderTool.name,
|
|
1461
|
+
error,
|
|
1462
|
+
"Could not create reminder.",
|
|
1463
|
+
);
|
|
1464
|
+
params.send({
|
|
1465
|
+
type: "tool_result",
|
|
1466
|
+
callId,
|
|
1467
|
+
name: reminderTool.name,
|
|
1468
|
+
ok: false,
|
|
1469
|
+
result: { error: message },
|
|
1470
|
+
});
|
|
1471
|
+
params.send({
|
|
1472
|
+
type: "token",
|
|
1473
|
+
value:
|
|
1474
|
+
`I couldn't confirm reminder creation: ${message}. ` +
|
|
1475
|
+
"I did not auto-retry to avoid duplicate reminders.",
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
return true;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// Fast path 2: direct text/iMessage
|
|
1483
|
+
const messageIntent = parseDirectMessageIntent({
|
|
1484
|
+
text: lastUserText,
|
|
1485
|
+
peopleAliases: params.memory?.enabled ? params.memory.aliases.people : [],
|
|
1486
|
+
});
|
|
1487
|
+
if (messageIntent) {
|
|
1488
|
+
if (!params.localTools.enableMail) {
|
|
1489
|
+
params.send({
|
|
1490
|
+
type: "token",
|
|
1491
|
+
value:
|
|
1492
|
+
"Messaging tools are disabled. Enable **mail/messages tools** in Settings > Local Tools, then retry.",
|
|
1493
|
+
});
|
|
1494
|
+
return true;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
const messageTool = findToolByName(tools, "messages_send");
|
|
1498
|
+
if (messageTool) {
|
|
1499
|
+
if (requiresApprovalForRisk(messageTool.risk, params.localTools.approvalMode)) {
|
|
1500
|
+
return false;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
const callId = "fastpath_messages_send";
|
|
1504
|
+
const args = {
|
|
1505
|
+
to: messageIntent.to,
|
|
1506
|
+
body: messageIntent.body,
|
|
1507
|
+
};
|
|
1508
|
+
params.send({
|
|
1509
|
+
type: "tool_call",
|
|
1510
|
+
callId,
|
|
1511
|
+
name: messageTool.name,
|
|
1512
|
+
args,
|
|
1513
|
+
});
|
|
1514
|
+
params.send({
|
|
1515
|
+
type: "tool_progress",
|
|
1516
|
+
callId,
|
|
1517
|
+
name: messageTool.name,
|
|
1518
|
+
status: "started",
|
|
1519
|
+
message: "Sending message.",
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
try {
|
|
1523
|
+
const output = await messageTool.execute(args, {
|
|
1524
|
+
localTools: params.localTools,
|
|
1525
|
+
signal: params.signal,
|
|
1526
|
+
});
|
|
1527
|
+
params.send({
|
|
1528
|
+
type: "tool_result",
|
|
1529
|
+
callId,
|
|
1530
|
+
name: messageTool.name,
|
|
1531
|
+
ok: true,
|
|
1532
|
+
result: output,
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
const outputRecord =
|
|
1536
|
+
output && typeof output === "object"
|
|
1537
|
+
? (output as { recipients?: Array<{ name?: string | null; input?: string; handle?: string }> })
|
|
1538
|
+
: null;
|
|
1539
|
+
const recipientLabel =
|
|
1540
|
+
outputRecord?.recipients?.map((recipient) => recipient.name || recipient.input || recipient.handle)
|
|
1541
|
+
.filter(Boolean)
|
|
1542
|
+
.join(", ") || messageIntent.to.join(", ");
|
|
1543
|
+
|
|
1544
|
+
params.send({
|
|
1545
|
+
type: "token",
|
|
1546
|
+
value: `**Done.** Sent message to ${recipientLabel}.`,
|
|
1547
|
+
});
|
|
1548
|
+
} catch (error) {
|
|
1549
|
+
const message = formatToolErrorForUser(
|
|
1550
|
+
messageTool.name,
|
|
1551
|
+
error,
|
|
1552
|
+
"Could not send message.",
|
|
1553
|
+
);
|
|
1554
|
+
params.send({
|
|
1555
|
+
type: "tool_result",
|
|
1556
|
+
callId,
|
|
1557
|
+
name: messageTool.name,
|
|
1558
|
+
ok: false,
|
|
1559
|
+
result: { error: message },
|
|
1560
|
+
});
|
|
1561
|
+
params.send({
|
|
1562
|
+
type: "token",
|
|
1563
|
+
value: `I couldn't send that message: ${message}.`,
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
return true;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// Fast path 3: quick-note capture only (defer richer notes to model planning)
|
|
1571
|
+
const noteFollowup = inferDetailedNoteFollowup(params.messages);
|
|
1572
|
+
const noteIntent = noteFollowup ?? inferNoteTopic(params.messages);
|
|
1573
|
+
const isQuickNoteRequest =
|
|
1574
|
+
/\b(quick note|jot(?:\s+this|\s+that|\s+it|\s+down)?|capture (?:this|that|it) (?:as a )?note)\b/i.test(
|
|
1575
|
+
lastUserText,
|
|
1576
|
+
);
|
|
1577
|
+
if (params.localTools.enableNotes && noteIntent && isQuickNoteRequest) {
|
|
1578
|
+
const noteTool = findToolByName(tools, "notes_create_or_append");
|
|
1579
|
+
if (noteTool) {
|
|
1580
|
+
if (requiresApprovalForRisk(noteTool.risk, params.localTools.approvalMode)) {
|
|
1581
|
+
return false;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const title = noteIntent.title;
|
|
1585
|
+
const body = buildDetailedNoteBody(noteIntent.topic);
|
|
1586
|
+
|
|
1587
|
+
const callId = "fastpath_note_create";
|
|
1588
|
+
params.send({
|
|
1589
|
+
type: "tool_call",
|
|
1590
|
+
callId,
|
|
1591
|
+
name: noteTool.name,
|
|
1592
|
+
args: { title, body, mode: "create" },
|
|
1593
|
+
});
|
|
1594
|
+
params.send({
|
|
1595
|
+
type: "tool_progress",
|
|
1596
|
+
callId,
|
|
1597
|
+
name: noteTool.name,
|
|
1598
|
+
status: "started",
|
|
1599
|
+
message: noteFollowup ? "Updating note with more detail." : "Creating detailed note.",
|
|
1600
|
+
});
|
|
1601
|
+
try {
|
|
1602
|
+
const output = await noteTool.execute(
|
|
1603
|
+
{ title, body, mode: "create" },
|
|
1604
|
+
{ localTools: params.localTools, signal: params.signal },
|
|
1605
|
+
);
|
|
1606
|
+
params.send({
|
|
1607
|
+
type: "tool_result",
|
|
1608
|
+
callId,
|
|
1609
|
+
name: noteTool.name,
|
|
1610
|
+
ok: true,
|
|
1611
|
+
result: output,
|
|
1612
|
+
});
|
|
1613
|
+
params.send({
|
|
1614
|
+
type: "token",
|
|
1615
|
+
value: noteFollowup
|
|
1616
|
+
? `**Done.** Updated note "${title}" with more detail.`
|
|
1617
|
+
: `**Done.** Created detailed note "${title}".`,
|
|
1618
|
+
});
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
const message = formatToolErrorForUser(noteTool.name, error, "Could not create note.");
|
|
1621
|
+
params.send({
|
|
1622
|
+
type: "tool_result",
|
|
1623
|
+
callId,
|
|
1624
|
+
name: noteTool.name,
|
|
1625
|
+
ok: false,
|
|
1626
|
+
result: { error: message },
|
|
1627
|
+
});
|
|
1628
|
+
params.send({
|
|
1629
|
+
type: "token",
|
|
1630
|
+
value: `I couldn't create that note: ${message}.`,
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
return true;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
if (!params.localTools.enableMusic) {
|
|
1638
|
+
return false;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
const intent = extractDirectMusicIntent(
|
|
1642
|
+
params.messages,
|
|
1643
|
+
params.memory?.enabled ? params.memory.aliases.music : [],
|
|
1644
|
+
);
|
|
1645
|
+
if (!intent) {
|
|
1646
|
+
return false;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
const playTool = findToolByName(tools, "music_play");
|
|
1650
|
+
const volumeTool = findToolByName(
|
|
1651
|
+
tools,
|
|
1652
|
+
params.localTools.enableSystem ? "system_set_volume" : "music_set_volume",
|
|
1653
|
+
);
|
|
1654
|
+
if (!playTool) {
|
|
1655
|
+
return false;
|
|
1656
|
+
}
|
|
1657
|
+
if (requiresApprovalForRisk(playTool.risk, params.localTools.approvalMode)) {
|
|
1658
|
+
return false;
|
|
1659
|
+
}
|
|
1660
|
+
if (
|
|
1661
|
+
intent.volume !== null &&
|
|
1662
|
+
volumeTool &&
|
|
1663
|
+
requiresApprovalForRisk(volumeTool.risk, params.localTools.approvalMode)
|
|
1664
|
+
) {
|
|
1665
|
+
return false;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
const playCallId = "fastpath_music_play";
|
|
1669
|
+
params.send({
|
|
1670
|
+
type: "tool_call",
|
|
1671
|
+
callId: playCallId,
|
|
1672
|
+
name: playTool.name,
|
|
1673
|
+
args: {
|
|
1674
|
+
query: intent.query,
|
|
1675
|
+
title: intent.title,
|
|
1676
|
+
artist: intent.artist,
|
|
1677
|
+
source: "apple_music",
|
|
1678
|
+
},
|
|
1679
|
+
});
|
|
1680
|
+
params.send({
|
|
1681
|
+
type: "tool_progress",
|
|
1682
|
+
callId: playCallId,
|
|
1683
|
+
name: playTool.name,
|
|
1684
|
+
status: "started",
|
|
1685
|
+
message: "Starting playback.",
|
|
1686
|
+
});
|
|
1687
|
+
let playOutput: unknown;
|
|
1688
|
+
try {
|
|
1689
|
+
playOutput = await playTool.execute(
|
|
1690
|
+
{
|
|
1691
|
+
query: intent.query,
|
|
1692
|
+
title: intent.title,
|
|
1693
|
+
artist: intent.artist,
|
|
1694
|
+
source: "apple_music",
|
|
1695
|
+
},
|
|
1696
|
+
{ localTools: params.localTools, signal: params.signal },
|
|
1697
|
+
);
|
|
1698
|
+
} catch (error) {
|
|
1699
|
+
const message = formatToolErrorForUser(playTool.name, error, "Could not start playback.");
|
|
1700
|
+
params.send({
|
|
1701
|
+
type: "tool_result",
|
|
1702
|
+
callId: playCallId,
|
|
1703
|
+
name: playTool.name,
|
|
1704
|
+
ok: false,
|
|
1705
|
+
result: { error: message },
|
|
1706
|
+
});
|
|
1707
|
+
params.send({
|
|
1708
|
+
type: "token",
|
|
1709
|
+
value: `I couldn't start Apple Music playback: ${message}.`,
|
|
1710
|
+
});
|
|
1711
|
+
return true;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
const playResult =
|
|
1715
|
+
playOutput && typeof playOutput === "object"
|
|
1716
|
+
? (playOutput as {
|
|
1717
|
+
playing?: boolean;
|
|
1718
|
+
matched?: boolean;
|
|
1719
|
+
playlistName?: string | null;
|
|
1720
|
+
title?: string | null;
|
|
1721
|
+
artist?: string | null;
|
|
1722
|
+
album?: string | null;
|
|
1723
|
+
reason?: string | null;
|
|
1724
|
+
matchedTitle?: string | null;
|
|
1725
|
+
matchedArtist?: string | null;
|
|
1726
|
+
catalogTitle?: string | null;
|
|
1727
|
+
catalogArtist?: string | null;
|
|
1728
|
+
})
|
|
1729
|
+
: null;
|
|
1730
|
+
|
|
1731
|
+
if (!playResult || playResult.playing !== true) {
|
|
1732
|
+
params.send({
|
|
1733
|
+
type: "tool_result",
|
|
1734
|
+
callId: playCallId,
|
|
1735
|
+
name: playTool.name,
|
|
1736
|
+
ok: false,
|
|
1737
|
+
result: playOutput ?? { error: "Unable to start playback." },
|
|
1738
|
+
});
|
|
1739
|
+
const reason =
|
|
1740
|
+
typeof playResult?.reason === "string" && playResult.reason.trim()
|
|
1741
|
+
? playResult.reason.trim()
|
|
1742
|
+
: null;
|
|
1743
|
+
params.send({
|
|
1744
|
+
type: "token",
|
|
1745
|
+
value:
|
|
1746
|
+
reason
|
|
1747
|
+
? `I couldn't start that track automatically in Apple Music: ${reason}`
|
|
1748
|
+
: "I couldn't start that track automatically in Apple Music. Please retry once with the exact song title and artist.",
|
|
1749
|
+
});
|
|
1750
|
+
return true;
|
|
1751
|
+
}
|
|
1752
|
+
params.send({
|
|
1753
|
+
type: "tool_result",
|
|
1754
|
+
callId: playCallId,
|
|
1755
|
+
name: playTool.name,
|
|
1756
|
+
ok: true,
|
|
1757
|
+
result: playOutput,
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
const resolvedTitle =
|
|
1761
|
+
typeof playResult.title === "string" && playResult.title.trim()
|
|
1762
|
+
? playResult.title.trim()
|
|
1763
|
+
: intent.title || intent.query;
|
|
1764
|
+
const resolvedArtist =
|
|
1765
|
+
typeof playResult.artist === "string" && playResult.artist.trim()
|
|
1766
|
+
? playResult.artist.trim()
|
|
1767
|
+
: intent.artist;
|
|
1768
|
+
const resolvedPlaylistName =
|
|
1769
|
+
typeof playResult.playlistName === "string" && playResult.playlistName.trim()
|
|
1770
|
+
? playResult.playlistName.trim()
|
|
1771
|
+
: null;
|
|
1772
|
+
const isSpecificSongRequest = Boolean(intent.title || intent.artist);
|
|
1773
|
+
const hasNearbyMatch =
|
|
1774
|
+
playResult.matched === false &&
|
|
1775
|
+
isSpecificSongRequest &&
|
|
1776
|
+
typeof playResult.reason === "string" &&
|
|
1777
|
+
playResult.reason.trim().length > 0;
|
|
1778
|
+
|
|
1779
|
+
if (intent.volume !== null && volumeTool) {
|
|
1780
|
+
const volumeCallId = "fastpath_set_volume";
|
|
1781
|
+
params.send({
|
|
1782
|
+
type: "tool_call",
|
|
1783
|
+
callId: volumeCallId,
|
|
1784
|
+
name: volumeTool.name,
|
|
1785
|
+
args: { level: intent.volume },
|
|
1786
|
+
});
|
|
1787
|
+
params.send({
|
|
1788
|
+
type: "tool_progress",
|
|
1789
|
+
callId: volumeCallId,
|
|
1790
|
+
name: volumeTool.name,
|
|
1791
|
+
status: "started",
|
|
1792
|
+
message: `Setting volume to ${intent.volume}%.`,
|
|
1793
|
+
});
|
|
1794
|
+
try {
|
|
1795
|
+
const volumeOutput = await volumeTool.execute(
|
|
1796
|
+
{ level: intent.volume },
|
|
1797
|
+
{ localTools: params.localTools, signal: params.signal },
|
|
1798
|
+
);
|
|
1799
|
+
params.send({
|
|
1800
|
+
type: "tool_result",
|
|
1801
|
+
callId: volumeCallId,
|
|
1802
|
+
name: volumeTool.name,
|
|
1803
|
+
ok: true,
|
|
1804
|
+
result: volumeOutput,
|
|
1805
|
+
});
|
|
1806
|
+
} catch (error) {
|
|
1807
|
+
const message = formatToolErrorForUser(volumeTool.name, error, "Could not set volume.");
|
|
1808
|
+
params.send({
|
|
1809
|
+
type: "tool_result",
|
|
1810
|
+
callId: volumeCallId,
|
|
1811
|
+
name: volumeTool.name,
|
|
1812
|
+
ok: false,
|
|
1813
|
+
result: { error: message },
|
|
1814
|
+
});
|
|
1815
|
+
params.send({
|
|
1816
|
+
type: "token",
|
|
1817
|
+
value:
|
|
1818
|
+
`Started playback for "${resolvedTitle}", but couldn't set volume: ${message}.`,
|
|
1819
|
+
});
|
|
1820
|
+
return true;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
params.send({
|
|
1825
|
+
type: "token",
|
|
1826
|
+
value:
|
|
1827
|
+
hasNearbyMatch
|
|
1828
|
+
? `Started nearby match "${resolvedTitle}"${resolvedArtist ? ` by ${resolvedArtist}` : ""}. ${playResult.reason}`
|
|
1829
|
+
: resolvedPlaylistName && intent.volume !== null
|
|
1830
|
+
? `**Done.** Playing playlist "${resolvedPlaylistName}" in Apple Music and set volume to ${intent.volume}%.`
|
|
1831
|
+
: resolvedPlaylistName
|
|
1832
|
+
? `**Done.** Playing playlist "${resolvedPlaylistName}" in Apple Music.`
|
|
1833
|
+
: intent.volume !== null
|
|
1834
|
+
? `**Done.** Playing "${resolvedTitle}"${resolvedArtist ? ` by ${resolvedArtist}` : ""} in Apple Music and set volume to ${intent.volume}%.`
|
|
1835
|
+
: `**Done.** Playing "${resolvedTitle}"${resolvedArtist ? ` by ${resolvedArtist}` : ""} in Apple Music.`,
|
|
1836
|
+
});
|
|
1837
|
+
return true;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
async function streamPlainChat(params: {
|
|
1841
|
+
connection: RuntimeConnection;
|
|
1842
|
+
model: string;
|
|
1843
|
+
system?: string;
|
|
1844
|
+
messages: ChatMessageInput[];
|
|
1845
|
+
enableWebSources: boolean;
|
|
1846
|
+
send: (chunk: ChatStreamChunk) => void;
|
|
1847
|
+
signal?: AbortSignal;
|
|
1848
|
+
}) {
|
|
1849
|
+
if (params.connection.kind === "ollama") {
|
|
1850
|
+
await streamOllamaChat({
|
|
1851
|
+
baseUrl: params.connection.baseUrl ?? "http://localhost:11434",
|
|
1852
|
+
model: params.model,
|
|
1853
|
+
messages: params.messages,
|
|
1854
|
+
signal: params.signal,
|
|
1855
|
+
onToken: (value) => emitTokenText(params.send, value),
|
|
1856
|
+
});
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
if (params.connection.kind === "builtin" && params.connection.provider === "anthropic") {
|
|
1861
|
+
if (!params.connection.apiKey) {
|
|
1862
|
+
throw new Error("Missing Anthropic API key for this connection.");
|
|
1863
|
+
}
|
|
1864
|
+
const client = new Anthropic({ apiKey: params.connection.apiKey });
|
|
1865
|
+
const response = await client.messages.stream({
|
|
1866
|
+
model: params.model,
|
|
1867
|
+
max_tokens: 1024,
|
|
1868
|
+
system: params.system,
|
|
1869
|
+
messages: params.messages.map((message) => ({
|
|
1870
|
+
role: message.role === "assistant" ? "assistant" : "user",
|
|
1871
|
+
content: message.content,
|
|
1872
|
+
})),
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
for await (const event of response) {
|
|
1876
|
+
if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
|
|
1877
|
+
emitTokenText(params.send, event.delta.text);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
const openAIBaseURL =
|
|
1884
|
+
params.connection.kind === "openai_compatible"
|
|
1885
|
+
? params.connection.baseUrl
|
|
1886
|
+
: params.connection.kind === "builtin" && params.connection.provider === "google"
|
|
1887
|
+
? params.connection.baseUrl ?? GEMINI_OPENAI_BASE_URL
|
|
1888
|
+
: undefined;
|
|
1889
|
+
const openAIHeaders = headersArrayToRecord(params.connection.headers);
|
|
1890
|
+
const client = new OpenAI({
|
|
1891
|
+
apiKey: params.connection.apiKey ?? "no-key-required",
|
|
1892
|
+
baseURL: openAIBaseURL,
|
|
1893
|
+
defaultHeaders: openAIHeaders,
|
|
1894
|
+
});
|
|
1895
|
+
const useWebSources =
|
|
1896
|
+
params.enableWebSources && supportsWebSourcesViaOpenAI(params.connection);
|
|
1897
|
+
const response = await client.responses.create(
|
|
1898
|
+
{
|
|
1899
|
+
model: params.model,
|
|
1900
|
+
instructions: params.system,
|
|
1901
|
+
input: params.messages.map((message) => ({
|
|
1902
|
+
type: "message",
|
|
1903
|
+
role: message.role,
|
|
1904
|
+
content: message.content,
|
|
1905
|
+
})),
|
|
1906
|
+
tools: useWebSources ? [{ type: "web_search" }] : undefined,
|
|
1907
|
+
tool_choice: useWebSources ? "auto" : undefined,
|
|
1908
|
+
include: useWebSources ? ["web_search_call.action.sources"] : undefined,
|
|
1909
|
+
stream: true,
|
|
1910
|
+
},
|
|
1911
|
+
{ signal: params.signal },
|
|
1912
|
+
);
|
|
1913
|
+
|
|
1914
|
+
const gatheredSources = new Map<string, ProviderCitationSource>();
|
|
1915
|
+
|
|
1916
|
+
for await (const event of response) {
|
|
1917
|
+
const eventAny = event as {
|
|
1918
|
+
type: string;
|
|
1919
|
+
delta?: string;
|
|
1920
|
+
error?: { message?: string };
|
|
1921
|
+
response?: { output?: unknown };
|
|
1922
|
+
annotation?: unknown;
|
|
1923
|
+
};
|
|
1924
|
+
if (eventAny.type === "response.output_text.delta") {
|
|
1925
|
+
emitTokenText(params.send, eventAny.delta ?? "");
|
|
1926
|
+
continue;
|
|
1927
|
+
}
|
|
1928
|
+
if (eventAny.type === "response.output_text.annotation.added") {
|
|
1929
|
+
const source = sourceFromAnnotation(eventAny.annotation);
|
|
1930
|
+
mergeCitationSources(gatheredSources, source ? [source] : undefined);
|
|
1931
|
+
continue;
|
|
1932
|
+
}
|
|
1933
|
+
if (eventAny.type === "response.completed") {
|
|
1934
|
+
mergeCitationSources(
|
|
1935
|
+
gatheredSources,
|
|
1936
|
+
extractCitationSourcesFromResponse(eventAny.response ?? {}),
|
|
1937
|
+
);
|
|
1938
|
+
continue;
|
|
1939
|
+
}
|
|
1940
|
+
if (eventAny.type === "response.error") {
|
|
1941
|
+
params.send({
|
|
1942
|
+
type: "error",
|
|
1943
|
+
error: eventAny.error?.message || "OpenAI-compatible response error",
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
const sources = [...gatheredSources.values()];
|
|
1949
|
+
if (sources.length > 0) {
|
|
1950
|
+
params.send({
|
|
1951
|
+
type: "sources",
|
|
1952
|
+
items: sources,
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
function getToolSession(params: {
|
|
1958
|
+
connection: RuntimeConnection;
|
|
1959
|
+
model: string;
|
|
1960
|
+
system?: string;
|
|
1961
|
+
messages: ChatMessageInput[];
|
|
1962
|
+
localTools: LocalToolsSettings;
|
|
1963
|
+
enableWebSources: boolean;
|
|
1964
|
+
}): {
|
|
1965
|
+
session: ToolProviderSession;
|
|
1966
|
+
tools: ReturnType<typeof createToolRegistry>;
|
|
1967
|
+
} {
|
|
1968
|
+
const tools = createToolRegistry(params.localTools);
|
|
1969
|
+
const schemas = toProviderToolSchemas(tools);
|
|
1970
|
+
const turns = createToolMessages(params.messages, params.system);
|
|
1971
|
+
|
|
1972
|
+
if (params.connection.kind === "builtin" && params.connection.provider === "anthropic") {
|
|
1973
|
+
if (!params.connection.apiKey) {
|
|
1974
|
+
throw new Error("Missing Anthropic API key for this connection.");
|
|
1975
|
+
}
|
|
1976
|
+
return {
|
|
1977
|
+
tools,
|
|
1978
|
+
session: createAnthropicSession({
|
|
1979
|
+
model: params.model,
|
|
1980
|
+
apiKey: params.connection.apiKey,
|
|
1981
|
+
system: params.system,
|
|
1982
|
+
messages: turns,
|
|
1983
|
+
tools: schemas,
|
|
1984
|
+
}),
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
if (params.connection.kind === "ollama") {
|
|
1989
|
+
throw new Error("Ollama tool sessions are not supported in this build.");
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
const baseURL =
|
|
1993
|
+
params.connection.kind === "openai_compatible"
|
|
1994
|
+
? params.connection.baseUrl
|
|
1995
|
+
: params.connection.kind === "builtin" && params.connection.provider === "google"
|
|
1996
|
+
? params.connection.baseUrl ?? GEMINI_OPENAI_BASE_URL
|
|
1997
|
+
: undefined;
|
|
1998
|
+
const defaultHeaders = headersArrayToRecord(params.connection.headers);
|
|
1999
|
+
if (params.connection.kind === "openai_compatible") {
|
|
2000
|
+
if (!baseURL) {
|
|
2001
|
+
throw new Error("OpenAI-compatible connection is missing baseUrl.");
|
|
2002
|
+
}
|
|
2003
|
+
return {
|
|
2004
|
+
tools,
|
|
2005
|
+
session: createOpenAICompatibleSession({
|
|
2006
|
+
model: params.model,
|
|
2007
|
+
apiKey: params.connection.apiKey ?? "no-key-required",
|
|
2008
|
+
system: params.system,
|
|
2009
|
+
messages: turns,
|
|
2010
|
+
tools: schemas,
|
|
2011
|
+
enableWebSources: params.enableWebSources,
|
|
2012
|
+
baseURL,
|
|
2013
|
+
defaultHeaders,
|
|
2014
|
+
}),
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
return {
|
|
2019
|
+
tools,
|
|
2020
|
+
session: createOpenAISession({
|
|
2021
|
+
model: params.model,
|
|
2022
|
+
apiKey: params.connection.apiKey ?? "no-key-required",
|
|
2023
|
+
system: params.system,
|
|
2024
|
+
messages: turns,
|
|
2025
|
+
tools: schemas,
|
|
2026
|
+
enableWebSources: params.enableWebSources,
|
|
2027
|
+
baseURL,
|
|
2028
|
+
defaultHeaders,
|
|
2029
|
+
}),
|
|
2030
|
+
};
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
async function runToolOrchestrator(params: {
|
|
2034
|
+
connection: RuntimeConnection;
|
|
2035
|
+
model: string;
|
|
2036
|
+
system?: string;
|
|
2037
|
+
messages: ChatMessageInput[];
|
|
2038
|
+
localTools: LocalToolsSettings;
|
|
2039
|
+
enableWebSources: boolean;
|
|
2040
|
+
send: (chunk: ChatStreamChunk) => void;
|
|
2041
|
+
signal?: AbortSignal;
|
|
2042
|
+
}) {
|
|
2043
|
+
const { tools, session } = getToolSession({
|
|
2044
|
+
connection: params.connection,
|
|
2045
|
+
model: params.model,
|
|
2046
|
+
system: buildToolSystemPrompt(params.system, params.messages),
|
|
2047
|
+
messages: params.messages,
|
|
2048
|
+
localTools: params.localTools,
|
|
2049
|
+
enableWebSources: params.enableWebSources,
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
if (tools.length === 0) {
|
|
2053
|
+
params.send({
|
|
2054
|
+
type: "tool_progress",
|
|
2055
|
+
callId: "tooling",
|
|
2056
|
+
name: "tooling",
|
|
2057
|
+
status: "warning",
|
|
2058
|
+
message: "Local tools are enabled but no tool categories are active in settings.",
|
|
2059
|
+
});
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
let emittedAssistantText = false;
|
|
2064
|
+
let sawSuccessfulToolResult = false;
|
|
2065
|
+
const gatheredSources = new Map<string, ProviderCitationSource>();
|
|
2066
|
+
const emitSourcesIfPresent = () => {
|
|
2067
|
+
const sources = [...gatheredSources.values()];
|
|
2068
|
+
if (sources.length > 0) {
|
|
2069
|
+
params.send({
|
|
2070
|
+
type: "sources",
|
|
2071
|
+
items: sources,
|
|
2072
|
+
});
|
|
2073
|
+
}
|
|
2074
|
+
};
|
|
2075
|
+
const emitThinkingUpdate = (summary: string) => {
|
|
2076
|
+
emitThinking(params.send, "update", summary);
|
|
2077
|
+
};
|
|
2078
|
+
|
|
2079
|
+
for (let stepIndex = 0; stepIndex < MAX_TOOL_STEPS; stepIndex += 1) {
|
|
2080
|
+
if (params.signal?.aborted) {
|
|
2081
|
+
throw new Error("Request aborted.");
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
emitThinkingUpdate(
|
|
2085
|
+
stepIndex === 0
|
|
2086
|
+
? "Planning your request and choosing the next action."
|
|
2087
|
+
: "Reviewing results and deciding the next step.",
|
|
2088
|
+
);
|
|
2089
|
+
const step = await session.step(params.signal);
|
|
2090
|
+
mergeCitationSources(gatheredSources, step.sources);
|
|
2091
|
+
|
|
2092
|
+
const modelRefusalLikeText =
|
|
2093
|
+
params.localTools.enabled &&
|
|
2094
|
+
/\b(can(?:not|'t)|unable|don't have)\b[\s\S]{0,80}\b(control|access|create|set|play|open)\b[\s\S]{0,80}\b(device|here|from here)\b/i.test(
|
|
2095
|
+
step.text,
|
|
2096
|
+
);
|
|
2097
|
+
const shouldEmitText = !modelRefusalLikeText;
|
|
2098
|
+
|
|
2099
|
+
if (shouldEmitText) {
|
|
2100
|
+
emitTokenText(params.send, step.text, (token) => {
|
|
2101
|
+
if (!emittedAssistantText && token.trim()) {
|
|
2102
|
+
emittedAssistantText = true;
|
|
2103
|
+
}
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
if (step.toolCalls.length === 0) {
|
|
2108
|
+
if (modelRefusalLikeText && !sawSuccessfulToolResult) {
|
|
2109
|
+
params.send({
|
|
2110
|
+
type: "token",
|
|
2111
|
+
value:
|
|
2112
|
+
"I hit a local-tool planning issue on that request. Please repeat the action once and I will execute it directly.",
|
|
2113
|
+
});
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
if (sawSuccessfulToolResult && !emittedAssistantText) {
|
|
2117
|
+
params.send({ type: "token", value: "**Done.** I completed that request." });
|
|
2118
|
+
}
|
|
2119
|
+
emitSourcesIfPresent();
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
emitThinkingUpdate("Executing requested actions.");
|
|
2124
|
+
for (const call of step.toolCalls) {
|
|
2125
|
+
let callInput = call.input;
|
|
2126
|
+
if (call.name === "reminder_create") {
|
|
2127
|
+
const titleValue = call.input?.title;
|
|
2128
|
+
if (typeof titleValue === "string" && titleValue.trim()) {
|
|
2129
|
+
callInput = {
|
|
2130
|
+
...call.input,
|
|
2131
|
+
title: sanitizeReminderTitle(titleValue) || "Reminder",
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
params.send({ type: "tool_call", callId: call.id, name: call.name, args: callInput });
|
|
2137
|
+
|
|
2138
|
+
const tool = findToolByName(tools, call.name);
|
|
2139
|
+
if (!tool) {
|
|
2140
|
+
const message = `Unknown tool requested by model: ${call.name}`;
|
|
2141
|
+
params.send({
|
|
2142
|
+
type: "tool_result",
|
|
2143
|
+
callId: call.id,
|
|
2144
|
+
name: call.name,
|
|
2145
|
+
ok: false,
|
|
2146
|
+
result: { error: message },
|
|
2147
|
+
});
|
|
2148
|
+
session.addToolResult({
|
|
2149
|
+
callId: call.id,
|
|
2150
|
+
name: call.name,
|
|
2151
|
+
result: message,
|
|
2152
|
+
isError: true,
|
|
2153
|
+
});
|
|
2154
|
+
continue;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
if (requiresApprovalForRisk(tool.risk, params.localTools.approvalMode)) {
|
|
2158
|
+
const approval = createApprovalRequest();
|
|
2159
|
+
params.send({
|
|
2160
|
+
type: "approval_requested",
|
|
2161
|
+
approvalId: approval.approvalId,
|
|
2162
|
+
callId: call.id,
|
|
2163
|
+
name: call.name,
|
|
2164
|
+
args: callInput,
|
|
2165
|
+
reason: summarizeApprovalReason(call.name, callInput),
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
emitThinkingUpdate(`Waiting for approval to run ${call.name}.`);
|
|
2169
|
+
const decision = await approval.promise;
|
|
2170
|
+
params.send({
|
|
2171
|
+
type: "approval_resolved",
|
|
2172
|
+
approvalId: approval.approvalId,
|
|
2173
|
+
callId: call.id,
|
|
2174
|
+
decision,
|
|
2175
|
+
message:
|
|
2176
|
+
decision === "approve"
|
|
2177
|
+
? "Approved"
|
|
2178
|
+
: decision === "timeout"
|
|
2179
|
+
? "Timed out"
|
|
2180
|
+
: "Denied",
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
if (decision !== "approve") {
|
|
2184
|
+
const message =
|
|
2185
|
+
decision === "timeout"
|
|
2186
|
+
? "Tool call timed out waiting for approval."
|
|
2187
|
+
: "Tool call denied by user.";
|
|
2188
|
+
params.send({
|
|
2189
|
+
type: "tool_result",
|
|
2190
|
+
callId: call.id,
|
|
2191
|
+
name: call.name,
|
|
2192
|
+
ok: false,
|
|
2193
|
+
result: { error: message },
|
|
2194
|
+
});
|
|
2195
|
+
session.addToolResult({
|
|
2196
|
+
callId: call.id,
|
|
2197
|
+
name: call.name,
|
|
2198
|
+
result: message,
|
|
2199
|
+
isError: true,
|
|
2200
|
+
});
|
|
2201
|
+
continue;
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
params.send({
|
|
2206
|
+
type: "tool_progress",
|
|
2207
|
+
callId: call.id,
|
|
2208
|
+
name: call.name,
|
|
2209
|
+
status: "started",
|
|
2210
|
+
message: `Executing ${call.name}...`,
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
try {
|
|
2214
|
+
const output = await tool.execute(callInput, {
|
|
2215
|
+
localTools: params.localTools,
|
|
2216
|
+
signal: params.signal,
|
|
2217
|
+
});
|
|
2218
|
+
if (callInput !== call.input && call.name === "reminder_create") {
|
|
2219
|
+
const outputWithSanitizedTitle =
|
|
2220
|
+
output && typeof output === "object"
|
|
2221
|
+
? { ...(output as Record<string, unknown>), title: (callInput.title as string) || "Reminder" }
|
|
2222
|
+
: output;
|
|
2223
|
+
params.send({
|
|
2224
|
+
type: "tool_result",
|
|
2225
|
+
callId: call.id,
|
|
2226
|
+
name: call.name,
|
|
2227
|
+
ok: true,
|
|
2228
|
+
result: outputWithSanitizedTitle,
|
|
2229
|
+
});
|
|
2230
|
+
sawSuccessfulToolResult = true;
|
|
2231
|
+
session.addToolResult({
|
|
2232
|
+
callId: call.id,
|
|
2233
|
+
name: call.name,
|
|
2234
|
+
result: serializeToolOutput(outputWithSanitizedTitle),
|
|
2235
|
+
});
|
|
2236
|
+
continue;
|
|
2237
|
+
}
|
|
2238
|
+
params.send({
|
|
2239
|
+
type: "tool_result",
|
|
2240
|
+
callId: call.id,
|
|
2241
|
+
name: call.name,
|
|
2242
|
+
ok: true,
|
|
2243
|
+
result: output,
|
|
2244
|
+
});
|
|
2245
|
+
sawSuccessfulToolResult = true;
|
|
2246
|
+
mergeCitationSources(gatheredSources, extractSourcesFromToolOutput(call.name, output));
|
|
2247
|
+
session.addToolResult({
|
|
2248
|
+
callId: call.id,
|
|
2249
|
+
name: call.name,
|
|
2250
|
+
result: serializeToolOutput(output),
|
|
2251
|
+
});
|
|
2252
|
+
emitThinkingUpdate("Tool finished. Updating the response.");
|
|
2253
|
+
} catch (error) {
|
|
2254
|
+
const message = formatToolErrorForUser(
|
|
2255
|
+
call.name,
|
|
2256
|
+
error,
|
|
2257
|
+
"Unknown tool execution error.",
|
|
2258
|
+
);
|
|
2259
|
+
params.send({
|
|
2260
|
+
type: "tool_result",
|
|
2261
|
+
callId: call.id,
|
|
2262
|
+
name: call.name,
|
|
2263
|
+
ok: false,
|
|
2264
|
+
result: { error: message },
|
|
2265
|
+
});
|
|
2266
|
+
session.addToolResult({
|
|
2267
|
+
callId: call.id,
|
|
2268
|
+
name: call.name,
|
|
2269
|
+
result: message,
|
|
2270
|
+
isError: true,
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
if (sawSuccessfulToolResult && !emittedAssistantText) {
|
|
2277
|
+
params.send({ type: "token", value: "**Done.** I completed that request." });
|
|
2278
|
+
emitSourcesIfPresent();
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
params.send({
|
|
2282
|
+
type: "token",
|
|
2283
|
+
value: "I hit an internal tool limit before finishing cleanly. I can continue from here with one more step.",
|
|
2284
|
+
});
|
|
2285
|
+
emitSourcesIfPresent();
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
export async function POST(request: Request) {
|
|
2289
|
+
const body = (await request.json()) as ChatRequest;
|
|
2290
|
+
const { model, messages, system } = body;
|
|
2291
|
+
const connection = resolveRuntimeConnection(body);
|
|
2292
|
+
|
|
2293
|
+
if (!connection) {
|
|
2294
|
+
return new Response(JSON.stringify({ error: "Missing connection." }), {
|
|
2295
|
+
status: 400,
|
|
2296
|
+
headers: { "Content-Type": "application/json" },
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
if (requiresApiKey(connection) && !connection.apiKey) {
|
|
2300
|
+
return new Response(JSON.stringify({ error: "Missing API key for selected connection." }), {
|
|
2301
|
+
status: 400,
|
|
2302
|
+
headers: { "Content-Type": "application/json" },
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
if (typeof model !== "string" || !model.trim()) {
|
|
2306
|
+
return new Response(JSON.stringify({ error: "Missing model." }), {
|
|
2307
|
+
status: 400,
|
|
2308
|
+
headers: { "Content-Type": "application/json" },
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
if (!Array.isArray(messages)) {
|
|
2312
|
+
return new Response(JSON.stringify({ error: "Missing chat messages." }), {
|
|
2313
|
+
status: 400,
|
|
2314
|
+
headers: { "Content-Type": "application/json" },
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
const sanitizedMessages = sanitizeChatMessages(messages);
|
|
2318
|
+
if (sanitizedMessages.length === 0) {
|
|
2319
|
+
return new Response(JSON.stringify({ error: "Missing non-empty chat messages." }), {
|
|
2320
|
+
status: 400,
|
|
2321
|
+
headers: { "Content-Type": "application/json" },
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
const webSourcesEnabled = normalizeWebSourcesEnabled(body.enableWebSources);
|
|
2326
|
+
const enableWebSourcesForConnection =
|
|
2327
|
+
webSourcesEnabled && supportsWebSourcesViaOpenAI(connection);
|
|
2328
|
+
const localToolsRequested = normalizeLocalTools(body.localTools);
|
|
2329
|
+
const memoryContext = sanitizeMemoryContext(body.memory);
|
|
2330
|
+
const encoder = new TextEncoder();
|
|
2331
|
+
|
|
2332
|
+
const stream = new ReadableStream({
|
|
2333
|
+
async start(controller) {
|
|
2334
|
+
const send = (chunk: ChatStreamChunk) => {
|
|
2335
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
|
2336
|
+
};
|
|
2337
|
+
const sendThinking = (
|
|
2338
|
+
status: "started" | "update" | "done",
|
|
2339
|
+
summary?: string,
|
|
2340
|
+
) => emitThinking(send, status, summary);
|
|
2341
|
+
|
|
2342
|
+
let sentDone = false;
|
|
2343
|
+
const sendDone = () => {
|
|
2344
|
+
if (sentDone) {
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
send({ type: "done" });
|
|
2348
|
+
sentDone = true;
|
|
2349
|
+
};
|
|
2350
|
+
|
|
2351
|
+
try {
|
|
2352
|
+
sendThinking("started", "Understanding your request.");
|
|
2353
|
+
const toolsSupported = connection.supportsTools !== false;
|
|
2354
|
+
const localTools = toolsSupported
|
|
2355
|
+
? localToolsRequested
|
|
2356
|
+
: { ...localToolsRequested, enabled: false };
|
|
2357
|
+
|
|
2358
|
+
if (localToolsRequested.enabled && !toolsSupported) {
|
|
2359
|
+
send({
|
|
2360
|
+
type: "tool_progress",
|
|
2361
|
+
callId: "tooling",
|
|
2362
|
+
name: "tooling",
|
|
2363
|
+
status: "warning",
|
|
2364
|
+
message: `Local tools are disabled for "${connection.name}" (connection does not support tool calls). Continuing with plain chat.`,
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
if (localTools.enabled) {
|
|
2369
|
+
const routeThroughTools = shouldRouteThroughToolOrchestrator(sanitizedMessages);
|
|
2370
|
+
if (routeThroughTools) {
|
|
2371
|
+
sendThinking("update", "Checking whether this can run through a direct action path.");
|
|
2372
|
+
let fastHandled = false;
|
|
2373
|
+
try {
|
|
2374
|
+
fastHandled = await tryDirectAutomationFastPath({
|
|
2375
|
+
localTools,
|
|
2376
|
+
messages: sanitizedMessages,
|
|
2377
|
+
memory: memoryContext,
|
|
2378
|
+
send,
|
|
2379
|
+
signal: request.signal,
|
|
2380
|
+
});
|
|
2381
|
+
} catch (error) {
|
|
2382
|
+
const message =
|
|
2383
|
+
error instanceof Error ? truncateMessage(error.message) : "Fast-path unavailable.";
|
|
2384
|
+
send({
|
|
2385
|
+
type: "tool_progress",
|
|
2386
|
+
callId: "tooling",
|
|
2387
|
+
name: "tooling",
|
|
2388
|
+
status: "warning",
|
|
2389
|
+
message: `Direct automation fast-path failed: ${message}. Continuing with tool orchestration.`,
|
|
2390
|
+
});
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
if (fastHandled) {
|
|
2394
|
+
sendThinking("done");
|
|
2395
|
+
sendDone();
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
sendThinking("update", "Planning tool actions and preparing the response.");
|
|
2400
|
+
await runToolOrchestrator({
|
|
2401
|
+
connection,
|
|
2402
|
+
model,
|
|
2403
|
+
system,
|
|
2404
|
+
messages: sanitizedMessages,
|
|
2405
|
+
localTools,
|
|
2406
|
+
enableWebSources: enableWebSourcesForConnection,
|
|
2407
|
+
send,
|
|
2408
|
+
signal: request.signal,
|
|
2409
|
+
});
|
|
2410
|
+
sendThinking("done");
|
|
2411
|
+
sendDone();
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
sendThinking("update", "Generating response.");
|
|
2417
|
+
await streamPlainChat({
|
|
2418
|
+
connection,
|
|
2419
|
+
model,
|
|
2420
|
+
system,
|
|
2421
|
+
messages: sanitizedMessages,
|
|
2422
|
+
enableWebSources: enableWebSourcesForConnection,
|
|
2423
|
+
send,
|
|
2424
|
+
signal: request.signal,
|
|
2425
|
+
});
|
|
2426
|
+
sendThinking("done");
|
|
2427
|
+
} catch (error) {
|
|
2428
|
+
const message = error instanceof Error ? truncateMessage(error.message) : "Unexpected error";
|
|
2429
|
+
send({ type: "error", error: message });
|
|
2430
|
+
sendThinking("done");
|
|
2431
|
+
} finally {
|
|
2432
|
+
sendDone();
|
|
2433
|
+
controller.close();
|
|
2434
|
+
}
|
|
2435
|
+
},
|
|
2436
|
+
});
|
|
2437
|
+
|
|
2438
|
+
return new Response(stream, {
|
|
2439
|
+
headers: {
|
|
2440
|
+
"Content-Type": "text/event-stream",
|
|
2441
|
+
"Cache-Control": "no-cache, no-transform",
|
|
2442
|
+
Connection: "keep-alive",
|
|
2443
|
+
},
|
|
2444
|
+
});
|
|
2445
|
+
}
|