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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/bin/iris.mjs +267 -0
  4. package/package.json +61 -0
  5. package/template/LICENSE +21 -0
  6. package/template/README.md +49 -0
  7. package/template/eslint.config.mjs +18 -0
  8. package/template/next.config.ts +7 -0
  9. package/template/package-lock.json +9193 -0
  10. package/template/package.json +46 -0
  11. package/template/postcss.config.mjs +7 -0
  12. package/template/public/file.svg +1 -0
  13. package/template/public/globe.svg +1 -0
  14. package/template/public/next.svg +1 -0
  15. package/template/public/vercel.svg +1 -0
  16. package/template/public/window.svg +1 -0
  17. package/template/src/app/api/chat/route.ts +2445 -0
  18. package/template/src/app/api/connections/models/route.ts +255 -0
  19. package/template/src/app/api/connections/test/route.ts +124 -0
  20. package/template/src/app/api/local-sync/route.ts +74 -0
  21. package/template/src/app/api/tool-approval/route.ts +47 -0
  22. package/template/src/app/favicon.ico +0 -0
  23. package/template/src/app/globals.css +808 -0
  24. package/template/src/app/layout.tsx +74 -0
  25. package/template/src/app/page.tsx +444 -0
  26. package/template/src/components/ChatView.tsx +1537 -0
  27. package/template/src/components/Composer.tsx +160 -0
  28. package/template/src/components/MapView.tsx +244 -0
  29. package/template/src/components/MessageCard.tsx +955 -0
  30. package/template/src/components/SearchModal.tsx +72 -0
  31. package/template/src/components/SettingsModal.tsx +1257 -0
  32. package/template/src/components/Sidebar.tsx +153 -0
  33. package/template/src/components/TopBar.tsx +164 -0
  34. package/template/src/lib/connections.ts +275 -0
  35. package/template/src/lib/data.ts +324 -0
  36. package/template/src/lib/db.ts +49 -0
  37. package/template/src/lib/hooks.ts +76 -0
  38. package/template/src/lib/local-sync.ts +192 -0
  39. package/template/src/lib/memory.ts +695 -0
  40. package/template/src/lib/model-presets.ts +251 -0
  41. package/template/src/lib/store.ts +36 -0
  42. package/template/src/lib/tooling/approvals.ts +78 -0
  43. package/template/src/lib/tooling/providers/anthropic.ts +155 -0
  44. package/template/src/lib/tooling/providers/ollama.ts +73 -0
  45. package/template/src/lib/tooling/providers/openai.ts +267 -0
  46. package/template/src/lib/tooling/providers/openai_compatible.ts +16 -0
  47. package/template/src/lib/tooling/providers/types.ts +44 -0
  48. package/template/src/lib/tooling/registry.ts +103 -0
  49. package/template/src/lib/tooling/runtime.ts +189 -0
  50. package/template/src/lib/tooling/safety.ts +165 -0
  51. package/template/src/lib/tooling/tools/apps.ts +108 -0
  52. package/template/src/lib/tooling/tools/apps_plus.ts +153 -0
  53. package/template/src/lib/tooling/tools/communication.ts +883 -0
  54. package/template/src/lib/tooling/tools/files.ts +395 -0
  55. package/template/src/lib/tooling/tools/music.ts +988 -0
  56. package/template/src/lib/tooling/tools/notes.ts +461 -0
  57. package/template/src/lib/tooling/tools/notes_plus.ts +294 -0
  58. package/template/src/lib/tooling/tools/numbers.ts +175 -0
  59. package/template/src/lib/tooling/tools/schedule.ts +579 -0
  60. package/template/src/lib/tooling/tools/system.ts +142 -0
  61. package/template/src/lib/tooling/tools/web.ts +212 -0
  62. package/template/src/lib/tooling/tools/workflow.ts +218 -0
  63. package/template/src/lib/tooling/types.ts +27 -0
  64. package/template/src/lib/types.ts +309 -0
  65. package/template/src/lib/utils.ts +108 -0
  66. package/template/tsconfig.json +34 -0
