iris-chatbot 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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],
@@ -731,6 +834,10 @@ function summarizeApprovalReason(toolName: string, args: Record<string, unknown>
731
834
  return `Approval required for ${toolName}.`;
732
835
  }
733
836
 
837
+ function isPreSendDraftTool(toolName: string): boolean {
838
+ return toolName === "mail_send" || toolName === "messages_send";
839
+ }
840
+
734
841
  function normalizeAliasText(input: string): string {
735
842
  return input
736
843
  .toLowerCase()
@@ -994,6 +1101,452 @@ function parseDirectMessageIntent(params: {
994
1101
  };
995
1102
  }
996
1103
 
1104
+ type DraftCommunicationIntent = {
1105
+ intentType: "explicit_draft_request" | "send_request";
1106
+ toolName: "mail_send" | "messages_send";
1107
+ args: Record<string, unknown>;
1108
+ };
1109
+
1110
+ type DraftHintKind = "body" | "topic";
1111
+ type DraftCommunicationIntentType = "explicit_draft_request" | "send_request" | "draft_edit_request";
1112
+
1113
+ function isLikelyDraftEditInstruction(input: string): boolean {
1114
+ const text = input.replace(/\s+/g, " ").trim();
1115
+ if (!text) {
1116
+ return false;
1117
+ }
1118
+
1119
+ if (
1120
+ /\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
1121
+ .test(text)
1122
+ ) {
1123
+ return false;
1124
+ }
1125
+
1126
+ const editVerb = /\b(edit|rewrite|change|update|improve|polish|adjust|expand|shorten|lengthen|make)\b/i.test(text);
1127
+ const draftField = /\b(email|mail|message|text|draft|subject|body)\b/i.test(text);
1128
+ const toneOrLength =
1129
+ /\b(longer|shorter|more formal|less formal|more casual|more polite|more professional|concise|friendlier)\b/i
1130
+ .test(text);
1131
+ const pronounEdit = /\b(make|rewrite|edit|change|update)\s+(?:it|this)\b/i.test(text);
1132
+
1133
+ return (editVerb && draftField) || toneOrLength || pronounEdit;
1134
+ }
1135
+
1136
+ function classifyDraftCommunicationIntent(input: string): DraftCommunicationIntentType | null {
1137
+ const text = input.replace(/\s+/g, " ").trim();
1138
+ if (!text) {
1139
+ return null;
1140
+ }
1141
+ if (isLikelyDraftEditInstruction(text)) {
1142
+ return "draft_edit_request";
1143
+ }
1144
+
1145
+ const communicationMention = /\b(email|mail|text|message|imessage|sms|draft)\b/i.test(text);
1146
+ if (!communicationMention) {
1147
+ return null;
1148
+ }
1149
+
1150
+ const explicitDraftPattern =
1151
+ /\b(?:write|draft|compose|create)\b/i.test(text) ||
1152
+ /\b(?:email|mail|message|text)\s+draft\b/i.test(text) ||
1153
+ /\bdraft\s+(?:an?\s+)?(?:email|mail|message|text)\b/i.test(text);
1154
+ if (explicitDraftPattern) {
1155
+ return "explicit_draft_request";
1156
+ }
1157
+
1158
+ if (/\bsend\b/i.test(text) || /^\s*(?:email|mail|text(?:\s+message)?|message)\b/i.test(text)) {
1159
+ return "send_request";
1160
+ }
1161
+ return null;
1162
+ }
1163
+
1164
+ function normalizeRecipientList(
1165
+ recipientChunk: string,
1166
+ peopleAliases: MemoryContextPayload["aliases"]["people"] | undefined,
1167
+ resolveAliases: boolean,
1168
+ ): string[] {
1169
+ const recipients = recipientChunk
1170
+ .replace(/^to\s+/i, "")
1171
+ .split(/\s*,\s*|\s+and\s+/i)
1172
+ .map((value) =>
1173
+ value
1174
+ .trim()
1175
+ .replace(/[,:;]+$/g, "")
1176
+ .replace(/^['"]|['"]$/g, ""),
1177
+ )
1178
+ .filter(Boolean)
1179
+ .slice(0, 3);
1180
+
1181
+ const normalized = recipients
1182
+ .map((value) => {
1183
+ const compact = normalizeAliasText(value);
1184
+ if (!compact || compact === "me" || compact === "myself" || compact === "us") {
1185
+ return "";
1186
+ }
1187
+ if (!resolveAliases) {
1188
+ return value;
1189
+ }
1190
+ return resolvePersonAlias(value, peopleAliases) ?? value;
1191
+ })
1192
+ .filter(Boolean);
1193
+ return [...new Set(normalized)];
1194
+ }
1195
+
1196
+ function normalizeDraftText(input: string): string {
1197
+ return input
1198
+ .replace(/\s+/g, " ")
1199
+ .trim()
1200
+ .replace(/^['"]|['"]$/g, "")
1201
+ .replace(/^[,.;:\-]+|[,.;:\-]+$/g, "")
1202
+ .trim();
1203
+ }
1204
+
1205
+ function capitalizeFirst(input: string): string {
1206
+ if (!input) {
1207
+ return input;
1208
+ }
1209
+ return input.charAt(0).toUpperCase() + input.slice(1);
1210
+ }
1211
+
1212
+ function toSentence(input: string): string {
1213
+ const normalized = normalizeDraftText(input);
1214
+ if (!normalized) {
1215
+ return "";
1216
+ }
1217
+ const withPronoun = normalized
1218
+ .replace(/\bi\b/g, "I")
1219
+ .replace(/^cant\b/i, "can't")
1220
+ .replace(/^dont\b/i, "don't")
1221
+ .replace(/^wont\b/i, "won't")
1222
+ .replace(/^im\b/i, "I'm");
1223
+ const withSubject = /^(can't|cannot|won't|will not|unable|am unable|not able|couldn't|didn't|don't)\b/i.test(withPronoun)
1224
+ ? `I ${withPronoun}`
1225
+ : withPronoun;
1226
+ const capitalized = capitalizeFirst(withSubject);
1227
+ return /[.!?]$/.test(capitalized) ? capitalized : `${capitalized}.`;
1228
+ }
1229
+
1230
+ function normalizeNameLabel(input: string): string {
1231
+ return input
1232
+ .split(/\s+/)
1233
+ .map((token) => {
1234
+ if (!token) {
1235
+ return token;
1236
+ }
1237
+ if (token.includes("@") || /\d/.test(token)) {
1238
+ return token;
1239
+ }
1240
+ return token.charAt(0).toUpperCase() + token.slice(1).toLowerCase();
1241
+ })
1242
+ .join(" ");
1243
+ }
1244
+
1245
+ function toClause(input: string): string {
1246
+ const sentence = toSentence(input);
1247
+ return sentence ? sentence.replace(/[.!?]+$/g, "").trim() : "";
1248
+ }
1249
+
1250
+ function ensureFirstPersonClause(input: string): string {
1251
+ const clause = toClause(input);
1252
+ if (!clause) {
1253
+ return "";
1254
+ }
1255
+ if (/^i\b/i.test(clause)) {
1256
+ return `I${clause.slice(1)}`;
1257
+ }
1258
+ if (/^(can't|cannot|won't|will not|am|have|had|need|would|could|should|must|did|do|was|were)\b/i.test(clause)) {
1259
+ return `I ${clause}`;
1260
+ }
1261
+ return clause;
1262
+ }
1263
+
1264
+ function inferEmailSalutation(recipient: string): string {
1265
+ const compact = normalizeAliasText(recipient);
1266
+ if (/^(?:a|an|the)?\s*(?:professor|teacher|instructor|ta|lecturer|advisor)\b/i.test(compact)) {
1267
+ return "Dear Professor [Last Name],";
1268
+ }
1269
+ if (/^(?:a|an|the)?\s*(?:dr|doctor)\b/i.test(compact)) {
1270
+ return "Dear Dr. [Last Name],";
1271
+ }
1272
+ if (/@|\d{7,}/.test(recipient)) {
1273
+ return "Hello,";
1274
+ }
1275
+ const label = normalizeNameLabel(recipient).replace(/^(?:A|An|The)\s+/g, "").trim();
1276
+ if (!label) {
1277
+ return "Hello,";
1278
+ }
1279
+ return `Dear ${label},`;
1280
+ }
1281
+
1282
+ function inferEmailSubject(params: { explicitSubject: string; bodyText: string; topicText: string }): string {
1283
+ const explicit = normalizeDraftText(params.explicitSubject);
1284
+ if (explicit) {
1285
+ return explicit;
1286
+ }
1287
+ const basis = normalizeDraftText(params.bodyText || params.topicText);
1288
+ const lower = basis.toLowerCase();
1289
+ if (!basis) {
1290
+ return "Quick Update";
1291
+ }
1292
+ 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)) {
1293
+ return "Unable to Make It Today";
1294
+ }
1295
+ if (/can't make it|cannot make it|unable to make it|won't be able to/.test(lower)) {
1296
+ return "Unable to Make It";
1297
+ }
1298
+ if (/miss(ing)? class.*today|today.*miss(ing)? class/.test(lower)) {
1299
+ return "Absence from Class Today";
1300
+ }
1301
+ if (/miss(ing)? class|absence|absent/.test(lower)) {
1302
+ return "Absence from Class";
1303
+ }
1304
+ if (/running late|late/.test(lower)) {
1305
+ return "Running Late";
1306
+ }
1307
+ const clipped = basis.length > 64 ? basis.slice(0, 64).trim() : basis;
1308
+ return titleCase(clipped.replace(/[.!?]+$/g, ""));
1309
+ }
1310
+
1311
+ function extractRecipientAndHint(input: string): {
1312
+ recipientChunk: string;
1313
+ hintKind: DraftHintKind | null;
1314
+ hintText: string;
1315
+ } {
1316
+ const compact = normalizeDraftText(input);
1317
+ if (!compact) {
1318
+ return { recipientChunk: "", hintKind: null, hintText: "" };
1319
+ }
1320
+ const lowered = compact.toLowerCase();
1321
+ const separators: Array<{ token: string; kind: DraftHintKind }> = [
1322
+ { token: " that says ", kind: "body" },
1323
+ { token: " saying that ", kind: "body" },
1324
+ { token: " saying ", kind: "body" },
1325
+ { token: " with message ", kind: "body" },
1326
+ { token: " message: ", kind: "body" },
1327
+ { token: " about ", kind: "topic" },
1328
+ { token: " regarding ", kind: "topic" },
1329
+ { token: " re: ", kind: "topic" },
1330
+ { token: " that ", kind: "body" },
1331
+ ];
1332
+
1333
+ let bestIndex = -1;
1334
+ let bestSeparator: { token: string; kind: DraftHintKind } | null = null;
1335
+ for (const separator of separators) {
1336
+ const index = lowered.indexOf(separator.token);
1337
+ if (index <= 0) {
1338
+ continue;
1339
+ }
1340
+ if (bestIndex === -1 || index < bestIndex) {
1341
+ bestIndex = index;
1342
+ bestSeparator = separator;
1343
+ }
1344
+ }
1345
+
1346
+ if (!bestSeparator) {
1347
+ return {
1348
+ recipientChunk: compact,
1349
+ hintKind: null,
1350
+ hintText: "",
1351
+ };
1352
+ }
1353
+ return {
1354
+ recipientChunk: compact.slice(0, bestIndex).trim(),
1355
+ hintKind: bestSeparator.kind,
1356
+ hintText: compact.slice(bestIndex + bestSeparator.token.length).trim(),
1357
+ };
1358
+ }
1359
+
1360
+ function buildEmailDraftBody(params: {
1361
+ topicText: string;
1362
+ explicitBody: string;
1363
+ recipient: string;
1364
+ sourceText?: string;
1365
+ }): string {
1366
+ const salutation = inferEmailSalutation(params.recipient);
1367
+ const combined = normalizeDraftText([params.sourceText ?? "", params.explicitBody, params.topicText].join(" "));
1368
+ const loweredCombined = combined.toLowerCase();
1369
+ const wantsLong = /\b(long|detailed|thorough)\b/i.test(loweredCombined);
1370
+ const wantsShort = /\b(short|brief|concise)\b/i.test(loweredCombined);
1371
+ const absenceLike = /\b(can't make|cannot make|unable to|won't be able|miss(?:ing)? class|absence|absent|not attend)\b/i
1372
+ .test(loweredCombined);
1373
+
1374
+ if (absenceLike) {
1375
+ const baselineAbsence = /\btoday\b/i.test(loweredCombined)
1376
+ ? "I won't be able to attend class today"
1377
+ : "I won't be able to attend class";
1378
+ const absenceClause = ensureFirstPersonClause(params.explicitBody) || baselineAbsence;
1379
+ const detailParagraph = wantsLong
1380
+ ? "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."
1381
+ : "I will review the material covered and catch up on any notes or assignments.";
1382
+
1383
+ if (wantsShort) {
1384
+ return `${salutation}\n\n${absenceClause}.\n\nThank you for your understanding.\n\nBest,\n[Your Name]`;
1385
+ }
1386
+
1387
+ 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]`;
1388
+ }
1389
+
1390
+ const explicitSentence = toSentence(params.explicitBody);
1391
+ if (explicitSentence) {
1392
+ return `${salutation}\n\n${explicitSentence}\n\nPlease let me know if you need anything else from me.\n\nBest,\n[Your Name]`;
1393
+ }
1394
+ const topicClause = toClause(params.topicText);
1395
+ if (topicClause) {
1396
+ const detailLine = wantsLong
1397
+ ? "I wanted to share a bit more context so you have the full picture."
1398
+ : "I wanted to share a quick update.";
1399
+ return `${salutation}\n\n${detailLine} I wanted to reach out regarding ${topicClause}.\n\nThank you for your time.\n\nBest,\n[Your Name]`;
1400
+ }
1401
+ return `${salutation}\n\nI wanted to share a quick update.\n\nThank you for your time.\n\nBest,\n[Your Name]`;
1402
+ }
1403
+
1404
+ function buildMessageDraftBody(params: { topicText: string; explicitBody: string; recipient: string }): string {
1405
+ const firstLabel = normalizeNameLabel(params.recipient).split(/\s+/)[0] || params.recipient;
1406
+ const explicitSentence = toSentence(params.explicitBody);
1407
+ if (explicitSentence) {
1408
+ const apology = /can't make|cannot make|unable to|won't be able|miss class|absence|late/i.test(explicitSentence)
1409
+ ? " Sorry for the short notice."
1410
+ : "";
1411
+ return `Hey ${firstLabel}, ${explicitSentence}${apology}`;
1412
+ }
1413
+ const topicSentence = toSentence(params.topicText);
1414
+ if (topicSentence) {
1415
+ return `Hey ${firstLabel}, just wanted to message you about ${topicSentence}`;
1416
+ }
1417
+ return `Hey ${firstLabel}, wanted to check in when you have a minute.`;
1418
+ }
1419
+
1420
+ function parseDraftCommunicationIntent(params: {
1421
+ text: string;
1422
+ peopleAliases?: MemoryContextPayload["aliases"]["people"];
1423
+ }): DraftCommunicationIntent | null {
1424
+ const text = params.text.replace(/\s+/g, " ").trim();
1425
+ if (!text) {
1426
+ return null;
1427
+ }
1428
+ const intentType = classifyDraftCommunicationIntent(text);
1429
+ if (intentType === "draft_edit_request" || !intentType) {
1430
+ return null;
1431
+ }
1432
+ const actionVerbMention = /\b(write|draft|compose|create|send)\b/i.test(text);
1433
+ const communicationNounMention = /\b(email|mail|text|message|imessage|sms|draft)\b/i.test(text);
1434
+ if (!actionVerbMention && !communicationNounMention) {
1435
+ return null;
1436
+ }
1437
+
1438
+ const emailMention = /\b(email|mail)\b/i.test(text);
1439
+ const messageMention = /\b(text|message|imessage|sms)\b/i.test(text);
1440
+ const genericDraftToMention =
1441
+ /(?:write|draft|compose|create)(?:\s+me)?\s+(?:an?\s+)?(?:[a-z]+\s+){0,3}draft\s+(?:for|to)\b/i.test(text) ||
1442
+ /\bdraft\s+(?:for|to)\b/i.test(text) ||
1443
+ /(?:write|draft|compose|create)\s+to\b/i.test(text);
1444
+ if (!emailMention && !messageMention && !genericDraftToMention) {
1445
+ return null;
1446
+ }
1447
+
1448
+ let channel: "email" | "message" = emailMention ? "email" : "message";
1449
+ if (!emailMention && !messageMention) {
1450
+ channel = "email";
1451
+ }
1452
+ if (emailMention && messageMention) {
1453
+ const lowered = text.toLowerCase();
1454
+ const emailIndex = lowered.search(/\b(email|mail)\b/);
1455
+ const messageIndex = lowered.search(/\b(text|message|imessage|sms)\b/);
1456
+ channel = emailIndex !== -1 && (messageIndex === -1 || emailIndex < messageIndex) ? "email" : "message";
1457
+ }
1458
+
1459
+ let recipientChunk = "";
1460
+ let hintKind: DraftHintKind | null = null;
1461
+ let hintText = "";
1462
+ const recipientPatterns = [
1463
+ /(?: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,
1464
+ /(?:write|draft|compose|create)(?:\s+me)?\s+(?:an?\s+)?(?:[a-z]+\s+){0,3}draft\s+(?:for|to)\s+(.+)$/i,
1465
+ /(?:email|mail|text(?:\s+message)?|message)\s+(?:for|to)\s+(.+)$/i,
1466
+ /^\s*(?:email|mail|text(?:\s+message)?|message)\s+(.+)$/i,
1467
+ /\bdraft\s+(?:for|to)\s+(.+)$/i,
1468
+ /(?:write|draft|compose|create)\s+to\s+(.+)$/i,
1469
+ ];
1470
+ for (const pattern of recipientPatterns) {
1471
+ const match = text.match(pattern);
1472
+ if (match?.[1]) {
1473
+ const extracted = extractRecipientAndHint(match[1].trim());
1474
+ recipientChunk = extracted.recipientChunk;
1475
+ hintKind = extracted.hintKind;
1476
+ hintText = extracted.hintText;
1477
+ break;
1478
+ }
1479
+ }
1480
+ if (!recipientChunk) {
1481
+ const fallbackRecipientMatch = text.match(/\b(?:to|for)\s+(.+)$/i);
1482
+ if (fallbackRecipientMatch?.[1]) {
1483
+ const extracted = extractRecipientAndHint(fallbackRecipientMatch[1].trim());
1484
+ recipientChunk = extracted.recipientChunk;
1485
+ hintKind = extracted.hintKind;
1486
+ hintText = extracted.hintText;
1487
+ }
1488
+ }
1489
+ if (!recipientChunk) {
1490
+ return null;
1491
+ }
1492
+
1493
+ const recipients = normalizeRecipientList(
1494
+ recipientChunk,
1495
+ params.peopleAliases,
1496
+ channel === "message",
1497
+ );
1498
+ if (recipients.length === 0) {
1499
+ return null;
1500
+ }
1501
+
1502
+ const subjectMatch = text.match(/\bsubject(?:\s*[:\-]\s*|\s+)(.+?)(?=\s+(?:body|message|that says|saying)\b|$)/i);
1503
+ const messageMatch = text.match(/\b(?:that says|saying(?: that)?|with message|message:)\s+(.+)$/i);
1504
+ const topicMatch = text.match(/\b(?:about|regarding|re(?:\:)?)(?:\s+the)?\s+(.+)$/i);
1505
+ const topic = normalizeDraftText(
1506
+ (topicMatch?.[1] ?? "") || (hintKind === "topic" ? hintText : ""),
1507
+ );
1508
+ const explicitBody = normalizeDraftText(
1509
+ (messageMatch?.[1] ?? "") || (hintKind === "body" ? hintText : ""),
1510
+ );
1511
+
1512
+ if (channel === "email") {
1513
+ const subject = inferEmailSubject({
1514
+ explicitSubject: subjectMatch?.[1] ?? "",
1515
+ bodyText: explicitBody,
1516
+ topicText: topic,
1517
+ });
1518
+ const body = buildEmailDraftBody({
1519
+ topicText: topic,
1520
+ explicitBody,
1521
+ recipient: recipients[0],
1522
+ sourceText: text,
1523
+ });
1524
+ return {
1525
+ intentType,
1526
+ toolName: "mail_send",
1527
+ args: {
1528
+ to: recipients,
1529
+ subject,
1530
+ body,
1531
+ },
1532
+ };
1533
+ }
1534
+
1535
+ const body = buildMessageDraftBody({
1536
+ topicText: topic,
1537
+ explicitBody,
1538
+ recipient: recipients[0],
1539
+ });
1540
+ return {
1541
+ intentType,
1542
+ toolName: "messages_send",
1543
+ args: {
1544
+ to: recipients,
1545
+ body,
1546
+ },
1547
+ };
1548
+ }
1549
+
997
1550
  function extractDirectMusicIntent(messages: ChatMessageInput[], aliases?: MemoryMusicAlias[]): {
998
1551
  query: string;
999
1552
  title: string | null;
@@ -1482,59 +2035,111 @@ async function tryDirectAutomationFastPath(params: {
1482
2035
  }
1483
2036
  }
1484
2037
 
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) {
2038
+ const runDraftableCommunication = async (intent: DraftCommunicationIntent): Promise<boolean> => {
1491
2039
  if (!params.localTools.enableMail) {
1492
2040
  params.send({
1493
2041
  type: "token",
1494
2042
  value:
1495
- "Messaging tools are disabled. Enable **mail/messages tools** in Settings > Local Tools, then retry.",
2043
+ "Mail/messages tools are disabled. Enable **mail/messages tools** in Settings > Local Tools, then retry.",
1496
2044
  });
1497
2045
  return true;
1498
2046
  }
1499
2047
 
1500
- const messageTool = findToolByName(tools, "messages_send");
1501
- if (messageTool) {
1502
- if (requiresApprovalForRisk(messageTool.risk, params.localTools.approvalMode)) {
1503
- return false;
1504
- }
2048
+ const tool = findToolByName(tools, intent.toolName);
2049
+ if (!tool) {
2050
+ return false;
2051
+ }
1505
2052
 
1506
- const callId = "fastpath_messages_send";
1507
- const args = {
1508
- to: messageIntent.to,
1509
- body: messageIntent.body,
1510
- };
2053
+ const requiresDraftReview =
2054
+ isPreSendDraftTool(tool.name) &&
2055
+ (params.localTools.preSendDraftReview || intent.intentType === "explicit_draft_request");
2056
+ if (!requiresDraftReview && requiresApprovalForRisk(tool.risk, params.localTools.approvalMode)) {
2057
+ return false;
2058
+ }
2059
+
2060
+ const callId = intent.toolName === "mail_send" ? "fastpath_mail_send" : "fastpath_messages_send";
2061
+ let executionArgs: Record<string, unknown> = { ...intent.args };
2062
+ params.send({
2063
+ type: "tool_call",
2064
+ callId,
2065
+ name: tool.name,
2066
+ args: intent.args,
2067
+ });
2068
+
2069
+ if (requiresDraftReview) {
2070
+ const approval = createApprovalRequest();
1511
2071
  params.send({
1512
- type: "tool_call",
2072
+ type: "approval_requested",
2073
+ approvalId: approval.approvalId,
1513
2074
  callId,
1514
- name: messageTool.name,
1515
- args,
2075
+ name: tool.name,
2076
+ args: intent.args,
2077
+ reason: summarizeApprovalReason(tool.name, intent.args),
1516
2078
  });
2079
+ const approvalResolution = await approval.promise;
1517
2080
  params.send({
1518
- type: "tool_progress",
2081
+ type: "approval_resolved",
2082
+ approvalId: approval.approvalId,
1519
2083
  callId,
1520
- name: messageTool.name,
1521
- status: "started",
1522
- message: "Sending message.",
2084
+ decision: approvalResolution.decision,
2085
+ message:
2086
+ approvalResolution.decision === "approve"
2087
+ ? "Approved"
2088
+ : approvalResolution.decision === "supersede"
2089
+ ? "Replaced"
2090
+ : approvalResolution.decision === "timeout"
2091
+ ? "Timed out"
2092
+ : "Denied",
1523
2093
  });
2094
+ if (approvalResolution.decision !== "approve") {
2095
+ if (approvalResolution.decision === "deny") {
2096
+ params.send({
2097
+ type: "token",
2098
+ value: "Draft discarded. Nothing was sent.",
2099
+ });
2100
+ } else if (approvalResolution.decision === "timeout") {
2101
+ params.send({
2102
+ type: "token",
2103
+ value: "Draft review expired. Nothing was sent.",
2104
+ });
2105
+ }
2106
+ return true;
2107
+ }
2108
+ if (
2109
+ approvalResolution.args &&
2110
+ typeof approvalResolution.args === "object" &&
2111
+ !Array.isArray(approvalResolution.args)
2112
+ ) {
2113
+ executionArgs = approvalResolution.args;
2114
+ }
2115
+ }
1524
2116
 
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
- });
2117
+ const progressMessage = tool.name === "mail_send" ? "Sending email." : "Sending message.";
2118
+ params.send({
2119
+ type: "tool_progress",
2120
+ callId,
2121
+ name: tool.name,
2122
+ status: "started",
2123
+ message: progressMessage,
2124
+ });
2125
+
2126
+ try {
2127
+ const output = await tool.execute(executionArgs, {
2128
+ localTools: params.localTools,
2129
+ signal: params.signal,
2130
+ });
2131
+ params.send({
2132
+ type: "tool_result",
2133
+ callId,
2134
+ name: tool.name,
2135
+ ok: true,
2136
+ result: output,
2137
+ });
1537
2138
 
2139
+ const argsRecipients = Array.isArray(executionArgs.to)
2140
+ ? executionArgs.to.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
2141
+ : [];
2142
+ if (tool.name === "messages_send") {
1538
2143
  const outputRecord =
1539
2144
  output && typeof output === "object"
1540
2145
  ? (output as { recipients?: Array<{ name?: string | null; input?: string; handle?: string }> })
@@ -1542,30 +2147,66 @@ async function tryDirectAutomationFastPath(params: {
1542
2147
  const recipientLabel =
1543
2148
  outputRecord?.recipients?.map((recipient) => recipient.name || recipient.input || recipient.handle)
1544
2149
  .filter(Boolean)
1545
- .join(", ") || messageIntent.to.join(", ");
1546
-
2150
+ .join(", ") || argsRecipients.join(", ");
1547
2151
  params.send({
1548
2152
  type: "token",
1549
2153
  value: `**Done.** Sent message to ${recipientLabel}.`,
1550
2154
  });
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
- });
2155
+ } else {
2156
+ const recipientLabel = argsRecipients.join(", ");
1564
2157
  params.send({
1565
2158
  type: "token",
1566
- value: `I couldn't send that message: ${message}.`,
2159
+ value: `**Done.** Sent email to ${recipientLabel}.`,
1567
2160
  });
1568
2161
  }
2162
+ } catch (error) {
2163
+ const fallback = tool.name === "mail_send" ? "Could not send email." : "Could not send message.";
2164
+ const message = formatToolErrorForUser(tool.name, error, fallback);
2165
+ params.send({
2166
+ type: "tool_result",
2167
+ callId,
2168
+ name: tool.name,
2169
+ ok: false,
2170
+ result: { error: message },
2171
+ });
2172
+ params.send({
2173
+ type: "token",
2174
+ value:
2175
+ tool.name === "mail_send"
2176
+ ? `I couldn't send that email: ${message}.`
2177
+ : `I couldn't send that message: ${message}.`,
2178
+ });
2179
+ }
2180
+ return true;
2181
+ };
2182
+
2183
+ // Fast path 2: direct text/iMessage
2184
+ const messageIntent = parseDirectMessageIntent({
2185
+ text: lastUserText,
2186
+ peopleAliases: params.memory?.enabled ? params.memory.aliases.people : [],
2187
+ });
2188
+ if (messageIntent) {
2189
+ const handled = await runDraftableCommunication({
2190
+ intentType: "send_request",
2191
+ toolName: "messages_send",
2192
+ args: {
2193
+ to: messageIntent.to,
2194
+ body: messageIntent.body,
2195
+ },
2196
+ });
2197
+ if (handled) {
2198
+ return true;
2199
+ }
2200
+ }
2201
+
2202
+ // Fast path 2b: draft or general write/send email/message
2203
+ const draftCommunicationIntent = parseDraftCommunicationIntent({
2204
+ text: lastUserText,
2205
+ peopleAliases: params.memory?.enabled ? params.memory.aliases.people : [],
2206
+ });
2207
+ if (draftCommunicationIntent) {
2208
+ const handled = await runDraftableCommunication(draftCommunicationIntent);
2209
+ if (handled) {
1569
2210
  return true;
1570
2211
  }
1571
2212
  }
