iris-chatbot 5.2.0 → 5.3.1

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.
@@ -44,6 +44,8 @@ const LOCAL_TOOL_SYSTEM_INSTRUCTIONS = [
44
44
  "Calendar: You have direct access to the user's Apple Calendar via calendar_list_events and related tools. You CAN see their events—call the tools; do not say you cannot access or see their calendar. Never ask the user to paste their calendar, export it, or send a screenshot; you already have access. When they ask what's on the calendar, what's happening this/next weekend/month, or say 'check' or 'you can see', call calendar_list_events immediately with from/to for the range (e.g. next month → from: 'today', to: 'end of next month'; specific day → YYYY-MM-DD for both). Always pass calendar: 'all'. from/to support 'today', 'tomorrow', 'next week', 'end of next week', 'next month', 'end of next month'. Report the exact queried range from the tool result. For move/delete/reschedule: call calendar_list_events first to get uids. For rescheduling to a new time on the same calendar use calendar_update_event (preserves recurrence); for delete use calendar_delete_event; use calendar_move_event only when update is not sufficient (e.g. user says move and you need delete+recreate). When the user says to add or put something on their calendar (e.g. 'Add 1:1 with Alex for Thursday at 9am to my calendar', 'Put team standup on Friday at 10', 'Schedule meeting with John next Monday at 2pm'), use calendar_create_event with title = only the event name (e.g. '1:1 with Alex', 'team standup', 'meeting with John') and start = the date/time phrase (e.g. 'Thursday at 9am', 'Friday at 10', 'next Monday at 2pm'). Do not put the date/time in the title.",
45
45
  "Files and Folders: You HAVE direct access to the user's files via file_list and file_find. Never say you cannot see, access, or list their files—call the tools. When the user asks 'what files do I have,' 'files from the last 7 days,' 'recent files,' or similar: call file_list immediately with path set to a folder (e.g. ~/Downloads, ~/Desktop, ~/Documents—or call once per root if you want all) and modifiedInLastDays: 7 (or the number of days they said). You have full file system access via file_list, file_mkdir, file_move, file_copy, file_delete_to_trash, file_batch_move, and file_find. When the user gives a folder or file name (e.g. 'us debt clock'), use file_find with searchPath '~' and the name as given. When moving MULTIPLE files, use file_batch_move with { operations: [ ... ] } or { destination, sources }. For organizing: file_find if location unknown, then file_list (use modifiedInLastDays for 'recent'), then file_mkdir/file_batch_move as needed. Never refuse file tasks or ask the user to use Finder manually—you have the tools.",
46
46
  "For iMessage/text requests, use messages_send; if contact matching is ambiguous, ask one concise clarification with candidate names.",
47
+ "For requests to write, draft, compose, or send an email/message to someone, call mail_send or messages_send so the user can review/edit in the pre-send draft template when enabled.",
48
+ "When drafting email or message content, write complete, natural prose tailored to the request. Avoid placeholder text unless the user explicitly asks for a template with placeholders.",
47
49
  "For media requests, assume Apple Music unless the user specifies otherwise.",
48
50
  "For note creation, write structured markdown with clear headings, bolded key labels, and tables when they improve clarity.",
49
51
  "If the user requests multiple actions in one prompt, execute every requested action sequentially before your final response.",
@@ -117,16 +119,50 @@ function buildDetectedActionHint(messages: ChatMessageInput[]): string | null {
117
119
  return `Detected user actions:\n${lines.join("\n")}\nExecute each action in order and confirm each result.`;
118
120
  }
119
121
 
120
- function buildToolSystemPrompt(system: string | undefined, messages: ChatMessageInput[]): string {
122
+ type DraftEditPromptContext = {
123
+ toolName: "mail_send" | "messages_send";
124
+ to: string[];
125
+ cc: string[];
126
+ subject: string;
127
+ body: string;
128
+ };
129
+
130
+ function buildDraftEditPromptContextMessage(context: DraftEditPromptContext): string {
131
+ const lines = [
132
+ "You are editing an existing pre-send draft.",
133
+ `Channel: ${context.toolName === "mail_send" ? "email" : "message"}.`,
134
+ `Current To: ${context.to.join(", ") || "(none)"}`,
135
+ ];
136
+ if (context.toolName === "mail_send") {
137
+ lines.push(`Current Cc: ${context.cc.join(", ") || "(none)"}`);
138
+ lines.push(`Current Subject: ${context.subject || "(none)"}`);
139
+ }
140
+ lines.push("Current Body:");
141
+ lines.push(context.body || "(empty)");
142
+ lines.push("");
143
+ lines.push("Apply the user's latest instruction to this draft.");
144
+ lines.push("Keep recipients/channel unchanged unless user explicitly requests changes.");
145
+ lines.push("Use mail_send or messages_send with updated args so the draft template refreshes.");
146
+ return lines.join("\n");
147
+ }
148
+
149
+ function buildToolSystemPrompt(
150
+ system: string | undefined,
151
+ messages: ChatMessageInput[],
152
+ draftEditContext?: DraftEditPromptContext,
153
+ ): string {
121
154
  const base = system?.trim();
122
155
  const actionHint = buildDetectedActionHint(messages);
156
+ const draftEditHint = draftEditContext ? buildDraftEditPromptContextMessage(draftEditContext) : null;
157
+ const extraBlocks = [actionHint, draftEditHint].filter(Boolean).join("\n\n");
158
+
123
159
  if (!base) {
124
- return actionHint
125
- ? `${LOCAL_TOOL_SYSTEM_INSTRUCTIONS}\n\n${actionHint}`
160
+ return extraBlocks
161
+ ? `${LOCAL_TOOL_SYSTEM_INSTRUCTIONS}\n\n${extraBlocks}`
126
162
  : LOCAL_TOOL_SYSTEM_INSTRUCTIONS;
127
163
  }
128
- return actionHint
129
- ? `${base}\n\n${LOCAL_TOOL_SYSTEM_INSTRUCTIONS}\n\n${actionHint}`
164
+ return extraBlocks
165
+ ? `${base}\n\n${LOCAL_TOOL_SYSTEM_INSTRUCTIONS}\n\n${extraBlocks}`
130
166
  : `${base}\n\n${LOCAL_TOOL_SYSTEM_INSTRUCTIONS}`;
131
167
  }
132
168
 
@@ -210,6 +246,68 @@ type RuntimeConnection = {
210
246
  supportsTools: boolean;
211
247
  };
212
248
 
249
+ type NormalizedRequestMeta = {
250
+ threadId?: string;
251
+ conversationId?: string;
252
+ assistantMessageId?: string;
253
+ forceToolRouting: boolean;
254
+ draftEditContext?: DraftEditPromptContext;
255
+ };
256
+
257
+ function sanitizeChatRequestMeta(meta: ChatRequest["meta"] | undefined): NormalizedRequestMeta {
258
+ const threadId = typeof meta?.threadId === "string" && meta.threadId.trim()
259
+ ? meta.threadId.trim()
260
+ : undefined;
261
+ const conversationId = typeof meta?.conversationId === "string" && meta.conversationId.trim()
262
+ ? meta.conversationId.trim()
263
+ : undefined;
264
+ const assistantMessageId = typeof meta?.assistantMessageId === "string" && meta.assistantMessageId.trim()
265
+ ? meta.assistantMessageId.trim()
266
+ : undefined;
267
+ const forceToolRouting = meta?.forceToolRouting === true;
268
+
269
+ let draftEditContext: DraftEditPromptContext | undefined;
270
+ const rawDraft = meta?.draftEditContext;
271
+ if (rawDraft && typeof rawDraft === "object" && !Array.isArray(rawDraft)) {
272
+ const record = rawDraft as {
273
+ toolName?: unknown;
274
+ to?: unknown;
275
+ cc?: unknown;
276
+ subject?: unknown;
277
+ body?: unknown;
278
+ };
279
+ const toolName =
280
+ record.toolName === "mail_send" || record.toolName === "messages_send"
281
+ ? record.toolName
282
+ : null;
283
+ const to = Array.isArray(record.to)
284
+ ? record.to.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
285
+ : [];
286
+ const cc = Array.isArray(record.cc)
287
+ ? record.cc.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
288
+ : [];
289
+ const subject = typeof record.subject === "string" ? record.subject : "";
290
+ const body = typeof record.body === "string" ? record.body : "";
291
+ if (toolName && to.length > 0 && body.trim().length > 0) {
292
+ draftEditContext = {
293
+ toolName,
294
+ to,
295
+ cc,
296
+ subject,
297
+ body,
298
+ };
299
+ }
300
+ }
301
+
302
+ return {
303
+ threadId,
304
+ conversationId,
305
+ assistantMessageId,
306
+ forceToolRouting,
307
+ draftEditContext,
308
+ };
309
+ }
310
+
213
311
  function headersArrayToRecord(headers: RuntimeConnection["headers"]): Record<string, string> | undefined {
214
312
  if (!Array.isArray(headers) || headers.length === 0) {
215
313
  return undefined;
@@ -311,6 +409,10 @@ function normalizeLocalTools(settings: LocalToolsSettings | undefined): LocalToo
311
409
  enabled: Boolean(settings.enabled),
312
410
  approvalMode: settings.approvalMode ?? DEFAULT_LOCAL_TOOLS_SETTINGS.approvalMode,
313
411
  safetyProfile: settings.safetyProfile ?? DEFAULT_LOCAL_TOOLS_SETTINGS.safetyProfile,
412
+ preSendDraftReview:
413
+ typeof settings.preSendDraftReview === "boolean"
414
+ ? settings.preSendDraftReview
415
+ : DEFAULT_LOCAL_TOOLS_SETTINGS.preSendDraftReview,
314
416
  allowedRoots:
315
417
  Array.isArray(settings.allowedRoots) && settings.allowedRoots.length > 0
316
418
  ? settings.allowedRoots
@@ -363,6 +465,7 @@ function normalizeLocalTools(settings: LocalToolsSettings | undefined): LocalToo
363
465
  normalized.enabled === true &&
364
466
  normalized.approvalMode === "always_confirm_writes" &&
365
467
  normalized.safetyProfile === "balanced" &&
468
+ normalized.preSendDraftReview === DEFAULT_LOCAL_TOOLS_SETTINGS.preSendDraftReview &&
366
469
  normalized.allowedRoots.length === DEFAULT_LOCAL_TOOLS_SETTINGS.allowedRoots.length &&
367
470
  normalized.allowedRoots.every(
368
471
  (value, index) => value === DEFAULT_LOCAL_TOOLS_SETTINGS.allowedRoots[index],
@@ -535,10 +638,103 @@ function normalizeWebSourcesEnabled(value: boolean | undefined): boolean {
535
638
  return value !== false;
536
639
  }
537
640
 
641
+ type WebSearchMode = "off" | "auto" | "required";
642
+
538
643
  function supportsWebSourcesViaOpenAI(connection: RuntimeConnection): boolean {
539
644
  return connection.kind === "builtin" && connection.provider === "openai";
540
645
  }
541
646
 
647
+ function getLastUserMessageText(messages: ChatMessageInput[]): string {
648
+ return [...messages].reverse().find((message) => message.role === "user")?.content?.trim() ?? "";
649
+ }
650
+
651
+ function decideWebSearchMode(params: {
652
+ enabled: boolean;
653
+ connection: RuntimeConnection;
654
+ messages: ChatMessageInput[];
655
+ }): WebSearchMode {
656
+ if (!params.enabled || !supportsWebSourcesViaOpenAI(params.connection)) {
657
+ return "off";
658
+ }
659
+
660
+ const text = getLastUserMessageText(params.messages).replace(/\s+/g, " ").trim().toLowerCase();
661
+ if (!text) {
662
+ return "auto";
663
+ }
664
+
665
+ const disableSearchRequested =
666
+ /\b(?:no|without|skip)\s+(?:web\s+)?(?:search|browsing|browse|lookup|look up)\b/.test(text) ||
667
+ /\b(?:don't|do not)\s+(?:web\s+)?(?:search|browse|look up|lookup)\b/.test(text) ||
668
+ /\boffline only\b/.test(text);
669
+ if (disableSearchRequested) {
670
+ return "off";
671
+ }
672
+
673
+ const explicitSearchRequested =
674
+ /\bsearch(?:\s+the)?\s+web\b/.test(text) ||
675
+ /\blook(?:\s+it)?\s+up\b/.test(text) ||
676
+ /\bcheck(?:\s+it)?\s+online\b/.test(text) ||
677
+ /\bverify(?:\s+it)?\s+online\b/.test(text) ||
678
+ /\bbrowse(?:\s+the)?\s+web\b/.test(text) ||
679
+ /\bgoogle\s+(?:it|this)\b/.test(text);
680
+ if (explicitSearchRequested) {
681
+ return "required";
682
+ }
683
+
684
+ const asksForSources =
685
+ /\b(?:source|sources|cite|citation|citations|reference|references|link|links)\b/.test(text);
686
+ const freshnessSignals =
687
+ /\b(?:latest|current|currently|today|right now|as of|recent|recently|up[- ]to[- ]date|breaking)\b/.test(
688
+ text,
689
+ );
690
+ const volatileDomainSignals =
691
+ /\b(?:price|pricing|cost|fee|subscription|per token|rate limit|stock|share price|market cap|forecast|weather|score|standings|odds|election|poll)\b/.test(
692
+ text,
693
+ );
694
+ const leadershipSignals =
695
+ /\b(?:who is|current)\s+(?:the\s+)?(?:ceo|president|prime minister|governor|mayor)\b/.test(text);
696
+ const metricSignals =
697
+ /\b(?:average|median|how many|number of|count|rate|percentage|percent|statistics?|stats?)\b/.test(
698
+ text,
699
+ );
700
+ const metricEntitySignals =
701
+ /\b(?:users?|people|prompts?|requests?|queries?|api|model|gpt|traffic|revenue|downloads?|adoption|daily|weekly|monthly|yearly|per day|per week|per month)\b/.test(
702
+ text,
703
+ );
704
+
705
+ if (
706
+ asksForSources ||
707
+ freshnessSignals ||
708
+ volatileDomainSignals ||
709
+ leadershipSignals ||
710
+ (metricSignals && metricEntitySignals)
711
+ ) {
712
+ return "required";
713
+ }
714
+
715
+ const likelyFactualQuestion =
716
+ text.includes("?") || /^(?:what|who|when|where|which|how)\b/.test(text);
717
+ const uncertaintySignals =
718
+ /\b(?:not sure|unsure|uncertain|verify|double-check|confirm|accurate|accuracy)\b/.test(text);
719
+ if (likelyFactualQuestion && uncertaintySignals) {
720
+ return "auto";
721
+ }
722
+
723
+ return "off";
724
+ }
725
+
726
+ function withWebSearchGuidance(system: string | undefined, mode: WebSearchMode): string | undefined {
727
+ const trimmed = system?.trim() || "";
728
+ if (mode === "off") {
729
+ return trimmed || undefined;
730
+ }
731
+ const guidance =
732
+ mode === "required"
733
+ ? "Web search is required for this turn. Use web_search before answering and cite the sources you relied on."
734
+ : "Use web_search when the request depends on current or uncertain facts (pricing, statistics, recency). For stable/general knowledge, answer directly without web_search.";
735
+ return trimmed ? `${trimmed}\n\n${guidance}` : guidance;
736
+ }
737
+
542
738
  function mergeCitationSources(
543
739
  target: Map<string, ProviderCitationSource>,
544
740
  sources: ProviderCitationSource[] | undefined,
@@ -731,6 +927,10 @@ function summarizeApprovalReason(toolName: string, args: Record<string, unknown>
731
927
  return `Approval required for ${toolName}.`;
732
928
  }
733
929
 
930
+ function isPreSendDraftTool(toolName: string): boolean {
931
+ return toolName === "mail_send" || toolName === "messages_send";
932
+ }
933
+
734
934
  function normalizeAliasText(input: string): string {
735
935
  return input
736
936
  .toLowerCase()
@@ -994,6 +1194,452 @@ function parseDirectMessageIntent(params: {
994
1194
  };
995
1195
  }
996
1196
 
1197
+ type DraftCommunicationIntent = {
1198
+ intentType: "explicit_draft_request" | "send_request";
1199
+ toolName: "mail_send" | "messages_send";
1200
+ args: Record<string, unknown>;
1201
+ };
1202
+
1203
+ type DraftHintKind = "body" | "topic";
1204
+ type DraftCommunicationIntentType = "explicit_draft_request" | "send_request" | "draft_edit_request";
1205
+
1206
+ function isLikelyDraftEditInstruction(input: string): boolean {
1207
+ const text = input.replace(/\s+/g, " ").trim();
1208
+ if (!text) {
1209
+ return false;
1210
+ }
1211
+
1212
+ if (
1213
+ /\b(?:send|write|draft|compose|create)\b[\s\S]{0,32}\b(?:email|mail|message|text)\b[\s\S]{0,40}\b(?:to|for)\b/i
1214
+ .test(text)
1215
+ ) {
1216
+ return false;
1217
+ }
1218
+
1219
+ const editVerb = /\b(edit|rewrite|change|update|improve|polish|adjust|expand|shorten|lengthen|make)\b/i.test(text);
1220
+ const draftField = /\b(email|mail|message|text|draft|subject|body)\b/i.test(text);
1221
+ const toneOrLength =
1222
+ /\b(longer|shorter|more formal|less formal|more casual|more polite|more professional|concise|friendlier)\b/i
1223
+ .test(text);
1224
+ const pronounEdit = /\b(make|rewrite|edit|change|update)\s+(?:it|this)\b/i.test(text);
1225
+
1226
+ return (editVerb && draftField) || toneOrLength || pronounEdit;
1227
+ }
1228
+
1229
+ function classifyDraftCommunicationIntent(input: string): DraftCommunicationIntentType | null {
1230
+ const text = input.replace(/\s+/g, " ").trim();
1231
+ if (!text) {
1232
+ return null;
1233
+ }
1234
+ if (isLikelyDraftEditInstruction(text)) {
1235
+ return "draft_edit_request";
1236
+ }
1237
+
1238
+ const communicationMention = /\b(email|mail|text|message|imessage|sms|draft)\b/i.test(text);
1239
+ if (!communicationMention) {
1240
+ return null;
1241
+ }
1242
+
1243
+ const explicitDraftPattern =
1244
+ /\b(?:write|draft|compose|create)\b/i.test(text) ||
1245
+ /\b(?:email|mail|message|text)\s+draft\b/i.test(text) ||
1246
+ /\bdraft\s+(?:an?\s+)?(?:email|mail|message|text)\b/i.test(text);
1247
+ if (explicitDraftPattern) {
1248
+ return "explicit_draft_request";
1249
+ }
1250
+
1251
+ if (/\bsend\b/i.test(text) || /^\s*(?:email|mail|text(?:\s+message)?|message)\b/i.test(text)) {
1252
+ return "send_request";
1253
+ }
1254
+ return null;
1255
+ }
1256
+
1257
+ function normalizeRecipientList(
1258
+ recipientChunk: string,
1259
+ peopleAliases: MemoryContextPayload["aliases"]["people"] | undefined,
1260
+ resolveAliases: boolean,
1261
+ ): string[] {
1262
+ const recipients = recipientChunk
1263
+ .replace(/^to\s+/i, "")
1264
+ .split(/\s*,\s*|\s+and\s+/i)
1265
+ .map((value) =>
1266
+ value
1267
+ .trim()
1268
+ .replace(/[,:;]+$/g, "")
1269
+ .replace(/^['"]|['"]$/g, ""),
1270
+ )
1271
+ .filter(Boolean)
1272
+ .slice(0, 3);
1273
+
1274
+ const normalized = recipients
1275
+ .map((value) => {
1276
+ const compact = normalizeAliasText(value);
1277
+ if (!compact || compact === "me" || compact === "myself" || compact === "us") {
1278
+ return "";
1279
+ }
1280
+ if (!resolveAliases) {
1281
+ return value;
1282
+ }
1283
+ return resolvePersonAlias(value, peopleAliases) ?? value;
1284
+ })
1285
+ .filter(Boolean);
1286
+ return [...new Set(normalized)];
1287
+ }
1288
+
1289
+ function normalizeDraftText(input: string): string {
1290
+ return input
1291
+ .replace(/\s+/g, " ")
1292
+ .trim()
1293
+ .replace(/^['"]|['"]$/g, "")
1294
+ .replace(/^[,.;:\-]+|[,.;:\-]+$/g, "")
1295
+ .trim();
1296
+ }
1297
+
1298
+ function capitalizeFirst(input: string): string {
1299
+ if (!input) {
1300
+ return input;
1301
+ }
1302
+ return input.charAt(0).toUpperCase() + input.slice(1);
1303
+ }
1304
+
1305
+ function toSentence(input: string): string {
1306
+ const normalized = normalizeDraftText(input);
1307
+ if (!normalized) {
1308
+ return "";
1309
+ }
1310
+ const withPronoun = normalized
1311
+ .replace(/\bi\b/g, "I")
1312
+ .replace(/^cant\b/i, "can't")
1313
+ .replace(/^dont\b/i, "don't")
1314
+ .replace(/^wont\b/i, "won't")
1315
+ .replace(/^im\b/i, "I'm");
1316
+ const withSubject = /^(can't|cannot|won't|will not|unable|am unable|not able|couldn't|didn't|don't)\b/i.test(withPronoun)
1317
+ ? `I ${withPronoun}`
1318
+ : withPronoun;
1319
+ const capitalized = capitalizeFirst(withSubject);
1320
+ return /[.!?]$/.test(capitalized) ? capitalized : `${capitalized}.`;
1321
+ }
1322
+
1323
+ function normalizeNameLabel(input: string): string {
1324
+ return input
1325
+ .split(/\s+/)
1326
+ .map((token) => {
1327
+ if (!token) {
1328
+ return token;
1329
+ }
1330
+ if (token.includes("@") || /\d/.test(token)) {
1331
+ return token;
1332
+ }
1333
+ return token.charAt(0).toUpperCase() + token.slice(1).toLowerCase();
1334
+ })
1335
+ .join(" ");
1336
+ }
1337
+
1338
+ function toClause(input: string): string {
1339
+ const sentence = toSentence(input);
1340
+ return sentence ? sentence.replace(/[.!?]+$/g, "").trim() : "";
1341
+ }
1342
+
1343
+ function ensureFirstPersonClause(input: string): string {
1344
+ const clause = toClause(input);
1345
+ if (!clause) {
1346
+ return "";
1347
+ }
1348
+ if (/^i\b/i.test(clause)) {
1349
+ return `I${clause.slice(1)}`;
1350
+ }
1351
+ if (/^(can't|cannot|won't|will not|am|have|had|need|would|could|should|must|did|do|was|were)\b/i.test(clause)) {
1352
+ return `I ${clause}`;
1353
+ }
1354
+ return clause;
1355
+ }
1356
+
1357
+ function inferEmailSalutation(recipient: string): string {
1358
+ const compact = normalizeAliasText(recipient);
1359
+ if (/^(?:a|an|the)?\s*(?:professor|teacher|instructor|ta|lecturer|advisor)\b/i.test(compact)) {
1360
+ return "Dear Professor [Last Name],";
1361
+ }
1362
+ if (/^(?:a|an|the)?\s*(?:dr|doctor)\b/i.test(compact)) {
1363
+ return "Dear Dr. [Last Name],";
1364
+ }
1365
+ if (/@|\d{7,}/.test(recipient)) {
1366
+ return "Hello,";
1367
+ }
1368
+ const label = normalizeNameLabel(recipient).replace(/^(?:A|An|The)\s+/g, "").trim();
1369
+ if (!label) {
1370
+ return "Hello,";
1371
+ }
1372
+ return `Dear ${label},`;
1373
+ }
1374
+
1375
+ function inferEmailSubject(params: { explicitSubject: string; bodyText: string; topicText: string }): string {
1376
+ const explicit = normalizeDraftText(params.explicitSubject);
1377
+ if (explicit) {
1378
+ return explicit;
1379
+ }
1380
+ const basis = normalizeDraftText(params.bodyText || params.topicText);
1381
+ const lower = basis.toLowerCase();
1382
+ if (!basis) {
1383
+ return "Quick Update";
1384
+ }
1385
+ if (/can't make it today|cannot make it today|unable to make it today|won't be able to make it today/.test(lower)) {
1386
+ return "Unable to Make It Today";
1387
+ }
1388
+ if (/can't make it|cannot make it|unable to make it|won't be able to/.test(lower)) {
1389
+ return "Unable to Make It";
1390
+ }
1391
+ if (/miss(ing)? class.*today|today.*miss(ing)? class/.test(lower)) {
1392
+ return "Absence from Class Today";
1393
+ }
1394
+ if (/miss(ing)? class|absence|absent/.test(lower)) {
1395
+ return "Absence from Class";
1396
+ }
1397
+ if (/running late|late/.test(lower)) {
1398
+ return "Running Late";
1399
+ }
1400
+ const clipped = basis.length > 64 ? basis.slice(0, 64).trim() : basis;
1401
+ return titleCase(clipped.replace(/[.!?]+$/g, ""));
1402
+ }
1403
+
1404
+ function extractRecipientAndHint(input: string): {
1405
+ recipientChunk: string;
1406
+ hintKind: DraftHintKind | null;
1407
+ hintText: string;
1408
+ } {
1409
+ const compact = normalizeDraftText(input);
1410
+ if (!compact) {
1411
+ return { recipientChunk: "", hintKind: null, hintText: "" };
1412
+ }
1413
+ const lowered = compact.toLowerCase();
1414
+ const separators: Array<{ token: string; kind: DraftHintKind }> = [
1415
+ { token: " that says ", kind: "body" },
1416
+ { token: " saying that ", kind: "body" },
1417
+ { token: " saying ", kind: "body" },
1418
+ { token: " with message ", kind: "body" },
1419
+ { token: " message: ", kind: "body" },
1420
+ { token: " about ", kind: "topic" },
1421
+ { token: " regarding ", kind: "topic" },
1422
+ { token: " re: ", kind: "topic" },
1423
+ { token: " that ", kind: "body" },
1424
+ ];
1425
+
1426
+ let bestIndex = -1;
1427
+ let bestSeparator: { token: string; kind: DraftHintKind } | null = null;
1428
+ for (const separator of separators) {
1429
+ const index = lowered.indexOf(separator.token);
1430
+ if (index <= 0) {
1431
+ continue;
1432
+ }
1433
+ if (bestIndex === -1 || index < bestIndex) {
1434
+ bestIndex = index;
1435
+ bestSeparator = separator;
1436
+ }
1437
+ }
1438
+
1439
+ if (!bestSeparator) {
1440
+ return {
1441
+ recipientChunk: compact,
1442
+ hintKind: null,
1443
+ hintText: "",
1444
+ };
1445
+ }
1446
+ return {
1447
+ recipientChunk: compact.slice(0, bestIndex).trim(),
1448
+ hintKind: bestSeparator.kind,
1449
+ hintText: compact.slice(bestIndex + bestSeparator.token.length).trim(),
1450
+ };
1451
+ }
1452
+
1453
+ function buildEmailDraftBody(params: {
1454
+ topicText: string;
1455
+ explicitBody: string;
1456
+ recipient: string;
1457
+ sourceText?: string;
1458
+ }): string {
1459
+ const salutation = inferEmailSalutation(params.recipient);
1460
+ const combined = normalizeDraftText([params.sourceText ?? "", params.explicitBody, params.topicText].join(" "));
1461
+ const loweredCombined = combined.toLowerCase();
1462
+ const wantsLong = /\b(long|detailed|thorough)\b/i.test(loweredCombined);
1463
+ const wantsShort = /\b(short|brief|concise)\b/i.test(loweredCombined);
1464
+ const absenceLike = /\b(can't make|cannot make|unable to|won't be able|miss(?:ing)? class|absence|absent|not attend)\b/i
1465
+ .test(loweredCombined);
1466
+
1467
+ if (absenceLike) {
1468
+ const baselineAbsence = /\btoday\b/i.test(loweredCombined)
1469
+ ? "I won't be able to attend class today"
1470
+ : "I won't be able to attend class";
1471
+ const absenceClause = ensureFirstPersonClause(params.explicitBody) || baselineAbsence;
1472
+ const detailParagraph = wantsLong
1473
+ ? "I will review the material covered and catch up on any notes or assignments. If there is anything specific you would like me to prioritize, please let me know."
1474
+ : "I will review the material covered and catch up on any notes or assignments.";
1475
+
1476
+ if (wantsShort) {
1477
+ return `${salutation}\n\n${absenceClause}.\n\nThank you for your understanding.\n\nBest,\n[Your Name]`;
1478
+ }
1479
+
1480
+ return `${salutation}\n\nI hope you are doing well. I wanted to let you know that ${absenceClause}.\n\n${detailParagraph}\n\nThank you for your understanding.\n\nBest,\n[Your Name]`;
1481
+ }
1482
+
1483
+ const explicitSentence = toSentence(params.explicitBody);
1484
+ if (explicitSentence) {
1485
+ return `${salutation}\n\n${explicitSentence}\n\nPlease let me know if you need anything else from me.\n\nBest,\n[Your Name]`;
1486
+ }
1487
+ const topicClause = toClause(params.topicText);
1488
+ if (topicClause) {
1489
+ const detailLine = wantsLong
1490
+ ? "I wanted to share a bit more context so you have the full picture."
1491
+ : "I wanted to share a quick update.";
1492
+ return `${salutation}\n\n${detailLine} I wanted to reach out regarding ${topicClause}.\n\nThank you for your time.\n\nBest,\n[Your Name]`;
1493
+ }
1494
+ return `${salutation}\n\nI wanted to share a quick update.\n\nThank you for your time.\n\nBest,\n[Your Name]`;
1495
+ }
1496
+
1497
+ function buildMessageDraftBody(params: { topicText: string; explicitBody: string; recipient: string }): string {
1498
+ const firstLabel = normalizeNameLabel(params.recipient).split(/\s+/)[0] || params.recipient;
1499
+ const explicitSentence = toSentence(params.explicitBody);
1500
+ if (explicitSentence) {
1501
+ const apology = /can't make|cannot make|unable to|won't be able|miss class|absence|late/i.test(explicitSentence)
1502
+ ? " Sorry for the short notice."
1503
+ : "";
1504
+ return `Hey ${firstLabel}, ${explicitSentence}${apology}`;
1505
+ }
1506
+ const topicSentence = toSentence(params.topicText);
1507
+ if (topicSentence) {
1508
+ return `Hey ${firstLabel}, just wanted to message you about ${topicSentence}`;
1509
+ }
1510
+ return `Hey ${firstLabel}, wanted to check in when you have a minute.`;
1511
+ }
1512
+
1513
+ function parseDraftCommunicationIntent(params: {
1514
+ text: string;
1515
+ peopleAliases?: MemoryContextPayload["aliases"]["people"];
1516
+ }): DraftCommunicationIntent | null {
1517
+ const text = params.text.replace(/\s+/g, " ").trim();
1518
+ if (!text) {
1519
+ return null;
1520
+ }
1521
+ const intentType = classifyDraftCommunicationIntent(text);
1522
+ if (intentType === "draft_edit_request" || !intentType) {
1523
+ return null;
1524
+ }
1525
+ const actionVerbMention = /\b(write|draft|compose|create|send)\b/i.test(text);
1526
+ const communicationNounMention = /\b(email|mail|text|message|imessage|sms|draft)\b/i.test(text);
1527
+ if (!actionVerbMention && !communicationNounMention) {
1528
+ return null;
1529
+ }
1530
+
1531
+ const emailMention = /\b(email|mail)\b/i.test(text);
1532
+ const messageMention = /\b(text|message|imessage|sms)\b/i.test(text);
1533
+ const genericDraftToMention =
1534
+ /(?:write|draft|compose|create)(?:\s+me)?\s+(?:an?\s+)?(?:[a-z]+\s+){0,3}draft\s+(?:for|to)\b/i.test(text) ||
1535
+ /\bdraft\s+(?:for|to)\b/i.test(text) ||
1536
+ /(?:write|draft|compose|create)\s+to\b/i.test(text);
1537
+ if (!emailMention && !messageMention && !genericDraftToMention) {
1538
+ return null;
1539
+ }
1540
+
1541
+ let channel: "email" | "message" = emailMention ? "email" : "message";
1542
+ if (!emailMention && !messageMention) {
1543
+ channel = "email";
1544
+ }
1545
+ if (emailMention && messageMention) {
1546
+ const lowered = text.toLowerCase();
1547
+ const emailIndex = lowered.search(/\b(email|mail)\b/);
1548
+ const messageIndex = lowered.search(/\b(text|message|imessage|sms)\b/);
1549
+ channel = emailIndex !== -1 && (messageIndex === -1 || emailIndex < messageIndex) ? "email" : "message";
1550
+ }
1551
+
1552
+ let recipientChunk = "";
1553
+ let hintKind: DraftHintKind | null = null;
1554
+ let hintText = "";
1555
+ const recipientPatterns = [
1556
+ /(?:write|draft|compose|create|send)(?:\s+me)?\s+(?:an?\s+)?(?:[a-z]+\s+){0,2}(?:email|mail|text(?:\s+message)?|message)(?:\s+draft)?\s+(?:for|to)\s+(.+)$/i,
1557
+ /(?:write|draft|compose|create)(?:\s+me)?\s+(?:an?\s+)?(?:[a-z]+\s+){0,3}draft\s+(?:for|to)\s+(.+)$/i,
1558
+ /(?:email|mail|text(?:\s+message)?|message)\s+(?:for|to)\s+(.+)$/i,
1559
+ /^\s*(?:email|mail|text(?:\s+message)?|message)\s+(.+)$/i,
1560
+ /\bdraft\s+(?:for|to)\s+(.+)$/i,
1561
+ /(?:write|draft|compose|create)\s+to\s+(.+)$/i,
1562
+ ];
1563
+ for (const pattern of recipientPatterns) {
1564
+ const match = text.match(pattern);
1565
+ if (match?.[1]) {
1566
+ const extracted = extractRecipientAndHint(match[1].trim());
1567
+ recipientChunk = extracted.recipientChunk;
1568
+ hintKind = extracted.hintKind;
1569
+ hintText = extracted.hintText;
1570
+ break;
1571
+ }
1572
+ }
1573
+ if (!recipientChunk) {
1574
+ const fallbackRecipientMatch = text.match(/\b(?:to|for)\s+(.+)$/i);
1575
+ if (fallbackRecipientMatch?.[1]) {
1576
+ const extracted = extractRecipientAndHint(fallbackRecipientMatch[1].trim());
1577
+ recipientChunk = extracted.recipientChunk;
1578
+ hintKind = extracted.hintKind;
1579
+ hintText = extracted.hintText;
1580
+ }
1581
+ }
1582
+ if (!recipientChunk) {
1583
+ return null;
1584
+ }
1585
+
1586
+ const recipients = normalizeRecipientList(
1587
+ recipientChunk,
1588
+ params.peopleAliases,
1589
+ channel === "message",
1590
+ );
1591
+ if (recipients.length === 0) {
1592
+ return null;
1593
+ }
1594
+
1595
+ const subjectMatch = text.match(/\bsubject(?:\s*[:\-]\s*|\s+)(.+?)(?=\s+(?:body|message|that says|saying)\b|$)/i);
1596
+ const messageMatch = text.match(/\b(?:that says|saying(?: that)?|with message|message:)\s+(.+)$/i);
1597
+ const topicMatch = text.match(/\b(?:about|regarding|re(?:\:)?)(?:\s+the)?\s+(.+)$/i);
1598
+ const topic = normalizeDraftText(
1599
+ (topicMatch?.[1] ?? "") || (hintKind === "topic" ? hintText : ""),
1600
+ );
1601
+ const explicitBody = normalizeDraftText(
1602
+ (messageMatch?.[1] ?? "") || (hintKind === "body" ? hintText : ""),
1603
+ );
1604
+
1605
+ if (channel === "email") {
1606
+ const subject = inferEmailSubject({
1607
+ explicitSubject: subjectMatch?.[1] ?? "",
1608
+ bodyText: explicitBody,
1609
+ topicText: topic,
1610
+ });
1611
+ const body = buildEmailDraftBody({
1612
+ topicText: topic,
1613
+ explicitBody,
1614
+ recipient: recipients[0],
1615
+ sourceText: text,
1616
+ });
1617
+ return {
1618
+ intentType,
1619
+ toolName: "mail_send",
1620
+ args: {
1621
+ to: recipients,
1622
+ subject,
1623
+ body,
1624
+ },
1625
+ };
1626
+ }
1627
+
1628
+ const body = buildMessageDraftBody({
1629
+ topicText: topic,
1630
+ explicitBody,
1631
+ recipient: recipients[0],
1632
+ });
1633
+ return {
1634
+ intentType,
1635
+ toolName: "messages_send",
1636
+ args: {
1637
+ to: recipients,
1638
+ body,
1639
+ },
1640
+ };
1641
+ }
1642
+
997
1643
  function extractDirectMusicIntent(messages: ChatMessageInput[], aliases?: MemoryMusicAlias[]): {
998
1644
  query: string;
999
1645
  title: string | null;
@@ -1482,59 +2128,111 @@ async function tryDirectAutomationFastPath(params: {
1482
2128
  }
1483
2129
  }
1484
2130
 
1485
- // Fast path 2: direct text/iMessage
1486
- const messageIntent = parseDirectMessageIntent({
1487
- text: lastUserText,
1488
- peopleAliases: params.memory?.enabled ? params.memory.aliases.people : [],
1489
- });
1490
- if (messageIntent) {
2131
+ const runDraftableCommunication = async (intent: DraftCommunicationIntent): Promise<boolean> => {
1491
2132
  if (!params.localTools.enableMail) {
1492
2133
  params.send({
1493
2134
  type: "token",
1494
2135
  value:
1495
- "Messaging tools are disabled. Enable **mail/messages tools** in Settings > Local Tools, then retry.",
2136
+ "Mail/messages tools are disabled. Enable **mail/messages tools** in Settings > Local Tools, then retry.",
1496
2137
  });
1497
2138
  return true;
1498
2139
  }
1499
2140
 
1500
- const messageTool = findToolByName(tools, "messages_send");
1501
- if (messageTool) {
1502
- if (requiresApprovalForRisk(messageTool.risk, params.localTools.approvalMode)) {
1503
- return false;
1504
- }
2141
+ const tool = findToolByName(tools, intent.toolName);
2142
+ if (!tool) {
2143
+ return false;
2144
+ }
1505
2145
 
1506
- const callId = "fastpath_messages_send";
1507
- const args = {
1508
- to: messageIntent.to,
1509
- body: messageIntent.body,
1510
- };
2146
+ const requiresDraftReview =
2147
+ isPreSendDraftTool(tool.name) &&
2148
+ (params.localTools.preSendDraftReview || intent.intentType === "explicit_draft_request");
2149
+ if (!requiresDraftReview && requiresApprovalForRisk(tool.risk, params.localTools.approvalMode)) {
2150
+ return false;
2151
+ }
2152
+
2153
+ const callId = intent.toolName === "mail_send" ? "fastpath_mail_send" : "fastpath_messages_send";
2154
+ let executionArgs: Record<string, unknown> = { ...intent.args };
2155
+ params.send({
2156
+ type: "tool_call",
2157
+ callId,
2158
+ name: tool.name,
2159
+ args: intent.args,
2160
+ });
2161
+
2162
+ if (requiresDraftReview) {
2163
+ const approval = createApprovalRequest();
1511
2164
  params.send({
1512
- type: "tool_call",
2165
+ type: "approval_requested",
2166
+ approvalId: approval.approvalId,
1513
2167
  callId,
1514
- name: messageTool.name,
1515
- args,
2168
+ name: tool.name,
2169
+ args: intent.args,
2170
+ reason: summarizeApprovalReason(tool.name, intent.args),
1516
2171
  });
2172
+ const approvalResolution = await approval.promise;
1517
2173
  params.send({
1518
- type: "tool_progress",
2174
+ type: "approval_resolved",
2175
+ approvalId: approval.approvalId,
1519
2176
  callId,
1520
- name: messageTool.name,
1521
- status: "started",
1522
- message: "Sending message.",
2177
+ decision: approvalResolution.decision,
2178
+ message:
2179
+ approvalResolution.decision === "approve"
2180
+ ? "Approved"
2181
+ : approvalResolution.decision === "supersede"
2182
+ ? "Replaced"
2183
+ : approvalResolution.decision === "timeout"
2184
+ ? "Timed out"
2185
+ : "Denied",
1523
2186
  });
2187
+ if (approvalResolution.decision !== "approve") {
2188
+ if (approvalResolution.decision === "deny") {
2189
+ params.send({
2190
+ type: "token",
2191
+ value: "Draft discarded. Nothing was sent.",
2192
+ });
2193
+ } else if (approvalResolution.decision === "timeout") {
2194
+ params.send({
2195
+ type: "token",
2196
+ value: "Draft review expired. Nothing was sent.",
2197
+ });
2198
+ }
2199
+ return true;
2200
+ }
2201
+ if (
2202
+ approvalResolution.args &&
2203
+ typeof approvalResolution.args === "object" &&
2204
+ !Array.isArray(approvalResolution.args)
2205
+ ) {
2206
+ executionArgs = approvalResolution.args;
2207
+ }
2208
+ }
1524
2209
 
1525
- try {
1526
- const output = await messageTool.execute(args, {
1527
- localTools: params.localTools,
1528
- signal: params.signal,
1529
- });
1530
- params.send({
1531
- type: "tool_result",
1532
- callId,
1533
- name: messageTool.name,
1534
- ok: true,
1535
- result: output,
1536
- });
2210
+ const progressMessage = tool.name === "mail_send" ? "Sending email." : "Sending message.";
2211
+ params.send({
2212
+ type: "tool_progress",
2213
+ callId,
2214
+ name: tool.name,
2215
+ status: "started",
2216
+ message: progressMessage,
2217
+ });
2218
+
2219
+ try {
2220
+ const output = await tool.execute(executionArgs, {
2221
+ localTools: params.localTools,
2222
+ signal: params.signal,
2223
+ });
2224
+ params.send({
2225
+ type: "tool_result",
2226
+ callId,
2227
+ name: tool.name,
2228
+ ok: true,
2229
+ result: output,
2230
+ });
1537
2231
 
2232
+ const argsRecipients = Array.isArray(executionArgs.to)
2233
+ ? executionArgs.to.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
2234
+ : [];
2235
+ if (tool.name === "messages_send") {
1538
2236
  const outputRecord =
1539
2237
  output && typeof output === "object"
1540
2238
  ? (output as { recipients?: Array<{ name?: string | null; input?: string; handle?: string }> })
@@ -1542,30 +2240,66 @@ async function tryDirectAutomationFastPath(params: {
1542
2240
  const recipientLabel =
1543
2241
  outputRecord?.recipients?.map((recipient) => recipient.name || recipient.input || recipient.handle)
1544
2242
  .filter(Boolean)
1545
- .join(", ") || messageIntent.to.join(", ");
1546
-
2243
+ .join(", ") || argsRecipients.join(", ");
1547
2244
  params.send({
1548
2245
  type: "token",
1549
2246
  value: `**Done.** Sent message to ${recipientLabel}.`,
1550
2247
  });
1551
- } catch (error) {
1552
- const message = formatToolErrorForUser(
1553
- messageTool.name,
1554
- error,
1555
- "Could not send message.",
1556
- );
1557
- params.send({
1558
- type: "tool_result",
1559
- callId,
1560
- name: messageTool.name,
1561
- ok: false,
1562
- result: { error: message },
1563
- });
2248
+ } else {
2249
+ const recipientLabel = argsRecipients.join(", ");
1564
2250
  params.send({
1565
2251
  type: "token",
1566
- value: `I couldn't send that message: ${message}.`,
2252
+ value: `**Done.** Sent email to ${recipientLabel}.`,
1567
2253
  });
1568
2254
  }
2255
+ } catch (error) {
2256
+ const fallback = tool.name === "mail_send" ? "Could not send email." : "Could not send message.";
2257
+ const message = formatToolErrorForUser(tool.name, error, fallback);
2258
+ params.send({
2259
+ type: "tool_result",
2260
+ callId,
2261
+ name: tool.name,
2262
+ ok: false,
2263
+ result: { error: message },
2264
+ });
2265
+ params.send({
2266
+ type: "token",
2267
+ value:
2268
+ tool.name === "mail_send"
2269
+ ? `I couldn't send that email: ${message}.`
2270
+ : `I couldn't send that message: ${message}.`,
2271
+ });
2272
+ }
2273
+ return true;
2274
+ };
2275
+
2276
+ // Fast path 2: direct text/iMessage
2277
+ const messageIntent = parseDirectMessageIntent({
2278
+ text: lastUserText,
2279
+ peopleAliases: params.memory?.enabled ? params.memory.aliases.people : [],
2280
+ });
2281
+ if (messageIntent) {
2282
+ const handled = await runDraftableCommunication({
2283
+ intentType: "send_request",
2284
+ toolName: "messages_send",
2285
+ args: {
2286
+ to: messageIntent.to,
2287
+ body: messageIntent.body,
2288
+ },
2289
+ });
2290
+ if (handled) {
2291
+ return true;
2292
+ }
2293
+ }
2294
+
2295
+ // Fast path 2b: draft or general write/send email/message
2296
+ const draftCommunicationIntent = parseDraftCommunicationIntent({
2297
+ text: lastUserText,
2298
+ peopleAliases: params.memory?.enabled ? params.memory.aliases.people : [],
2299
+ });
2300
+ if (draftCommunicationIntent) {
2301
+ const handled = await runDraftableCommunication(draftCommunicationIntent);
2302
+ if (handled) {
1569
2303
  return true;
1570
2304
  }
1571
2305
  }
@@ -1846,6 +2580,7 @@ async function streamPlainChat(params: {
1846
2580
  system?: string;
1847
2581
  messages: ChatMessageInput[];
1848
2582
  enableWebSources: boolean;
2583
+ webSearchMode?: WebSearchMode;
1849
2584
  send: (chunk: ChatStreamChunk) => void;
1850
2585
  signal?: AbortSignal;
1851
2586
  }) {
@@ -1895,20 +2630,30 @@ async function streamPlainChat(params: {
1895
2630
  baseURL: openAIBaseURL,
1896
2631
  defaultHeaders: openAIHeaders,
1897
2632
  });
1898
- const useWebSources =
1899
- params.enableWebSources && supportsWebSourcesViaOpenAI(params.connection);
2633
+ const useWebSources = params.enableWebSources && supportsWebSourcesViaOpenAI(params.connection);
2634
+ const webSearchMode: WebSearchMode = useWebSources
2635
+ ? params.webSearchMode === "required"
2636
+ ? "required"
2637
+ : "auto"
2638
+ : "off";
2639
+ const instructions = withWebSearchGuidance(params.system, webSearchMode);
1900
2640
  const response = await client.responses.create(
1901
2641
  {
1902
2642
  model: params.model,
1903
- instructions: params.system,
2643
+ instructions,
1904
2644
  input: params.messages.map((message) => ({
1905
2645
  type: "message",
1906
2646
  role: message.role,
1907
2647
  content: message.content,
1908
2648
  })),
1909
- tools: useWebSources ? [{ type: "web_search" }] : undefined,
1910
- tool_choice: useWebSources ? "auto" : undefined,
1911
- include: useWebSources ? ["web_search_call.action.sources"] : undefined,
2649
+ tools: webSearchMode !== "off" ? [{ type: "web_search" }] : undefined,
2650
+ tool_choice:
2651
+ webSearchMode === "required"
2652
+ ? "required"
2653
+ : webSearchMode === "auto"
2654
+ ? "auto"
2655
+ : undefined,
2656
+ include: webSearchMode !== "off" ? ["web_search_call.action.sources"] : undefined,
1912
2657
  stream: true,
1913
2658
  },
1914
2659
  { signal: params.signal },
@@ -2040,13 +2785,15 @@ async function runToolOrchestrator(params: {
2040
2785
  messages: ChatMessageInput[];
2041
2786
  localTools: LocalToolsSettings;
2042
2787
  enableWebSources: boolean;
2788
+ forceDraftReviewForSend?: boolean;
2789
+ draftEditContext?: DraftEditPromptContext;
2043
2790
  send: (chunk: ChatStreamChunk) => void;
2044
2791
  signal?: AbortSignal;
2045
2792
  }) {
2046
2793
  const { tools, session } = getToolSession({
2047
2794
  connection: params.connection,
2048
2795
  model: params.model,
2049
- system: buildToolSystemPrompt(params.system, params.messages),
2796
+ system: buildToolSystemPrompt(params.system, params.messages, params.draftEditContext),
2050
2797
  messages: params.messages,
2051
2798
  localTools: params.localTools,
2052
2799
  enableWebSources: params.enableWebSources,
@@ -2157,7 +2904,10 @@ async function runToolOrchestrator(params: {
2157
2904
  continue;
2158
2905
  }
2159
2906
 
2160
- if (requiresApprovalForRisk(tool.risk, params.localTools.approvalMode)) {
2907
+ const requiresDraftReview =
2908
+ isPreSendDraftTool(call.name) &&
2909
+ (params.localTools.preSendDraftReview || params.forceDraftReviewForSend === true);
2910
+ if (requiresDraftReview || requiresApprovalForRisk(tool.risk, params.localTools.approvalMode)) {
2161
2911
  const approval = createApprovalRequest();
2162
2912
  params.send({
2163
2913
  type: "approval_requested",
@@ -2169,23 +2919,47 @@ async function runToolOrchestrator(params: {
2169
2919
  });
2170
2920
 
2171
2921
  emitThinkingUpdate(`Waiting for approval to run ${call.name}.`);
2172
- const decision = await approval.promise;
2922
+ const approvalResolution = await approval.promise;
2173
2923
  params.send({
2174
2924
  type: "approval_resolved",
2175
2925
  approvalId: approval.approvalId,
2176
2926
  callId: call.id,
2177
- decision,
2927
+ decision: approvalResolution.decision,
2178
2928
  message:
2179
- decision === "approve"
2929
+ approvalResolution.decision === "approve"
2180
2930
  ? "Approved"
2181
- : decision === "timeout"
2931
+ : approvalResolution.decision === "supersede"
2932
+ ? "Replaced"
2933
+ : approvalResolution.decision === "timeout"
2182
2934
  ? "Timed out"
2183
2935
  : "Denied",
2184
2936
  });
2185
2937
 
2186
- if (decision !== "approve") {
2938
+ if (approvalResolution.decision !== "approve") {
2939
+ if (requiresDraftReview && isPreSendDraftTool(call.name)) {
2940
+ const message =
2941
+ approvalResolution.decision === "deny"
2942
+ ? "Draft discarded. Nothing was sent."
2943
+ : approvalResolution.decision === "timeout"
2944
+ ? "Draft review expired. Nothing was sent."
2945
+ : "Draft replaced by a newer instruction.";
2946
+ if (approvalResolution.decision !== "supersede") {
2947
+ params.send({
2948
+ type: "token",
2949
+ value: message,
2950
+ });
2951
+ }
2952
+ session.addToolResult({
2953
+ callId: call.id,
2954
+ name: call.name,
2955
+ result: message,
2956
+ isError: false,
2957
+ });
2958
+ continue;
2959
+ }
2960
+
2187
2961
  const message =
2188
- decision === "timeout"
2962
+ approvalResolution.decision === "timeout"
2189
2963
  ? "Tool call timed out waiting for approval."
2190
2964
  : "Tool call denied by user.";
2191
2965
  params.send({
@@ -2203,6 +2977,13 @@ async function runToolOrchestrator(params: {
2203
2977
  });
2204
2978
  continue;
2205
2979
  }
2980
+ if (
2981
+ approvalResolution.args &&
2982
+ typeof approvalResolution.args === "object" &&
2983
+ !Array.isArray(approvalResolution.args)
2984
+ ) {
2985
+ callInput = approvalResolution.args;
2986
+ }
2206
2987
  }
2207
2988
 
2208
2989
  params.send({
@@ -2326,10 +3107,15 @@ export async function POST(request: Request) {
2326
3107
  }
2327
3108
 
2328
3109
  const webSourcesEnabled = normalizeWebSourcesEnabled(body.enableWebSources);
2329
- const enableWebSourcesForConnection =
2330
- webSourcesEnabled && supportsWebSourcesViaOpenAI(connection);
3110
+ const webSearchMode = decideWebSearchMode({
3111
+ enabled: webSourcesEnabled,
3112
+ connection,
3113
+ messages: sanitizedMessages,
3114
+ });
3115
+ const enableWebSourcesForConnection = webSearchMode !== "off";
2331
3116
  const localToolsRequested = normalizeLocalTools(body.localTools);
2332
3117
  const memoryContext = sanitizeMemoryContext(body.memory);
3118
+ const requestMeta = sanitizeChatRequestMeta(body.meta);
2333
3119
  const encoder = new TextEncoder();
2334
3120
 
2335
3121
  const stream = new ReadableStream({
@@ -2369,28 +3155,36 @@ export async function POST(request: Request) {
2369
3155
  }
2370
3156
 
2371
3157
  if (localTools.enabled) {
2372
- const routeThroughTools = shouldRouteThroughToolOrchestrator(sanitizedMessages);
3158
+ const lastUserText =
3159
+ [...sanitizedMessages].reverse().find((message) => message.role === "user")?.content ?? "";
3160
+ const communicationIntentType = classifyDraftCommunicationIntent(lastUserText);
3161
+ const forceDraftReviewForSend =
3162
+ communicationIntentType === "explicit_draft_request" || Boolean(requestMeta.draftEditContext);
3163
+ const routeThroughTools =
3164
+ requestMeta.forceToolRouting || shouldRouteThroughToolOrchestrator(sanitizedMessages);
2373
3165
  if (routeThroughTools) {
2374
3166
  sendThinking("update", "Checking whether this can run through a direct action path.");
2375
3167
  let fastHandled = false;
2376
- try {
2377
- fastHandled = await tryDirectAutomationFastPath({
2378
- localTools,
2379
- messages: sanitizedMessages,
2380
- memory: memoryContext,
2381
- send,
2382
- signal: request.signal,
2383
- });
2384
- } catch (error) {
2385
- const message =
2386
- error instanceof Error ? truncateMessage(error.message) : "Fast-path unavailable.";
2387
- send({
2388
- type: "tool_progress",
2389
- callId: "tooling",
2390
- name: "tooling",
2391
- status: "warning",
2392
- message: `Direct automation fast-path failed: ${message}. Continuing with tool orchestration.`,
2393
- });
3168
+ if (!requestMeta.forceToolRouting) {
3169
+ try {
3170
+ fastHandled = await tryDirectAutomationFastPath({
3171
+ localTools,
3172
+ messages: sanitizedMessages,
3173
+ memory: memoryContext,
3174
+ send,
3175
+ signal: request.signal,
3176
+ });
3177
+ } catch (error) {
3178
+ const message =
3179
+ error instanceof Error ? truncateMessage(error.message) : "Fast-path unavailable.";
3180
+ send({
3181
+ type: "tool_progress",
3182
+ callId: "tooling",
3183
+ name: "tooling",
3184
+ status: "warning",
3185
+ message: `Direct automation fast-path failed: ${message}. Continuing with tool orchestration.`,
3186
+ });
3187
+ }
2394
3188
  }
2395
3189
 
2396
3190
  if (fastHandled) {
@@ -2407,6 +3201,8 @@ export async function POST(request: Request) {
2407
3201
  messages: sanitizedMessages,
2408
3202
  localTools,
2409
3203
  enableWebSources: enableWebSourcesForConnection,
3204
+ forceDraftReviewForSend,
3205
+ draftEditContext: requestMeta.draftEditContext,
2410
3206
  send,
2411
3207
  signal: request.signal,
2412
3208
  });
@@ -2423,6 +3219,7 @@ export async function POST(request: Request) {
2423
3219
  system,
2424
3220
  messages: sanitizedMessages,
2425
3221
  enableWebSources: enableWebSourcesForConnection,
3222
+ webSearchMode,
2426
3223
  send,
2427
3224
  signal: request.signal,
2428
3225
  });