@@ -0,0 +1,883 @@
1
+ import { assertAllowedPath, ensureMacOS } from "../safety";
2
+ import { runCommandSafe } from "../runtime";
3
+ import type { ToolDefinition, ToolExecutionContext } from "../types";
4
+
5
+ type MailInput = {
6
+ to?: string[];
7
+ subject?: string;
8
+ body?: string;
9
+ cc?: string[];
10
+ attachments?: string[];
11
+ };
12
+
13
+ type MessageDraftInput = {
14
+ to?: string[];
15
+ body?: string;
16
+ };
17
+
18
+ type MessageSendInput = MessageDraftInput;
19
+
20
+ type ContactRecord = {
21
+ fullName: string;
22
+ firstName: string;
23
+ lastName: string;
24
+ phones: string[];
25
+ emails: string[];
26
+ };
27
+
28
+ type ContactMatch = {
29
+ contact: ContactRecord;
30
+ score: number;
31
+ exactName: boolean;
32
+ exactCompact: boolean;
33
+ };
34
+
35
+ type ResolvedMessageRecipient = {
36
+ input: string;
37
+ name: string | null;
38
+ handle: string;
39
+ source: "contact" | "direct";
40
+ };
41
+
42
+ const APPLESCRIPT_TIMEOUT_MS = 45_000;
43
+ const CONTACT_RECORD_DELIMITER = String.fromCharCode(30);
44
+ const CONTACT_FIELD_DELIMITER = String.fromCharCode(31);
45
+ const CONTACT_VALUE_DELIMITER = String.fromCharCode(29);
46
+ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
47
+ const PHONE_HANDLE_REGEX = /^[+]?[\d().\-\s]{7,}$/;
48
+ const CONTACT_LOOKUP_TIMEOUT_MS = 12_000;
49
+ const CONTACT_QUERY_CACHE_TTL_MS = 5 * 60 * 1000;
50
+ const contactQueryCache = new Map<string, { expiresAt: number; contacts: ContactRecord[] }>();
51
+
52
+ function asObject(input: unknown): Record<string, unknown> {
53
+ if (!input || typeof input !== "object") {
54
+ throw new Error("Tool input must be an object.");
55
+ }
56
+ return input as Record<string, unknown>;
57
+ }
58
+
59
+ function asString(input: unknown, field: string): string {
60
+ if (typeof input !== "string" || !input.trim()) {
61
+ throw new Error(`Missing required string field: ${field}`);
62
+ }
63
+ return input.trim();
64
+ }
65
+
66
+ function asStringArray(input: unknown, field: string): string[] {
67
+ if (!Array.isArray(input) || input.length === 0) {
68
+ throw new Error(`Missing required array field: ${field}`);
69
+ }
70
+ const values = input
71
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
72
+ .filter(Boolean);
73
+ if (values.length === 0) {
74
+ throw new Error(`Missing required array field: ${field}`);
75
+ }
76
+ return values;
77
+ }
78
+
79
+ function dedupeList(values: string[]): string[] {
80
+ const out: string[] = [];
81
+ const seen = new Set<string>();
82
+ for (const value of values) {
83
+ const trimmed = value.trim();
84
+ if (!trimmed || seen.has(trimmed)) {
85
+ continue;
86
+ }
87
+ seen.add(trimmed);
88
+ out.push(trimmed);
89
+ }
90
+ return out;
91
+ }
92
+
93
+ function readContactQueryCache(query: string): ContactRecord[] | null {
94
+ const key = normalizeNameForMatch(query);
95
+ if (!key) {
96
+ return null;
97
+ }
98
+ const cached = contactQueryCache.get(key);
99
+ if (!cached) {
100
+ return null;
101
+ }
102
+ if (cached.expiresAt <= Date.now()) {
103
+ contactQueryCache.delete(key);
104
+ return null;
105
+ }
106
+ return cached.contacts;
107
+ }
108
+
109
+ function writeContactQueryCache(query: string, contacts: ContactRecord[]): void {
110
+ const key = normalizeNameForMatch(query);
111
+ if (!key) {
112
+ return;
113
+ }
114
+ contactQueryCache.set(key, {
115
+ expiresAt: Date.now() + CONTACT_QUERY_CACHE_TTL_MS,
116
+ contacts,
117
+ });
118
+
119
+ if (contactQueryCache.size > 200) {
120
+ const now = Date.now();
121
+ for (const [cacheKey, value] of contactQueryCache) {
122
+ if (value.expiresAt <= now) {
123
+ contactQueryCache.delete(cacheKey);
124
+ }
125
+ if (contactQueryCache.size <= 160) {
126
+ break;
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ function normalizeNameForMatch(value: string): string {
133
+ return value
134
+ .normalize("NFKD")
135
+ .replace(/[\u0300-\u036f]/g, "")
136
+ .toLowerCase()
137
+ .replace(/[^a-z0-9]+/g, " ")
138
+ .trim()
139
+ .replace(/\s+/g, " ");
140
+ }
141
+
142
+ function compactName(value: string): string {
143
+ return value.replace(/\s+/g, "");
144
+ }
145
+
146
+ function normalizePhoneHandle(value: string): string {
147
+ const trimmed = value.trim();
148
+ if (!trimmed) {
149
+ return "";
150
+ }
151
+ const hasPlusPrefix = trimmed.startsWith("+");
152
+ const digits = trimmed.replace(/[^\d]/g, "");
153
+ if (!digits) {
154
+ return "";
155
+ }
156
+ return hasPlusPrefix ? `+${digits}` : digits;
157
+ }
158
+
159
+ function parseDelimitedValues(value: string | undefined): string[] {
160
+ if (!value) {
161
+ return [];
162
+ }
163
+ return dedupeList(
164
+ value
165
+ .split(CONTACT_VALUE_DELIMITER)
166
+ .map((entry) => entry.trim())
167
+ .filter(Boolean),
168
+ );
169
+ }
170
+
171
+ function contactDisplayName(contact: ContactRecord): string {
172
+ const primary = contact.fullName.trim();
173
+ if (primary) {
174
+ return primary;
175
+ }
176
+ const fallback = [contact.firstName.trim(), contact.lastName.trim()]
177
+ .filter(Boolean)
178
+ .join(" ")
179
+ .trim();
180
+ return fallback || "Unnamed contact";
181
+ }
182
+
183
+ function maybeDirectMessageHandle(input: string): string | null {
184
+ const trimmed = input.trim();
185
+ if (!trimmed) {
186
+ return null;
187
+ }
188
+ if (EMAIL_REGEX.test(trimmed)) {
189
+ return trimmed;
190
+ }
191
+ if (!PHONE_HANDLE_REGEX.test(trimmed)) {
192
+ return null;
193
+ }
194
+ const digits = trimmed.replace(/[^\d]/g, "");
195
+ if (!digits || digits.length < 7) {
196
+ return null;
197
+ }
198
+ return normalizePhoneHandle(trimmed);
199
+ }
200
+
201
+ function selectPreferredMessageHandle(contact: ContactRecord): string | null {
202
+ const phones = dedupeList(contact.phones.map(normalizePhoneHandle).filter(Boolean));
203
+ if (phones.length > 0) {
204
+ return phones[0];
205
+ }
206
+ const emails = dedupeList(
207
+ contact.emails
208
+ .map((value) => value.trim())
209
+ .filter((value) => EMAIL_REGEX.test(value)),
210
+ );
211
+ if (emails.length > 0) {
212
+ return emails[0];
213
+ }
214
+ return null;
215
+ }
216
+
217
+ function formatContactOption(contact: ContactRecord): string {
218
+ const name = contactDisplayName(contact);
219
+ const handle = selectPreferredMessageHandle(contact);
220
+ return handle ? `${name} (${handle})` : name;
221
+ }
222
+
223
+ function findContactMatches(query: string, contacts: ContactRecord[]): ContactMatch[] {
224
+ const queryNormalized = normalizeNameForMatch(query);
225
+ if (!queryNormalized) {
226
+ return [];
227
+ }
228
+ const queryCompact = compactName(queryNormalized);
229
+ const queryTokens = queryNormalized.split(" ").filter(Boolean);
230
+
231
+ const matches: ContactMatch[] = [];
232
+ for (const contact of contacts) {
233
+ const display = contactDisplayName(contact);
234
+ const normalizedName = normalizeNameForMatch(display);
235
+ if (!normalizedName) {
236
+ continue;
237
+ }
238
+ const compact = compactName(normalizedName);
239
+ const exactName = normalizedName === queryNormalized;
240
+ const exactCompact = compact === queryCompact;
241
+ const nameTokens = normalizedName.split(" ").filter(Boolean);
242
+ const containsMatch = queryNormalized.length >= 2 && normalizedName.includes(queryNormalized);
243
+ const tokenPrefixMatch =
244
+ queryTokens.length > 0 &&
245
+ queryTokens.every((token) =>
246
+ nameTokens.some((nameToken) => nameToken.startsWith(token)),
247
+ );
248
+
249
+ if (!(exactName || exactCompact || containsMatch || tokenPrefixMatch)) {
250
+ continue;
251
+ }
252
+
253
+ let score = 0;
254
+ if (exactName) {
255
+ score += 500;
256
+ }
257
+ if (exactCompact) {
258
+ score += 430;
259
+ }
260
+ if (queryNormalized.length >= 2 && normalizedName.startsWith(queryNormalized)) {
261
+ score += 250;
262
+ }
263
+ if (containsMatch) {
264
+ score += 170;
265
+ }
266
+ if (tokenPrefixMatch) {
267
+ score += 220;
268
+ }
269
+
270
+ score += Math.max(0, 30 - Math.abs(nameTokens.length - queryTokens.length) * 10);
271
+ matches.push({ contact, score, exactName, exactCompact });
272
+ }
273
+
274
+ matches.sort((left, right) => {
275
+ if (right.score !== left.score) {
276
+ return right.score - left.score;
277
+ }
278
+ return contactDisplayName(left.contact).localeCompare(contactDisplayName(right.contact));
279
+ });
280
+
281
+ return matches;
282
+ }
283
+
284
+ function notFoundContactError(query: string, contacts: ContactRecord[]): Error {
285
+ const queryNormalized = normalizeNameForMatch(query);
286
+ const queryTokens = queryNormalized.split(" ").filter((token) => token.length >= 2);
287
+ const suggestions = dedupeList(
288
+ contacts
289
+ .map((contact) => contactDisplayName(contact))
290
+ .filter(Boolean)
291
+ .filter((name) => {
292
+ const normalized = normalizeNameForMatch(name);
293
+ return queryTokens.some((token) => normalized.includes(token));
294
+ })
295
+ .slice(0, 4),
296
+ );
297
+
298
+ if (suggestions.length > 0) {
299
+ return new Error(
300
+ `No contact found for "${query}". Similar contacts: ${suggestions.join(", ")}. Ask the user which contact to message or provide a phone/email.`,
301
+ );
302
+ }
303
+ return new Error(
304
+ `No contact found for "${query}". Ask the user for a full contact name or a phone/email.`,
305
+ );
306
+ }
307
+
308
+ function ambiguousContactError(query: string, matches: ContactMatch[]): Error {
309
+ const candidates = dedupeList(
310
+ matches
311
+ .slice(0, 5)
312
+ .map((match) => formatContactOption(match.contact))
313
+ .filter(Boolean),
314
+ );
315
+ const choices = candidates.length > 0 ? candidates.join(", ") : "multiple contacts";
316
+ return new Error(
317
+ `Ambiguous contact name "${query}". Matching contacts: ${choices}. Ask the user which person to message.`,
318
+ );
319
+ }
320
+
321
+ function resolveContactFromMatches(query: string, matches: ContactMatch[]): ContactRecord {
322
+ if (matches.length === 0) {
323
+ throw new Error(`No matches available for "${query}".`);
324
+ }
325
+ if (matches.length === 1) {
326
+ return matches[0].contact;
327
+ }
328
+
329
+ const exactMatches = matches.filter((match) => match.exactName || match.exactCompact);
330
+ if (exactMatches.length === 1) {
331
+ return exactMatches[0].contact;
332
+ }
333
+ if (exactMatches.length > 1) {
334
+ const uniqueExactOptions = dedupeList(
335
+ exactMatches.map((match) => formatContactOption(match.contact)),
336
+ );
337
+ if (uniqueExactOptions.length === 1) {
338
+ return exactMatches[0].contact;
339
+ }
340
+ throw ambiguousContactError(query, exactMatches);
341
+ }
342
+
343
+ const queryTokenCount = normalizeNameForMatch(query).split(" ").filter(Boolean).length;
344
+ const top = matches[0];
345
+ const runnerUp = matches[1];
346
+ if (queryTokenCount >= 2 && top && runnerUp && top.score - runnerUp.score >= 150) {
347
+ return top.contact;
348
+ }
349
+
350
+ const uniqueOptions = dedupeList(matches.map((match) => formatContactOption(match.contact)));
351
+ if (uniqueOptions.length === 1) {
352
+ return matches[0].contact;
353
+ }
354
+
355
+ throw ambiguousContactError(query, matches);
356
+ }
357
+
358
+ function parseContactRows(output: string): ContactRecord[] {
359
+ if (!output) {
360
+ return [];
361
+ }
362
+
363
+ const rows = output
364
+ .split(CONTACT_RECORD_DELIMITER)
365
+ .map((row) => row.trim())
366
+ .filter(Boolean);
367
+
368
+ const contacts: ContactRecord[] = [];
369
+ const seen = new Set<string>();
370
+ for (const row of rows) {
371
+ const [fullName = "", firstName = "", lastName = "", phonesRaw = "", emailsRaw = ""] = row
372
+ .split(CONTACT_FIELD_DELIMITER);
373
+ const phones = parseDelimitedValues(phonesRaw);
374
+ const emails = parseDelimitedValues(emailsRaw);
375
+ if (phones.length === 0 && emails.length === 0) {
376
+ continue;
377
+ }
378
+ const fingerprint = [
379
+ fullName.trim(),
380
+ firstName.trim(),
381
+ lastName.trim(),
382
+ phones.join("|"),
383
+ emails.join("|"),
384
+ ].join("||");
385
+ if (seen.has(fingerprint)) {
386
+ continue;
387
+ }
388
+ seen.add(fingerprint);
389
+ contacts.push({
390
+ fullName: fullName.trim(),
391
+ firstName: firstName.trim(),
392
+ lastName: lastName.trim(),
393
+ phones,
394
+ emails,
395
+ });
396
+ }
397
+ return contacts;
398
+ }
399
+
400
+ async function getContactsMatchingQuery(query: string, signal?: AbortSignal): Promise<ContactRecord[]> {
401
+ const trimmedQuery = query.trim();
402
+ if (!trimmedQuery) {
403
+ return [];
404
+ }
405
+ const cached = readContactQueryCache(trimmedQuery);
406
+ if (cached) {
407
+ return cached;
408
+ }
409
+
410
+ const script =
411
+ "on replaceText(findText, replaceWith, sourceText)\n" +
412
+ "set AppleScript's text item delimiters to findText\n" +
413
+ "set textItems to text items of sourceText\n" +
414
+ "set AppleScript's text item delimiters to replaceWith\n" +
415
+ "set joinedText to textItems as text\n" +
416
+ "set AppleScript's text item delimiters to \"\"\n" +
417
+ "return joinedText\n" +
418
+ "end replaceText\n" +
419
+ "\n" +
420
+ "on sanitizeText(rawText)\n" +
421
+ "if rawText is missing value then return \"\"\n" +
422
+ "set cleaned to rawText as text\n" +
423
+ "set cleaned to my replaceText(return, \" \", cleaned)\n" +
424
+ "set cleaned to my replaceText(linefeed, \" \", cleaned)\n" +
425
+ "set cleaned to my replaceText(character id 29, \" \", cleaned)\n" +
426
+ "set cleaned to my replaceText(character id 30, \" \", cleaned)\n" +
427
+ "set cleaned to my replaceText(character id 31, \" \", cleaned)\n" +
428
+ "return cleaned\n" +
429
+ "end sanitizeText\n" +
430
+ "\n" +
431
+ "on joinList(values, delimiterText)\n" +
432
+ "set AppleScript's text item delimiters to delimiterText\n" +
433
+ "set joinedText to values as text\n" +
434
+ "set AppleScript's text item delimiters to \"\"\n" +
435
+ "return joinedText\n" +
436
+ "end joinList\n" +
437
+ "\n" +
438
+ "on run argv\n" +
439
+ "set queryText to item 1 of argv\n" +
440
+ "set fieldDelimiter to character id 31\n" +
441
+ "set recordDelimiter to character id 30\n" +
442
+ "set valueDelimiter to character id 29\n" +
443
+ "set outputRecords to {}\n" +
444
+ "tell application \"Contacts\"\n" +
445
+ "set matchedPeople to {}\n" +
446
+ "ignoring case\n" +
447
+ "try\n" +
448
+ "set matchedPeople to every person whose name contains queryText\n" +
449
+ "end try\n" +
450
+ "try\n" +
451
+ "set matchedPeople to matchedPeople & (every person whose first name contains queryText)\n" +
452
+ "end try\n" +
453
+ "try\n" +
454
+ "set matchedPeople to matchedPeople & (every person whose last name contains queryText)\n" +
455
+ "end try\n" +
456
+ "end ignoring\n" +
457
+ "repeat with p in matchedPeople\n" +
458
+ "set rawName to \"\"\n" +
459
+ "set rawFirstName to \"\"\n" +
460
+ "set rawLastName to \"\"\n" +
461
+ "try\n" +
462
+ "set rawName to name of p as text\n" +
463
+ "end try\n" +
464
+ "try\n" +
465
+ "set rawFirstName to first name of p as text\n" +
466
+ "end try\n" +
467
+ "try\n" +
468
+ "set rawLastName to last name of p as text\n" +
469
+ "end try\n" +
470
+ "set phoneValues to {}\n" +
471
+ "repeat with phoneEntry in phones of p\n" +
472
+ "try\n" +
473
+ "set phoneValue to value of phoneEntry as text\n" +
474
+ "if phoneValue is not \"\" then set end of phoneValues to my sanitizeText(phoneValue)\n" +
475
+ "end try\n" +
476
+ "end repeat\n" +
477
+ "set emailValues to {}\n" +
478
+ "repeat with emailEntry in emails of p\n" +
479
+ "try\n" +
480
+ "set emailValue to value of emailEntry as text\n" +
481
+ "if emailValue is not \"\" then set end of emailValues to my sanitizeText(emailValue)\n" +
482
+ "end try\n" +
483
+ "end repeat\n" +
484
+ "if (count of phoneValues) > 0 or (count of emailValues) > 0 then\n" +
485
+ "set displayName to my sanitizeText(rawName)\n" +
486
+ "set firstNameText to my sanitizeText(rawFirstName)\n" +
487
+ "set lastNameText to my sanitizeText(rawLastName)\n" +
488
+ "set phoneText to my joinList(phoneValues, valueDelimiter)\n" +
489
+ "set emailText to my joinList(emailValues, valueDelimiter)\n" +
490
+ "set rowText to displayName & fieldDelimiter & firstNameText & fieldDelimiter & lastNameText & fieldDelimiter & phoneText & fieldDelimiter & emailText\n" +
491
+ "set end of outputRecords to rowText\n" +
492
+ "end if\n" +
493
+ "end repeat\n" +
494
+ "end tell\n" +
495
+ "return my joinList(outputRecords, recordDelimiter)\n" +
496
+ "end run";
497
+
498
+ try {
499
+ const output = await runAppleScript(
500
+ script,
501
+ [trimmedQuery],
502
+ signal,
503
+ CONTACT_LOOKUP_TIMEOUT_MS,
504
+ );
505
+ const contacts = parseContactRows(output);
506
+ writeContactQueryCache(trimmedQuery, contacts);
507
+ return contacts;
508
+ } catch (error) {
509
+ const message = error instanceof Error ? error.message : String(error);
510
+ if (/timed out|timeout/i.test(message)) {
511
+ throw new Error(
512
+ "Contacts lookup timed out. Check Contacts permissions or send with a phone/email instead.",
513
+ );
514
+ }
515
+ throw new Error(`Contacts lookup failed: ${message}`);
516
+ }
517
+ }
518
+
519
+ async function resolveMessageRecipients(
520
+ requestedRecipients: string[],
521
+ signal?: AbortSignal,
522
+ ): Promise<ResolvedMessageRecipient[]> {
523
+ const recipients = requestedRecipients
524
+ .map((entry) => entry.trim())
525
+ .filter(Boolean);
526
+ if (recipients.length === 0) {
527
+ throw new Error("Missing required array field: to");
528
+ }
529
+
530
+ const resolved: ResolvedMessageRecipient[] = [];
531
+ for (const entry of recipients) {
532
+ const directHandle = maybeDirectMessageHandle(entry);
533
+ if (directHandle) {
534
+ resolved.push({
535
+ input: entry,
536
+ name: null,
537
+ handle: directHandle,
538
+ source: "direct",
539
+ });
540
+ continue;
541
+ }
542
+
543
+ const queryCandidates = dedupeList([
544
+ entry,
545
+ ...normalizeNameForMatch(entry)
546
+ .split(" ")
547
+ .filter((token) => token.length >= 3),
548
+ ]);
549
+ const candidateContacts: ContactRecord[] = [];
550
+ const candidateFingerprints = new Set<string>();
551
+ for (const query of queryCandidates) {
552
+ const contacts = await getContactsMatchingQuery(query, signal);
553
+ for (const contact of contacts) {
554
+ const fingerprint = [
555
+ contact.fullName,
556
+ contact.firstName,
557
+ contact.lastName,
558
+ contact.phones.join("|"),
559
+ contact.emails.join("|"),
560
+ ].join("||");
561
+ if (candidateFingerprints.has(fingerprint)) {
562
+ continue;
563
+ }
564
+ candidateFingerprints.add(fingerprint);
565
+ candidateContacts.push(contact);
566
+ }
567
+ if (candidateContacts.length >= 24) {
568
+ break;
569
+ }
570
+ }
571
+
572
+ const matches = findContactMatches(entry, candidateContacts);
573
+ if (matches.length === 0) {
574
+ throw notFoundContactError(entry, candidateContacts);
575
+ }
576
+
577
+ const matchedContact = resolveContactFromMatches(entry, matches);
578
+ const handle = selectPreferredMessageHandle(matchedContact);
579
+ if (!handle) {
580
+ throw new Error(
581
+ `Contact "${contactDisplayName(matchedContact)}" has no phone/email handle available for Messages.`,
582
+ );
583
+ }
584
+ resolved.push({
585
+ input: entry,
586
+ name: contactDisplayName(matchedContact),
587
+ handle,
588
+ source: "contact",
589
+ });
590
+ }
591
+ return resolved;
592
+ }
593
+
594
+ async function runMessagesSendScript(
595
+ handles: string[],
596
+ body: string,
597
+ signal?: AbortSignal,
598
+ ): Promise<number> {
599
+ const script =
600
+ "on run argv\n" +
601
+ "set recipientsText to item 1 of argv\n" +
602
+ "set msgBody to item 2 of argv\n" +
603
+ "set sentCount to 0\n" +
604
+ "tell application \"Messages\"\n" +
605
+ "launch\n" +
606
+ "set iMessageService to missing value\n" +
607
+ "set smsService to missing value\n" +
608
+ "try\n" +
609
+ "set iMessageService to first service whose service type = iMessage\n" +
610
+ "end try\n" +
611
+ "try\n" +
612
+ "set smsService to first service whose service type = SMS\n" +
613
+ "end try\n" +
614
+ "if iMessageService is missing value and smsService is missing value then error \"No iMessage or SMS service is configured in Messages.\"\n" +
615
+ "repeat with handleText in paragraphs of recipientsText\n" +
616
+ "if handleText is not \"\" then\n" +
617
+ "set sentToRecipient to false\n" +
618
+ "if iMessageService is not missing value then\n" +
619
+ "try\n" +
620
+ "set targetBuddy to buddy handleText of iMessageService\n" +
621
+ "send msgBody to targetBuddy\n" +
622
+ "set sentToRecipient to true\n" +
623
+ "end try\n" +
624
+ "end if\n" +
625
+ "if sentToRecipient is false and smsService is not missing value then\n" +
626
+ "try\n" +
627
+ "set targetBuddy to buddy handleText of smsService\n" +
628
+ "send msgBody to targetBuddy\n" +
629
+ "set sentToRecipient to true\n" +
630
+ "end try\n" +
631
+ "end if\n" +
632
+ "if sentToRecipient is false then error \"Could not resolve Messages recipient: \" & handleText\n" +
633
+ "set sentCount to sentCount + 1\n" +
634
+ "end if\n" +
635
+ "end repeat\n" +
636
+ "end tell\n" +
637
+ "return sentCount as text\n" +
638
+ "end run";
639
+
640
+ const output = await runAppleScript(script, [handles.join("\n"), body], signal, 20_000);
641
+ const parsed = Number.parseInt(output.trim(), 10);
642
+ if (Number.isFinite(parsed)) {
643
+ return parsed;
644
+ }
645
+ return handles.length;
646
+ }
647
+
648
+ function summarizeResolvedRecipients(
649
+ recipients: ResolvedMessageRecipient[],
650
+ ): Array<{ input: string; name: string | null; handle: string; source: "contact" | "direct" }> {
651
+ return recipients.map((recipient) => ({
652
+ input: recipient.input,
653
+ name: recipient.name,
654
+ handle: recipient.handle,
655
+ source: recipient.source,
656
+ }));
657
+ }
658
+
659
+ async function runAppleScript(
660
+ script: string,
661
+ args: string[] = [],
662
+ signal?: AbortSignal,
663
+ timeoutMs = APPLESCRIPT_TIMEOUT_MS,
664
+ ) {
665
+ const { stdout } = await runCommandSafe({
666
+ command: "osascript",
667
+ args: ["-e", script, ...args],
668
+ signal,
669
+ timeoutMs,
670
+ });
671
+ return stdout;
672
+ }
673
+
674
+ async function runMailCreateDraft(input: unknown, context: ToolExecutionContext) {
675
+ ensureMacOS("Mail automation");
676
+ const payload = asObject(input) as MailInput;
677
+ const to = asStringArray(payload.to, "to");
678
+ const subject = asString(payload.subject, "subject");
679
+ const body = asString(payload.body, "body");
680
+ const cc = Array.isArray(payload.cc)
681
+ ? payload.cc.filter((item): item is string => typeof item === "string" && item.trim() !== "")
682
+ : [];
683
+ const attachments = Array.isArray(payload.attachments)
684
+ ? payload.attachments.filter((item): item is string => typeof item === "string" && item.trim() !== "")
685
+ : [];
686
+
687
+ const resolvedAttachments: string[] = [];
688
+ for (const attachment of attachments) {
689
+ resolvedAttachments.push(
690
+ await assertAllowedPath({
691
+ candidate: attachment,
692
+ localTools: context.localTools,
693
+ mode: "read",
694
+ }),
695
+ );
696
+ }
697
+
698
+ if (context.localTools.dryRun) {
699
+ return {
700
+ dryRun: true,
701
+ action: "mail_create_draft",
702
+ to,
703
+ cc,
704
+ subject,
705
+ hasBody: Boolean(body),
706
+ attachments: resolvedAttachments,
707
+ };
708
+ }
709
+
710
+ const script =
711
+ 'on run argv\n' +
712
+ 'set toListText to item 1 of argv\n' +
713
+ 'set ccListText to item 2 of argv\n' +
714
+ 'set msgSubject to item 3 of argv\n' +
715
+ 'set msgBody to item 4 of argv\n' +
716
+ 'tell application "Mail"\n' +
717
+ 'set newMessage to make new outgoing message with properties {visible:true, subject:msgSubject, content:msgBody}\n' +
718
+ 'tell newMessage\n' +
719
+ 'repeat with addr in paragraphs of toListText\n' +
720
+ 'if addr is not "" then make new to recipient at end of to recipients with properties {address:addr}\n' +
721
+ 'end repeat\n' +
722
+ 'repeat with ccAddr in paragraphs of ccListText\n' +
723
+ 'if ccAddr is not "" then make new cc recipient at end of cc recipients with properties {address:ccAddr}\n' +
724
+ 'end repeat\n' +
725
+ 'end tell\n' +
726
+ 'activate\n' +
727
+ 'end tell\n' +
728
+ 'return "drafted"\n' +
729
+ 'end run';
730
+
731
+ await runAppleScript(script, [to.join("\n"), cc.join("\n"), subject, body], context.signal);
732
+ return { drafted: true, to, cc, subject, attachments: resolvedAttachments.length };
733
+ }
734
+
735
+ async function runMailSend(input: unknown, context: ToolExecutionContext) {
736
+ const result = await runMailCreateDraft(input, context);
737
+ if (context.localTools.dryRun) {
738
+ return {
739
+ ...result,
740
+ action: "mail_send",
741
+ };
742
+ }
743
+
744
+ const script =
745
+ 'tell application "Mail"\n' +
746
+ 'set m to last outgoing message\n' +
747
+ 'send m\n' +
748
+ 'end tell';
749
+ await runAppleScript(script, [], context.signal);
750
+ return { sent: true };
751
+ }
752
+
753
+ async function runMessagesCreateDraft(input: unknown, context: ToolExecutionContext) {
754
+ ensureMacOS("Messages automation");
755
+ const payload = asObject(input) as MessageDraftInput;
756
+ const to = asStringArray(payload.to, "to");
757
+ const body = asString(payload.body, "body");
758
+ const resolvedRecipients = await resolveMessageRecipients(to, context.signal);
759
+ const recipientSummary = summarizeResolvedRecipients(resolvedRecipients);
760
+
761
+ if (context.localTools.dryRun) {
762
+ return {
763
+ dryRun: true,
764
+ action: "messages_create_draft",
765
+ recipients: recipientSummary,
766
+ bodyPreview: body.slice(0, 160),
767
+ };
768
+ }
769
+
770
+ const script =
771
+ 'tell application "Messages"\n' +
772
+ "activate\n" +
773
+ "end tell\n" +
774
+ 'return "draft_prepared"\n';
775
+ await runAppleScript(script, [], context.signal);
776
+ return {
777
+ drafted: true,
778
+ recipients: recipientSummary,
779
+ note: "Messages opened. Review and send manually.",
780
+ };
781
+ }
782
+
783
+ async function runMessagesSend(input: unknown, context: ToolExecutionContext) {
784
+ ensureMacOS("Messages automation");
785
+ const payload = asObject(input) as MessageSendInput;
786
+ const to = asStringArray(payload.to, "to");
787
+ const body = asString(payload.body, "body");
788
+
789
+ const resolvedRecipients = await resolveMessageRecipients(to, context.signal);
790
+ const recipientSummary = summarizeResolvedRecipients(resolvedRecipients);
791
+ const uniqueHandles = dedupeList(resolvedRecipients.map((recipient) => recipient.handle));
792
+
793
+ if (context.localTools.dryRun) {
794
+ return {
795
+ dryRun: true,
796
+ action: "messages_send",
797
+ recipients: recipientSummary,
798
+ recipientCount: uniqueHandles.length,
799
+ bodyPreview: body.slice(0, 160),
800
+ };
801
+ }
802
+
803
+ if (uniqueHandles.length === 0) {
804
+ throw new Error("No valid message recipients were resolved.");
805
+ }
806
+
807
+ const sentCount = await runMessagesSendScript(uniqueHandles, body, context.signal);
808
+ return {
809
+ sent: true,
810
+ recipients: recipientSummary,
811
+ recipientCount: sentCount,
812
+ };
813
+ }
814
+
815
+ export const communicationTools: ToolDefinition[] = [
816
+ {
817
+ name: "mail_create_draft",
818
+ description: "Create an email draft in Apple Mail.",
819
+ inputSchema: {
820
+ type: "object",
821
+ required: ["to", "subject", "body"],
822
+ properties: {
823
+ to: { type: "array", items: { type: "string" } },
824
+ subject: { type: "string" },
825
+ body: { type: "string" },
826
+ cc: { type: "array", items: { type: "string" } },
827
+ attachments: { type: "array", items: { type: "string" } },
828
+ },
829
+ additionalProperties: false,
830
+ },
831
+ risk: "external",
832
+ execute: runMailCreateDraft,
833
+ },
834
+ {
835
+ name: "mail_send",
836
+ description: "Send an email via Apple Mail.",
837
+ inputSchema: {
838
+ type: "object",
839
+ required: ["to", "subject", "body"],
840
+ properties: {
841
+ to: { type: "array", items: { type: "string" } },
842
+ subject: { type: "string" },
843
+ body: { type: "string" },
844
+ cc: { type: "array", items: { type: "string" } },
845
+ attachments: { type: "array", items: { type: "string" } },
846
+ },
847
+ additionalProperties: false,
848
+ },
849
+ risk: "external",
850
+ execute: runMailSend,
851
+ },
852
+ {
853
+ name: "messages_create_draft",
854
+ description: "Open Messages with resolved recipients and prepare a manual draft (does not send).",
855
+ inputSchema: {
856
+ type: "object",
857
+ required: ["to", "body"],
858
+ properties: {
859
+ to: { type: "array", items: { type: "string" } },
860
+ body: { type: "string" },
861
+ },
862
+ additionalProperties: false,
863
+ },
864
+ risk: "external",
865
+ execute: runMessagesCreateDraft,
866
+ },
867
+ {
868
+ name: "messages_send",
869
+ description:
870
+ "Send a text via Messages. Recipient names are resolved through Contacts; ambiguous names require clarification.",
871
+ inputSchema: {
872
+ type: "object",
873
+ required: ["to", "body"],
874
+ properties: {
875
+ to: { type: "array", items: { type: "string" } },
876
+ body: { type: "string" },
877
+ },
878
+ additionalProperties: false,
879
+ },
880
+ risk: "external",
881
+ execute: runMessagesSend,
882
+ },
883
+ ];