@@ -2040,13 +2681,15 @@ async function runToolOrchestrator(params: {
2040
2681
  messages: ChatMessageInput[];
2041
2682
  localTools: LocalToolsSettings;
2042
2683
  enableWebSources: boolean;
2684
+ forceDraftReviewForSend?: boolean;
2685
+ draftEditContext?: DraftEditPromptContext;
2043
2686
  send: (chunk: ChatStreamChunk) => void;
2044
2687
  signal?: AbortSignal;
2045
2688
  }) {
2046
2689
  const { tools, session } = getToolSession({
2047
2690
  connection: params.connection,
2048
2691
  model: params.model,
2049
- system: buildToolSystemPrompt(params.system, params.messages),
2692
+ system: buildToolSystemPrompt(params.system, params.messages, params.draftEditContext),
2050
2693
  messages: params.messages,
2051
2694
  localTools: params.localTools,
2052
2695
  enableWebSources: params.enableWebSources,
@@ -2157,7 +2800,10 @@ async function runToolOrchestrator(params: {
2157
2800
  continue;
2158
2801
  }
2159
2802
 
2160
- if (requiresApprovalForRisk(tool.risk, params.localTools.approvalMode)) {
2803
+ const requiresDraftReview =
2804
+ isPreSendDraftTool(call.name) &&
2805
+ (params.localTools.preSendDraftReview || params.forceDraftReviewForSend === true);
2806
+ if (requiresDraftReview || requiresApprovalForRisk(tool.risk, params.localTools.approvalMode)) {
2161
2807
  const approval = createApprovalRequest();
2162
2808
  params.send({
2163
2809
  type: "approval_requested",
@@ -2169,23 +2815,47 @@ async function runToolOrchestrator(params: {
2169
2815
  });
2170
2816
 
2171
2817
  emitThinkingUpdate(`Waiting for approval to run ${call.name}.`);
2172
- const decision = await approval.promise;
2818
+ const approvalResolution = await approval.promise;
2173
2819
  params.send({
2174
2820
  type: "approval_resolved",
2175
2821
  approvalId: approval.approvalId,
2176
2822
  callId: call.id,
2177
- decision,
2823
+ decision: approvalResolution.decision,
2178
2824
  message:
2179
- decision === "approve"
2825
+ approvalResolution.decision === "approve"
2180
2826
  ? "Approved"
2181
- : decision === "timeout"
2827
+ : approvalResolution.decision === "supersede"
2828
+ ? "Replaced"
2829
+ : approvalResolution.decision === "timeout"
2182
2830
  ? "Timed out"
2183
2831
  : "Denied",
2184
2832
  });
2185
2833
 
2186
- if (decision !== "approve") {
2834
+ if (approvalResolution.decision !== "approve") {
2835
+ if (requiresDraftReview && isPreSendDraftTool(call.name)) {
2836
+ const message =
2837
+ approvalResolution.decision === "deny"
2838
+ ? "Draft discarded. Nothing was sent."
2839
+ : approvalResolution.decision === "timeout"
2840
+ ? "Draft review expired. Nothing was sent."
2841
+ : "Draft replaced by a newer instruction.";
2842
+ if (approvalResolution.decision !== "supersede") {
2843
+ params.send({
2844
+ type: "token",
2845
+ value: message,
2846
+ });
2847
+ }
2848
+ session.addToolResult({
2849
+ callId: call.id,
2850
+ name: call.name,
2851
+ result: message,
2852
+ isError: false,
2853
+ });
2854
+ continue;
2855
+ }
2856
+
2187
2857
  const message =
2188
- decision === "timeout"
2858
+ approvalResolution.decision === "timeout"
2189
2859
  ? "Tool call timed out waiting for approval."
2190
2860
  : "Tool call denied by user.";
2191
2861
  params.send({
@@ -2203,6 +2873,13 @@ async function runToolOrchestrator(params: {
2203
2873
  });
2204
2874
  continue;
2205
2875
  }
2876
+ if (
2877
+ approvalResolution.args &&
2878
+ typeof approvalResolution.args === "object" &&
2879
+ !Array.isArray(approvalResolution.args)
2880
+ ) {
2881
+ callInput = approvalResolution.args;
2882
+ }
2206
2883
  }
2207
2884
 
2208
2885
  params.send({
@@ -2330,6 +3007,7 @@ export async function POST(request: Request) {
2330
3007
  webSourcesEnabled && supportsWebSourcesViaOpenAI(connection);
2331
3008
  const localToolsRequested = normalizeLocalTools(body.localTools);
2332
3009
  const memoryContext = sanitizeMemoryContext(body.memory);
3010
+ const requestMeta = sanitizeChatRequestMeta(body.meta);
2333
3011
  const encoder = new TextEncoder();
2334
3012
 
2335
3013
  const stream = new ReadableStream({
@@ -2369,28 +3047,36 @@ export async function POST(request: Request) {
2369
3047
  }
2370
3048
 
2371
3049
  if (localTools.enabled) {
2372
- const routeThroughTools = shouldRouteThroughToolOrchestrator(sanitizedMessages);
3050
+ const lastUserText =
3051
+ [...sanitizedMessages].reverse().find((message) => message.role === "user")?.content ?? "";
3052
+ const communicationIntentType = classifyDraftCommunicationIntent(lastUserText);
3053
+ const forceDraftReviewForSend =
3054
+ communicationIntentType === "explicit_draft_request" || Boolean(requestMeta.draftEditContext);
3055
+ const routeThroughTools =
3056
+ requestMeta.forceToolRouting || shouldRouteThroughToolOrchestrator(sanitizedMessages);
2373
3057
  if (routeThroughTools) {
2374
3058
  sendThinking("update", "Checking whether this can run through a direct action path.");
2375
3059
  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
- });
3060
+ if (!requestMeta.forceToolRouting) {
3061
+ try {
3062
+ fastHandled = await tryDirectAutomationFastPath({
3063
+ localTools,
3064
+ messages: sanitizedMessages,
3065
+ memory: memoryContext,
3066
+ send,
3067
+ signal: request.signal,
3068
+ });
3069
+ } catch (error) {
3070
+ const message =
3071
+ error instanceof Error ? truncateMessage(error.message) : "Fast-path unavailable.";
3072
+ send({
3073
+ type: "tool_progress",
3074
+ callId: "tooling",
3075
+ name: "tooling",
3076
+ status: "warning",
3077
+ message: `Direct automation fast-path failed: ${message}. Continuing with tool orchestration.`,
3078
+ });
3079
+ }
2394
3080
  }
2395
3081
 
2396
3082
  if (fastHandled) {
@@ -2407,6 +3093,8 @@ export async function POST(request: Request) {
2407
3093
  messages: sanitizedMessages,
2408
3094
  localTools,
2409
3095
  enableWebSources: enableWebSourcesForConnection,
3096
+ forceDraftReviewForSend,
3097
+ draftEditContext: requestMeta.draftEditContext,
2410
3098
  send,
2411
3099
  signal: request.signal,
2412
3100
  });