iris-chatbot 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iris-chatbot",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "private": false,
5
5
  "description": "One-command installer for the Iris project template.",
6
6
  "bin": {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "iris",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "iris",
9
- "version": "4.0.0",
9
+ "version": "4.1.0",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.72.1",
12
12
  "clsx": "^2.1.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iris",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "private": true,
5
5
  "description": "One-command installer for the Iris project template.",
6
6
  "engines": {
@@ -42,25 +42,26 @@ const LOCAL_TOOL_SYSTEM_INSTRUCTIONS = [
42
42
  "When the user asks for file, app, notes, music, web, calendar, or system actions, call tools directly.",
43
43
  "Default to execution instead of clarification. Ask at most one question only when a truly required value is missing.",
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).",
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.",
45
46
  "For iMessage/text requests, use messages_send; if contact matching is ambiguous, ask one concise clarification with candidate names.",
46
47
  "For media requests, assume Apple Music unless the user specifies otherwise.",
47
48
  "For note creation, write structured markdown with clear headings, bolded key labels, and tables when they improve clarity.",
48
49
  "If the user requests multiple actions in one prompt, execute every requested action sequentially before your final response.",
49
50
  "Do not stop after completing just one action when additional requested actions remain.",
50
51
  "For multi-step requests, prefer a single workflow tool call when available.",
51
- "When tools are enabled, never claim you cannot access the user's device or calendar. You have calendar tools—use them; do not refuse or ask the user to paste/export/screenshot their calendar.",
52
+ "When tools are enabled, never claim you cannot access the user's device, calendar, or file system. You have the tools—use them; do not refuse or ask the user to do tasks manually that you can do with tools.",
52
53
  "After tool results arrive, provide a brief final answer and stop.",
53
54
  "Summarize tool activity in plain English and avoid raw JSON, stack traces, or script line numbers.",
54
55
  "Respect tool errors and provide clear next steps.",
55
56
  ].join(" ");
56
57
 
57
58
  const ACTION_VERB_PATTERN =
58
- "create|make|write|play|set|add|remind|open|send|launch|start|pause|resume|focus";
59
+ "create|make|write|play|set|add|remind|open|send|launch|start|pause|resume|focus|move|copy|delete|organize|reorganize|restructure|split|separate|sort|categorize";
59
60
  const SEQUENCE_MARKER_PATTERN = "first|second|third|fourth|fifth|next|then|finally|lastly";
60
61
  const TOOL_INTENT_EXPLICIT_PATTERN =
61
62
  /\b(use|run|call)\s+(?:the\s+)?(?:local\s+)?tools?\b|\b(on my (?:mac|computer|device)|locally)\b/i;
62
63
  const TOOL_INTENT_LEADING_VERB_PATTERN =
63
- /^(?:please\s+)?(?:(?:can|could|would)\s+you\s+)?(?:send|text|message|play|pause|resume|skip|next|previous|set|open|launch|focus|create|make|add|remind|search|find|list|move|copy|rename|delete|trash|append|write|read|start|stop)\b/i;
64
+ /^(?:please\s+)?(?:(?:can|could|would)\s+you\s+)?(?:send|text|message|play|pause|resume|skip|next|previous|set|open|launch|focus|create|make|add|remind|search|find|list|move|copy|rename|delete|trash|append|write|read|start|stop|organize|reorganize|restructure|split|separate|sort|categorize|put|place)(?:\s+|$)/i;
64
65
  const TOOL_INTENT_MESSAGING_PATTERN =
65
66
  /\b(text|message|imessage|sms|mail|email)\b[\s\S]{0,80}\b(send|text|message|email|mail)\b|\b(send|text|message|email|mail)\b[\s\S]{0,80}\b(text|message|imessage|sms|mail|email)\b/i;
66
67
  const TOOL_INTENT_NOTES_PATTERN =
@@ -70,7 +71,7 @@ const TOOL_INTENT_MUSIC_PATTERN =
70
71
  const TOOL_INTENT_SCHEDULE_PATTERN =
71
72
  /\b(calendar|event|reminder|schedule|events)\b[\s\S]{0,80}\b(create|add|list|show|set|remind|schedule|move|change|delete|reschedule|update|cancel|have|get|check)\b|\b(create|add|list|show|set|remind|schedule|move|change|delete|reschedule|update|cancel|have|get|check)\b[\s\S]{0,80}\b(calendar|event|reminder|schedule|events)\b|\b(move|reschedule|change|delete)\b[\s\S]{0,80}\b(event|meeting|appointment)\b|\b(what's happening|whats happening|what's on|whats on|next weekend|this weekend|you can see|just check)\b/i;
72
73
  const TOOL_INTENT_FILES_PATTERN =
73
- /\b(file|folder|directory|path|document)\b[\s\S]{0,80}\b(list|find|search|move|copy|rename|delete|trash|create|make|mkdir|open)\b|\b(list|find|search|move|copy|rename|delete|trash|create|make|mkdir|open)\b[\s\S]{0,80}\b(file|folder|directory|path|document)\b/i;
74
+ /\b(file|folder|directory|path|document|subfolder|subfolders)\b[\s\S]{0,80}\b(list|find|search|move|copy|rename|delete|trash|create|make|mkdir|open|organize|reorganize|restructure|split|separate|sort|categorize|put|place|have|show|see|get)\b|\b(list|find|search|move|copy|rename|delete|trash|create|make|mkdir|open|organize|reorganize|restructure|split|separate|sort|categorize|put|place|have|show|see|get)\b[\s\S]{0,80}\b(file|folder|directory|path|document|subfolder|subfolders)\b|\b(organize|reorganize|restructure|split|separate)\b[\s\S]{0,80}\b(into|by|by type|by date|by name)\b|\b(put|move|place)\b[\s\S]{0,40}\b(into|in|inside)\b[\s\S]{0,40}\b(folder|directory)\b|\b(create|make)\b[\s\S]{0,40}\b(folders?|directories?)\b[\s\S]{0,40}\b(for|called|named)\b|\b(recent|last \d+|past \d+)\b[\s\S]{0,40}\b(files?|days?|weeks?)\b|\b(files?)\b[\s\S]{0,40}\b(from|in|within|during)\b[\s\S]{0,40}\b(last|past|recent|\d+)\b|\b(what|which|show|list|my)\b[\s\S]{0,40}\b(files?|documents?)\b/i;
74
75
  const TOOL_INTENT_APPS_PATTERN =
75
76
  /\b(app|application|window|url|website|browser|system)\b[\s\S]{0,80}\b(open|launch|focus|quit|set|mute|unmute|browse)\b|\b(open|launch|focus|quit|set|mute|unmute|browse)\b[\s\S]{0,80}\b(app|application|window|url|website|browser|system)\b/i;
76
77
  const TOOL_INTENT_NUMBERS_PATTERN =
@@ -86,8 +87,8 @@ function splitCompoundActionClauses(input: string): string[] {
86
87
  const roughParts = normalized.split(
87
88
  new RegExp(
88
89
  `\\s*(?:,|;|\\n)+\\s*(?=(?:(?:${SEQUENCE_MARKER_PATTERN})\\b|${ACTION_VERB_PATTERN}\\b))|` +
89
- `\\s+(?:and then|and also|as well as|after that|plus|then|also|next|finally|lastly)\\s+|` +
90
- `\\s+and\\s+(?=(?:${ACTION_VERB_PATTERN})\\b)`,
90
+ `\\s+(?:and then|and also|as well as|after that|plus|then|also|next|finally|lastly)\\s+|` +
91
+ `\\s+and\\s+(?=(?:${ACTION_VERB_PATTERN})\\b)`,
91
92
  "i",
92
93
  ),
93
94
  );
@@ -234,9 +235,9 @@ function normalizeRuntimeConnection(connection: ChatConnectionPayload): RuntimeC
234
235
  : "openai_compatible";
235
236
  const provider =
236
237
  kind === "builtin" &&
237
- (connection.provider === "openai" ||
238
- connection.provider === "anthropic" ||
239
- connection.provider === "google")
238
+ (connection.provider === "openai" ||
239
+ connection.provider === "anthropic" ||
240
+ connection.provider === "google")
240
241
  ? connection.provider
241
242
  : undefined;
242
243
  const baseUrl =
@@ -264,9 +265,9 @@ function normalizeRuntimeConnection(connection: ChatConnectionPayload): RuntimeC
264
265
  apiKey,
265
266
  headers: Array.isArray(connection.headers)
266
267
  ? connection.headers.filter(
267
- (header): header is { key: string; value: string } =>
268
- Boolean(header) && typeof header.key === "string" && typeof header.value === "string",
269
- )
268
+ (header): header is { key: string; value: string } =>
269
+ Boolean(header) && typeof header.key === "string" && typeof header.value === "string",
270
+ )
270
271
  : [],
271
272
  supportsTools,
272
273
  };
@@ -435,47 +436,47 @@ function sanitizeMemoryContext(
435
436
 
436
437
  const people = Array.isArray(aliases.people)
437
438
  ? aliases.people
438
- .map((item) => {
439
- const alias = typeof item?.alias === "string" ? item.alias.trim() : "";
440
- const target = typeof item?.target === "string" ? item.target.trim() : "";
441
- if (!alias || !target) {
442
- return null;
443
- }
444
- return { alias, target };
445
- })
446
- .filter((item): item is { alias: string; target: string } => Boolean(item))
447
- .slice(0, 16)
439
+ .map((item) => {
440
+ const alias = typeof item?.alias === "string" ? item.alias.trim() : "";
441
+ const target = typeof item?.target === "string" ? item.target.trim() : "";
442
+ if (!alias || !target) {
443
+ return null;
444
+ }
445
+ return { alias, target };
446
+ })
447
+ .filter((item): item is { alias: string; target: string } => Boolean(item))
448
+ .slice(0, 16)
448
449
  : [];
449
450
 
450
451
  const music = Array.isArray(aliases.music)
451
452
  ? aliases.music
452
- .map((item) => {
453
- const alias = typeof item?.alias === "string" ? item.alias.trim() : "";
454
- const query = typeof item?.query === "string" ? item.query.trim() : "";
455
- if (!alias || !query) {
456
- return null;
457
- }
458
- const title =
459
- typeof item?.title === "string" && item.title.trim()
460
- ? item.title.trim()
461
- : undefined;
462
- const artist =
463
- typeof item?.artist === "string" && item.artist.trim()
464
- ? item.artist.trim()
465
- : undefined;
466
- return { alias, query, title, artist };
467
- })
468
- .filter(
469
- (
470
- item,
471
- ): item is {
472
- alias: string;
473
- query: string;
474
- title: string | undefined;
475
- artist: string | undefined;
476
- } => Boolean(item),
477
- )
478
- .slice(0, 16)
453
+ .map((item) => {
454
+ const alias = typeof item?.alias === "string" ? item.alias.trim() : "";
455
+ const query = typeof item?.query === "string" ? item.query.trim() : "";
456
+ if (!alias || !query) {
457
+ return null;
458
+ }
459
+ const title =
460
+ typeof item?.title === "string" && item.title.trim()
461
+ ? item.title.trim()
462
+ : undefined;
463
+ const artist =
464
+ typeof item?.artist === "string" && item.artist.trim()
465
+ ? item.artist.trim()
466
+ : undefined;
467
+ return { alias, query, title, artist };
468
+ })
469
+ .filter(
470
+ (
471
+ item,
472
+ ): item is {
473
+ alias: string;
474
+ query: string;
475
+ title: string | undefined;
476
+ artist: string | undefined;
477
+ } => Boolean(item),
478
+ )
479
+ .slice(0, 16)
479
480
  : [];
480
481
 
481
482
  return {
@@ -679,7 +680,7 @@ function humanizeToolErrorMessage(toolName: string, message: string): string {
679
680
  }
680
681
 
681
682
  if (normalized.includes("missing required")) {
682
- const fieldMatch = cleaned.match(/missing required (?:string|numeric) field:\s*([a-zA-Z0-9_]+)/i);
683
+ const fieldMatch = cleaned.match(/missing required (?:string|numeric|array) field:\s*([a-zA-Z0-9_]+)/i);
683
684
  if (fieldMatch?.[1]) {
684
685
  return `Missing required field "${fieldMatch[1]}".`;
685
686
  }
@@ -1407,8 +1408,8 @@ async function tryDirectAutomationFastPath(params: {
1407
1408
  "Reminder";
1408
1409
  const title = sanitizeReminderTitle(
1409
1410
  rawTitle
1410
- .replace(/^(that|i have to|to|for)\s+/i, "")
1411
- .trim(),
1411
+ .replace(/^(that|i have to|to|for)\s+/i, "")
1412
+ .trim(),
1412
1413
  );
1413
1414
 
1414
1415
  return { title: title || "Reminder", due };
@@ -1715,18 +1716,18 @@ async function tryDirectAutomationFastPath(params: {
1715
1716
  const playResult =
1716
1717
  playOutput && typeof playOutput === "object"
1717
1718
  ? (playOutput as {
1718
- playing?: boolean;
1719
- matched?: boolean;
1720
- playlistName?: string | null;
1721
- title?: string | null;
1722
- artist?: string | null;
1723
- album?: string | null;
1724
- reason?: string | null;
1725
- matchedTitle?: string | null;
1726
- matchedArtist?: string | null;
1727
- catalogTitle?: string | null;
1728
- catalogArtist?: string | null;
1729
- })
1719
+ playing?: boolean;
1720
+ matched?: boolean;
1721
+ playlistName?: string | null;
1722
+ title?: string | null;
1723
+ artist?: string | null;
1724
+ album?: string | null;
1725
+ reason?: string | null;
1726
+ matchedTitle?: string | null;
1727
+ matchedArtist?: string | null;
1728
+ catalogTitle?: string | null;
1729
+ catalogArtist?: string | null;
1730
+ })
1730
1731
  : null;
1731
1732
 
1732
1733
  if (!playResult || playResult.playing !== true) {
@@ -1823,18 +1824,18 @@ async function tryDirectAutomationFastPath(params: {
1823
1824
  }
1824
1825
 
1825
1826
  params.send({
1826
- type: "token",
1827
- value:
1828
- hasNearbyMatch
1829
- ? `Started nearby match "${resolvedTitle}"${resolvedArtist ? ` by ${resolvedArtist}` : ""}. ${playResult.reason}`
1830
- : resolvedPlaylistName && intent.volume !== null
1831
- ? `**Done.** Playing playlist "${resolvedPlaylistName}" in Apple Music and set volume to ${intent.volume}%.`
1832
- : resolvedPlaylistName
1833
- ? `**Done.** Playing playlist "${resolvedPlaylistName}" in Apple Music.`
1834
- : intent.volume !== null
1827
+ type: "token",
1828
+ value:
1829
+ hasNearbyMatch
1830
+ ? `Started nearby match "${resolvedTitle}"${resolvedArtist ? ` by ${resolvedArtist}` : ""}. ${playResult.reason}`
1831
+ : resolvedPlaylistName && intent.volume !== null
1832
+ ? `**Done.** Playing playlist "${resolvedPlaylistName}" in Apple Music and set volume to ${intent.volume}%.`
1833
+ : resolvedPlaylistName
1834
+ ? `**Done.** Playing playlist "${resolvedPlaylistName}" in Apple Music.`
1835
+ : intent.volume !== null
1835
1836
  ? `**Done.** Playing "${resolvedTitle}"${resolvedArtist ? ` by ${resolvedArtist}` : ""} in Apple Music and set volume to ${intent.volume}%.`
1836
1837
  : `**Done.** Playing "${resolvedTitle}"${resolvedArtist ? ` by ${resolvedArtist}` : ""} in Apple Music.`,
1837
- });
1838
+ });
1838
1839
  return true;
1839
1840
  }
1840
1841
 
@@ -2159,12 +2160,12 @@ async function runToolOrchestrator(params: {
2159
2160
  const approval = createApprovalRequest();
2160
2161
  params.send({
2161
2162
  type: "approval_requested",
2162
- approvalId: approval.approvalId,
2163
- callId: call.id,
2164
- name: call.name,
2165
- args: callInput,
2166
- reason: summarizeApprovalReason(call.name, callInput),
2167
- });
2163
+ approvalId: approval.approvalId,
2164
+ callId: call.id,
2165
+ name: call.name,
2166
+ args: callInput,
2167
+ reason: summarizeApprovalReason(call.name, callInput),
2168
+ });
2168
2169
 
2169
2170
  emitThinkingUpdate(`Waiting for approval to run ${call.name}.`);
2170
2171
  const decision = await approval.promise;
@@ -344,7 +344,7 @@ export default function Home() {
344
344
  onSelect={async (id) => {
345
345
  if (id !== activeThreadId) {
346
346
  const prev = threads?.find((t) => t.id === activeThreadId);
347
- if (prev?.headMessageId == null) {
347
+ if (prev && prev.headMessageId == null) {
348
348
  await deleteThread(prev.id);
349
349
  }
350
350
  }
@@ -352,7 +352,7 @@ export default function Home() {
352
352
  }}
353
353
  onNewChat={async () => {
354
354
  const prev = threads?.find((t) => t.id === activeThreadId);
355
- if (prev?.headMessageId == null) {
355
+ if (prev && prev.headMessageId == null) {
356
356
  await deleteThread(prev.id);
357
357
  }
358
358
  const thread = await createNewThread();
@@ -19,6 +19,13 @@ import { splitContentAndSources } from "../lib/utils";
19
19
 
20
20
  const MAX_VISIBLE_TOOL_ITEMS = 8;
21
21
  const HIDDEN_TIMELINE_TOOLS = new Set(["tooling", "workflow_run"]);
22
+ const FILE_FIND_ALLOWED_ROOTS_ERROR = "Path is outside allowed roots";
23
+
24
+ function isHiddenFileFindError(event: ToolEvent): boolean {
25
+ if (event.toolName !== "file_find" || event.stage !== "result") return false;
26
+ const text = [event.message, event.payloadJson].filter(Boolean).join(" ");
27
+ return text.includes(FILE_FIND_ALLOWED_ROOTS_ERROR);
28
+ }
22
29
  const TOOL_TIMELINE_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", {
23
30
  month: "short",
24
31
  day: "numeric",
@@ -149,6 +156,8 @@ function humanizeToolName(toolName: string): string {
149
156
  file_copy: "Copy Item",
150
157
  file_mkdir: "Create Folder",
151
158
  file_delete_to_trash: "Move to Trash",
159
+ file_batch_move: "Move Files",
160
+ file_find: "Find Files",
152
161
  notes_create_or_append: "Update Note",
153
162
  notes_find: "Search Notes",
154
163
  app_open: "Open App",
@@ -265,7 +274,7 @@ function summarizeToolError(toolName: string, rawError: string): {
265
274
  if (normalized.includes("missing required")) {
266
275
  return {
267
276
  title: "Missing required information",
268
- detail: "The action did not include all required fields.",
277
+ detail: cleaned.length < 200 ? cleaned : "The action did not include all required fields.",
269
278
  };
270
279
  }
271
280
 
@@ -365,6 +374,23 @@ function summarizeToolCall(toolName: string, payload: Record<string, unknown> |
365
374
  };
366
375
  }
367
376
 
377
+ if (toolName === "file_batch_move") {
378
+ const operations = payload.operations;
379
+ const count = Array.isArray(operations) ? operations.length : 0;
380
+ return {
381
+ title: count > 0 ? `Moving ${count} files` : "Moving files",
382
+ };
383
+ }
384
+
385
+ if (toolName === "file_find") {
386
+ const name = getString(payload, "name");
387
+ const searchPath = getString(payload, "searchPath");
388
+ return {
389
+ title: name ? `Searching for "${name}"` : "Searching for files",
390
+ detail: searchPath ? `In ${shortenPath(searchPath)}` : undefined,
391
+ };
392
+ }
393
+
368
394
  return { title: "Preparing action" };
369
395
  }
370
396
 
@@ -459,9 +485,9 @@ function summarizeToolResult(toolName: string, payload: Record<string, unknown>
459
485
  const detail = queriedRange
460
486
  ? queriedRange
461
487
  : joinDetailParts([
462
- fromResolved ? `From: ${formatDateTime(fromResolved) ?? fromResolved}` : null,
463
- toResolved ? `To: ${formatDateTime(toResolved) ?? toResolved}` : null,
464
- ]);
488
+ fromResolved ? `From: ${formatDateTime(fromResolved) ?? fromResolved}` : null,
489
+ toResolved ? `To: ${formatDateTime(toResolved) ?? toResolved}` : null,
490
+ ]);
465
491
  return {
466
492
  title:
467
493
  count === 0
@@ -559,6 +585,46 @@ function summarizeToolResult(toolName: string, payload: Record<string, unknown>
559
585
  };
560
586
  }
561
587
 
588
+ if (toolName === "file_batch_move") {
589
+ const movedCount = getNumber(payload, "movedCount");
590
+ const failedCount = getNumber(payload, "failedCount");
591
+ const totalOps = getNumber(payload, "totalOperations");
592
+ if (movedCount !== null && failedCount !== null) {
593
+ if (failedCount === 0) {
594
+ return {
595
+ title: `Moved ${movedCount} files successfully`,
596
+ };
597
+ }
598
+ return {
599
+ title: `Moved ${movedCount} files`,
600
+ detail: `${failedCount} failed`,
601
+ };
602
+ }
603
+ if (totalOps !== null) {
604
+ return { title: `Processed ${totalOps} files` };
605
+ }
606
+ }
607
+
608
+ if (toolName === "file_find") {
609
+ const totalFound = getNumber(payload, "totalFound");
610
+ const searchName = getString(payload, "searchName");
611
+ const matches = payload.matches;
612
+ const topMatch = Array.isArray(matches) && matches.length > 0 ? matches[0] as Record<string, unknown> : null;
613
+ const topMatchPath = topMatch ? getString(topMatch, "path") : null;
614
+
615
+ if (totalFound !== null) {
616
+ if (totalFound === 0) {
617
+ return {
618
+ title: searchName ? `No matches for "${searchName}"` : "No matches found",
619
+ };
620
+ }
621
+ return {
622
+ title: `Found ${totalFound} match${totalFound === 1 ? "" : "es"}`,
623
+ detail: topMatchPath ? `Best match: ${shortenPath(topMatchPath)}` : undefined,
624
+ };
625
+ }
626
+ }
627
+
562
628
  return { title: "Completed successfully" };
563
629
  }
564
630
 
@@ -673,6 +739,7 @@ function MessageCard({
673
739
  () =>
674
740
  (toolEvents ?? [])
675
741
  .filter((event) => !HIDDEN_TIMELINE_TOOLS.has(event.toolName))
742
+ .filter((event) => !isHiddenFileFindError(event))
676
743
  .slice()
677
744
  .sort((a, b) => a.createdAt - b.createdAt),
678
745
  [toolEvents],
@@ -874,9 +941,8 @@ function MessageCard({
874
941
  {shelfThreads.map((thread) => (
875
942
  <div key={thread.id} className="thread-box-wrap">
876
943
  <button
877
- className={`thread-box ${
878
- activeThreadId === thread.id ? "active" : ""
879
- }`}
944
+ className={`thread-box ${activeThreadId === thread.id ? "active" : ""
945
+ }`}
880
946
  onClick={() => onSelectThread(thread.id)}
881
947
  >
882
948
  {thread.title}
@@ -903,11 +969,10 @@ function MessageCard({
903
969
  >
904
970
  {isAssistant ? (
905
971
  <button
906
- className={`flex items-center justify-center gap-2 rounded-full border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-muted)] transition ${
907
- canAddThread
908
- ? "hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
909
- : "opacity-60 cursor-not-allowed"
910
- }`}
972
+ className={`flex items-center justify-center gap-2 rounded-full border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-muted)] transition ${canAddThread
973
+ ? "hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
974
+ : "opacity-60 cursor-not-allowed"
975
+ }`}
911
976
  onClick={async () => {
912
977
  if (!canAddThread) return;
913
978
  await onAddThread(message);
@@ -927,9 +992,8 @@ function MessageCard({
927
992
  </button>
928
993
  ) : null}
929
994
  <button
930
- className={`flex items-center gap-2 rounded-full border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-muted)] transition hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)] ${
931
- isAssistant ? "" : "opacity-0 group-hover:opacity-100"
932
- }`}
995
+ className={`flex items-center gap-2 rounded-full border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-muted)] transition hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)] ${isAssistant ? "" : "opacity-0 group-hover:opacity-100"
996
+ }`}
933
997
  onClick={async () => {
934
998
  await navigator.clipboard.writeText(messageTextContent);
935
999
  setCopied(true);
@@ -944,11 +1008,10 @@ function MessageCard({
944
1008
  </button>
945
1009
  {canEditThreads ? (
946
1010
  <button
947
- className={`flex items-center gap-2 rounded-full border px-4 py-2 text-xs transition ${
948
- threadEditMode
949
- ? "border-[var(--accent)] text-[var(--text-primary)]"
950
- : "border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
951
- }`}
1011
+ className={`flex items-center gap-2 rounded-full border px-4 py-2 text-xs transition ${threadEditMode
1012
+ ? "border-[var(--accent)] text-[var(--text-primary)]"
1013
+ : "border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
1014
+ }`}
952
1015
  onClick={() => setThreadEditMode((prev) => !prev)}
953
1016
  aria-label="Edit threads"
954
1017
  >
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import nodeFs from "node:fs";
3
3
  import path from "node:path";
4
- import { assertAllowedPath, ensureMacOS } from "../safety";
4
+ import { assertAllowedPath, ensureMacOS, normalizeAllowedRoots } from "../safety";
5
5
  import { runCommandSafe } from "../runtime";
6
6
  import type { ToolDefinition, ToolExecutionContext } from "../types";
7
7
 
@@ -10,6 +10,8 @@ type FileListInput = {
10
10
  recursive?: boolean;
11
11
  pattern?: string;
12
12
  limit?: number;
13
+ /** Only include files modified in the last N days (directories are always included so you can navigate). */
14
+ modifiedInLastDays?: number;
13
15
  };
14
16
 
15
17
  type FileWriteInput = {
@@ -20,6 +22,18 @@ type FileWriteInput = {
20
22
  recursive?: boolean;
21
23
  };
22
24
 
25
+ type FileBatchMoveInput = {
26
+ operations: Array<{ source: string; destination: string }>;
27
+ overwrite?: boolean;
28
+ };
29
+
30
+ type FileFindInput = {
31
+ searchPath: string;
32
+ name: string;
33
+ type?: "file" | "directory" | "any";
34
+ maxDepth?: number;
35
+ };
36
+
23
37
  const APPLESCRIPT_TIMEOUT_MS = 20_000;
24
38
 
25
39
  function asObject(input: unknown): Record<string, unknown> {
@@ -53,9 +67,14 @@ async function listDirectory(params: {
53
67
  recursive: boolean;
54
68
  pattern?: string;
55
69
  limit: number;
70
+ modifiedInLastDays?: number;
56
71
  signal?: AbortSignal;
57
72
  }) {
58
73
  const matcher = params.pattern ? wildcardToRegex(params.pattern) : null;
74
+ const modifiedCutoffMs =
75
+ typeof params.modifiedInLastDays === "number" && params.modifiedInLastDays > 0
76
+ ? Date.now() - params.modifiedInLastDays * 24 * 60 * 60 * 1000
77
+ : null;
59
78
  const output: Array<Record<string, unknown>> = [];
60
79
  const queue: string[] = [params.rootPath];
61
80
  let totalCount = 0;
@@ -66,7 +85,13 @@ async function listDirectory(params: {
66
85
  }
67
86
 
68
87
  const current = queue.shift() as string;
69
- const entries = await fs.readdir(current, { withFileTypes: true });
88
+ let entries: nodeFs.Dirent[];
89
+ try {
90
+ entries = await fs.readdir(current, { withFileTypes: true });
91
+ } catch {
92
+ // Skip directories we can't read (permissions, broken symlinks, etc.)
93
+ continue;
94
+ }
70
95
 
71
96
  for (const entry of entries) {
72
97
  const fullPath = path.join(current, entry.name);
@@ -76,22 +101,74 @@ async function listDirectory(params: {
76
101
  }
77
102
  }
78
103
 
104
+ // Handle symlinks: check if they're valid before processing
105
+ if (entry.isSymbolicLink()) {
106
+ try {
107
+ // Use stat (follows symlinks) to check if the target exists
108
+ const targetStat = await fs.stat(fullPath);
109
+ // Process based on what the symlink points to
110
+ if (targetStat.isDirectory()) {
111
+ if (modifiedCutoffMs == null) {
112
+ totalCount += 1;
113
+ if (output.length < params.limit) {
114
+ output.push({ path: fullPath, name: entry.name, type: "directory" });
115
+ }
116
+ }
117
+ if (params.recursive || modifiedCutoffMs != null) {
118
+ queue.push(fullPath);
119
+ }
120
+ } else {
121
+ if (modifiedCutoffMs != null && targetStat.mtimeMs < modifiedCutoffMs) {
122
+ continue;
123
+ }
124
+ totalCount += 1;
125
+ if (output.length < params.limit) {
126
+ output.push({
127
+ path: fullPath,
128
+ name: entry.name,
129
+ type: "file",
130
+ size: targetStat.size,
131
+ modifiedAt: targetStat.mtimeMs,
132
+ });
133
+ }
134
+ }
135
+ } catch {
136
+ // Broken symlink - skip silently
137
+ continue;
138
+ }
139
+ continue;
140
+ }
141
+
79
142
  if (entry.isDirectory()) {
80
- totalCount += 1;
81
- if (output.length < params.limit) {
82
- output.push({
83
- path: fullPath,
84
- name: entry.name,
85
- type: "directory",
86
- });
143
+ // When filtering by date, don't include directories in output (just traverse them)
144
+ // Otherwise, include them
145
+ if (modifiedCutoffMs == null) {
146
+ totalCount += 1;
147
+ if (output.length < params.limit) {
148
+ output.push({
149
+ path: fullPath,
150
+ name: entry.name,
151
+ type: "directory",
152
+ });
153
+ }
87
154
  }
88
- if (params.recursive) {
155
+ // Recurse into directories when recursive OR when filtering by date (need to find recent files in subdirs)
156
+ if (params.recursive || modifiedCutoffMs != null) {
89
157
  queue.push(fullPath);
90
158
  }
91
159
  continue;
92
160
  }
93
161
 
94
- const stat = await fs.stat(fullPath);
162
+ let stat: Awaited<ReturnType<typeof fs.stat>>;
163
+ try {
164
+ stat = await fs.stat(fullPath);
165
+ } catch {
166
+ // File disappeared or can't be accessed - skip
167
+ continue;
168
+ }
169
+ if (modifiedCutoffMs != null && stat.mtimeMs < modifiedCutoffMs) {
170
+ continue;
171
+ }
95
172
  totalCount += 1;
96
173
  if (output.length < params.limit) {
97
174
  output.push({
@@ -142,12 +219,17 @@ async function runFileList(input: unknown, context: ToolExecutionContext) {
142
219
  typeof payload.limit === "number" && Number.isFinite(payload.limit)
143
220
  ? Math.max(1, Math.min(1000, Math.floor(payload.limit)))
144
221
  : 200;
222
+ const modifiedInLastDays =
223
+ typeof payload.modifiedInLastDays === "number" && Number.isFinite(payload.modifiedInLastDays) && payload.modifiedInLastDays > 0
224
+ ? Math.min(365, Math.floor(payload.modifiedInLastDays))
225
+ : undefined;
145
226
 
146
227
  const { entries, totalCount } = await listDirectory({
147
228
  rootPath,
148
229
  recursive,
149
230
  pattern,
150
231
  limit,
232
+ modifiedInLastDays,
151
233
  signal: context.signal,
152
234
  });
153
235
 
@@ -155,6 +237,7 @@ async function runFileList(input: unknown, context: ToolExecutionContext) {
155
237
  rootPath,
156
238
  recursive,
157
239
  pattern,
240
+ modifiedInLastDays: modifiedInLastDays ?? undefined,
158
241
  count: totalCount,
159
242
  returnedCount: entries.length,
160
243
  limit,
@@ -297,34 +380,359 @@ async function runFileDeleteToTrash(input: unknown, context: ToolExecutionContex
297
380
  }
298
381
 
299
382
  ensureMacOS("Moving files to Trash");
300
- const script =
383
+ // Create POSIX file reference outside Finder tell block to avoid "Can't get POSIX file" errors
384
+ const finderScript =
301
385
  'on run argv\n' +
302
386
  'set targetPath to item 1 of argv\n' +
303
- 'tell application "Finder"\n' +
304
- 'delete POSIX file targetPath\n' +
305
- 'end tell\n' +
387
+ 'set theFile to POSIX file targetPath\n' +
388
+ 'tell application "Finder" to delete theFile\n' +
306
389
  'return "ok"\n' +
307
390
  'end run';
308
391
 
309
- await runAppleScript(script, [resolvedTarget], context.signal);
392
+ try {
393
+ await runAppleScript(finderScript, [resolvedTarget], context.signal);
394
+ } catch (finderErr) {
395
+ // Fallback: move to Trash via shell (reliable for empty folders and when Finder fails)
396
+ const moveErr = finderErr instanceof Error ? finderErr : new Error(String(finderErr));
397
+ if (!moveErr.message.includes("Can't get POSIX file") && !moveErr.message.includes("POSIX file")) {
398
+ throw finderErr;
399
+ }
400
+ const trashScript =
401
+ 'on run argv\n' +
402
+ 'set targetPath to item 1 of argv\n' +
403
+ 'set trashPath to POSIX path of (path to trash)\n' +
404
+ 'do shell script "mv " & quoted form of targetPath & " " & quoted form of trashPath\n' +
405
+ 'return "ok"\n' +
406
+ 'end run';
407
+ await runAppleScript(trashScript, [resolvedTarget], context.signal);
408
+ }
310
409
  return {
311
410
  trashed: true,
312
411
  path: resolvedTarget,
313
412
  };
314
413
  }
315
414
 
415
+ async function runFileBatchMove(input: unknown, context: ToolExecutionContext) {
416
+ const payload = asObject(input) as FileBatchMoveInput;
417
+ const overwrite = asOptionalBoolean(payload.overwrite, false);
418
+
419
+ // Handle operations that might be stringified JSON
420
+ let operations = payload.operations;
421
+ if (typeof operations === "string") {
422
+ try {
423
+ operations = JSON.parse(operations) as Array<{ source: string; destination: string }>;
424
+ } catch {
425
+ throw new Error("Invalid operations: could not parse JSON array");
426
+ }
427
+ }
428
+
429
+ // Handle case where LLM sends the array at the top level
430
+ if (!operations && Array.isArray(input)) {
431
+ operations = input as Array<{ source: string; destination: string }>;
432
+ }
433
+
434
+ // Handle case where LLM sends with different key names
435
+ if (!operations) {
436
+ const payloadAny = payload as Record<string, unknown>;
437
+ const fallback = payloadAny.moves || payloadAny.files || payloadAny.items || payloadAny.list;
438
+ if (Array.isArray(fallback)) {
439
+ operations = fallback as Array<{ source: string; destination: string }>;
440
+ }
441
+ }
442
+
443
+ // Handle single move at top level: { source, destination } without operations array
444
+ if (!Array.isArray(operations) || operations.length === 0) {
445
+ const payloadAny = payload as Record<string, unknown>;
446
+ const src =
447
+ typeof payloadAny.source === "string" ? payloadAny.source.trim() :
448
+ typeof payloadAny.src === "string" ? payloadAny.src.trim() :
449
+ typeof payloadAny.from === "string" ? payloadAny.from.trim() : "";
450
+ const dest =
451
+ typeof payloadAny.destination === "string" ? payloadAny.destination.trim() :
452
+ typeof payloadAny.dest === "string" ? payloadAny.dest.trim() :
453
+ typeof payloadAny.to === "string" ? payloadAny.to.trim() : "";
454
+ if (src && dest) {
455
+ operations = [{ source: src, destination: dest }];
456
+ }
457
+ }
458
+
459
+ // Handle "move these files into this folder": { destination, sources } or { targetFolder, files } etc.
460
+ if (!Array.isArray(operations) || operations.length === 0) {
461
+ const payloadAny = payload as Record<string, unknown>;
462
+ const destDir =
463
+ typeof payloadAny.destination === "string" ? payloadAny.destination.trim() :
464
+ typeof payloadAny.dest === "string" ? payloadAny.dest.trim() :
465
+ typeof payloadAny.to === "string" ? payloadAny.to.trim() :
466
+ typeof payloadAny.targetFolder === "string" ? payloadAny.targetFolder.trim() : "";
467
+ const sourceListRaw =
468
+ payloadAny.sources ?? payloadAny.files ?? payloadAny.paths ?? payloadAny.sourcePaths ?? payloadAny.source;
469
+ const sourceList = Array.isArray(sourceListRaw)
470
+ ? sourceListRaw.map((s) => (typeof s === "string" ? s.trim() : "")).filter(Boolean)
471
+ : [];
472
+ if (destDir && sourceList.length > 0) {
473
+ operations = sourceList.map((sourcePath) => ({
474
+ source: sourcePath,
475
+ destination: path.join(destDir, path.basename(sourcePath)),
476
+ }));
477
+ }
478
+ }
479
+
480
+ if (!Array.isArray(operations) || operations.length === 0) {
481
+ const receivedKeys = Object.keys(payload).join(", ");
482
+ throw new Error(`Missing required array field: operations. Received keys: [${receivedKeys}]. Expected array of {source, destination} objects, or {destination, sources: [...]} to move many files into one folder.`);
483
+ }
484
+
485
+ // If operations is an array of path strings, treat as sources and use payload.destination as target folder
486
+ const firstOp = operations[0] as { source: string; destination: string } | string;
487
+ if (typeof firstOp === "string" && firstOp.trim()) {
488
+ const payloadAny = payload as Record<string, unknown>;
489
+ const destDir =
490
+ typeof payloadAny.destination === "string" ? payloadAny.destination.trim() :
491
+ typeof payloadAny.dest === "string" ? payloadAny.dest.trim() :
492
+ typeof payloadAny.to === "string" ? payloadAny.to.trim() :
493
+ typeof payloadAny.targetFolder === "string" ? payloadAny.targetFolder.trim() : "";
494
+ if (destDir) {
495
+ const pathStrings = operations as unknown as string[];
496
+ operations = pathStrings.map((p) => (typeof p === "string" ? p.trim() : "")).filter(Boolean).map((sourcePath) => ({
497
+ source: sourcePath,
498
+ destination: path.join(destDir, path.basename(sourcePath)),
499
+ }));
500
+ }
501
+ }
502
+
503
+ if (operations.length > 100) {
504
+ throw new Error("Too many operations (max 100)");
505
+ }
506
+
507
+ const results: Array<{ source: string; destination: string; moved: boolean; error?: string }> = [];
508
+
509
+ for (const op of operations) {
510
+ if (!op || typeof op !== "object") {
511
+ results.push({ source: "", destination: "", moved: false, error: "Invalid operation object" });
512
+ continue;
513
+ }
514
+
515
+ // Handle various possible field names
516
+ const opObj = op as Record<string, unknown>;
517
+ const source = typeof opObj.source === "string" ? opObj.source.trim() :
518
+ typeof opObj.src === "string" ? opObj.src.trim() :
519
+ typeof opObj.from === "string" ? opObj.from.trim() : "";
520
+ const destination = typeof opObj.destination === "string" ? opObj.destination.trim() :
521
+ typeof opObj.dest === "string" ? opObj.dest.trim() :
522
+ typeof opObj.to === "string" ? opObj.to.trim() : "";
523
+
524
+ if (!source || !destination) {
525
+ results.push({ source, destination, moved: false, error: "Missing source or destination path" });
526
+ continue;
527
+ }
528
+
529
+ try {
530
+ const resolvedSource = await assertAllowedPath({
531
+ candidate: source,
532
+ localTools: context.localTools,
533
+ mode: "read",
534
+ });
535
+ const resolvedDestination = await assertAllowedPath({
536
+ candidate: destination,
537
+ localTools: context.localTools,
538
+ mode: "write",
539
+ });
540
+
541
+ if (!overwrite) {
542
+ try {
543
+ await fs.access(resolvedDestination);
544
+ results.push({ source: resolvedSource, destination: resolvedDestination, moved: false, error: "Destination exists" });
545
+ continue;
546
+ } catch (err) {
547
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
548
+ throw err;
549
+ }
550
+ }
551
+ }
552
+
553
+ if (!context.localTools.dryRun) {
554
+ await fs.mkdir(path.dirname(resolvedDestination), { recursive: true });
555
+ await fs.rename(resolvedSource, resolvedDestination);
556
+ }
557
+
558
+ results.push({ source: resolvedSource, destination: resolvedDestination, moved: true });
559
+ } catch (err) {
560
+ const message = err instanceof Error ? err.message : "Unknown error";
561
+ results.push({ source, destination, moved: false, error: message });
562
+ }
563
+ }
564
+
565
+ const movedCount = results.filter((r) => r.moved).length;
566
+ const failedCount = results.filter((r) => !r.moved).length;
567
+
568
+ return {
569
+ totalOperations: results.length,
570
+ movedCount,
571
+ failedCount,
572
+ dryRun: context.localTools.dryRun,
573
+ results,
574
+ };
575
+ }
576
+
577
+ function normalizeForSearch(input: string): string {
578
+ return input
579
+ .toLowerCase()
580
+ .replace(/[_\-\s]+/g, " ")
581
+ .replace(/[^a-z0-9\s]/g, "")
582
+ .trim();
583
+ }
584
+
585
+ /** Normalized name with no spaces (for ordered-word matching): e.g. "US_Debt_Clock" -> "usdebtclock" */
586
+ function normalizedSlug(name: string): string {
587
+ return normalizeForSearch(name).replace(/\s+/g, "");
588
+ }
589
+
590
+ /** True if search words appear in order in the name (handles UsDebtClock, US_Debt_Clock, etc.). */
591
+ function matchesOrderedWords(searchWords: string[], nameSlug: string): boolean {
592
+ if (searchWords.length === 0) return false;
593
+ let idx = 0;
594
+ for (const w of searchWords) {
595
+ const i = nameSlug.indexOf(w, idx);
596
+ if (i === -1) return false;
597
+ idx = i + w.length;
598
+ }
599
+ return true;
600
+ }
601
+
602
+ async function runFileFind(input: unknown, context: ToolExecutionContext) {
603
+ const payload = asObject(input) as FileFindInput;
604
+ const searchPath = asString(payload.searchPath, "searchPath");
605
+ const name = asString(payload.name, "name");
606
+ const targetType = payload.type ?? "any";
607
+ const maxDepth = typeof payload.maxDepth === "number" ? Math.max(1, Math.min(10, payload.maxDepth)) : 3;
608
+
609
+ const trimmedPath = searchPath.trim();
610
+ const searchAllRoots = trimmedPath === "~" || trimmedPath === "~/";
611
+
612
+ let resolvedSearchPath: string;
613
+ const queue: Array<{ dirPath: string; depth: number }> = [];
614
+
615
+ if (searchAllRoots) {
616
+ const roots = await normalizeAllowedRoots(context.localTools.allowedRoots);
617
+ if (roots.length === 0) {
618
+ throw new Error("No allowed filesystem roots are configured.");
619
+ }
620
+ resolvedSearchPath = roots[0];
621
+ for (const root of roots) {
622
+ queue.push({ dirPath: root, depth: 0 });
623
+ }
624
+ } else {
625
+ resolvedSearchPath = await assertAllowedPath({
626
+ candidate: searchPath,
627
+ localTools: context.localTools,
628
+ mode: "read",
629
+ });
630
+ queue.push({ dirPath: resolvedSearchPath, depth: 0 });
631
+ }
632
+
633
+ const normalizedSearch = normalizeForSearch(name);
634
+ const matches: Array<{ path: string; name: string; type: string; matchScore: number }> = [];
635
+
636
+ while (queue.length > 0 && matches.length < 20) {
637
+ if (context.signal?.aborted) {
638
+ throw new Error("Aborted.");
639
+ }
640
+
641
+ const current = queue.shift()!;
642
+ if (current.depth >= maxDepth) {
643
+ continue;
644
+ }
645
+
646
+ try {
647
+ const entries = await fs.readdir(current.dirPath, { withFileTypes: true });
648
+
649
+ for (const entry of entries) {
650
+ const fullPath = path.join(current.dirPath, entry.name);
651
+ const isDir = entry.isDirectory();
652
+ const entryType = isDir ? "directory" : "file";
653
+
654
+ if (targetType !== "any" && targetType !== entryType) {
655
+ if (isDir) {
656
+ queue.push({ dirPath: fullPath, depth: current.depth + 1 });
657
+ }
658
+ continue;
659
+ }
660
+
661
+ const normalizedName = normalizeForSearch(entry.name);
662
+ const nameSlug = normalizedSlug(entry.name);
663
+ const searchWords = normalizedSearch.split(" ").filter(Boolean);
664
+ let matchScore = 0;
665
+
666
+ // Exact match (normalized): "us debt clock" === "us debt clock"
667
+ if (normalizedName === normalizedSearch) {
668
+ matchScore = 100;
669
+ }
670
+ // Same with no spaces: "usdebtclock" vs "usdebtclock" (e.g. UsDebtClock)
671
+ else if (nameSlug === normalizedSearch.replace(/\s+/g, "")) {
672
+ matchScore = 95;
673
+ }
674
+ // Starts with search term
675
+ else if (normalizedName.startsWith(normalizedSearch)) {
676
+ matchScore = 80;
677
+ }
678
+ // Contains search term (full phrase)
679
+ else if (normalizedName.includes(normalizedSearch)) {
680
+ matchScore = 60;
681
+ }
682
+ // Ordered words in name (handles US_Debt_Clock, UsDebtClock, us-debt-clock, etc.)
683
+ else if (matchesOrderedWords(searchWords, nameSlug)) {
684
+ matchScore = 70;
685
+ }
686
+ // Words overlap significantly
687
+ else if (searchWords.length > 0) {
688
+ const nameWords = normalizedName.split(" ").filter(Boolean);
689
+ const matchedWords = searchWords.filter((w) => nameWords.some((nw) => nw.includes(w) || w.includes(nw)));
690
+ if (matchedWords.length > 0) {
691
+ matchScore = Math.min(50, (matchedWords.length / searchWords.length) * 50);
692
+ }
693
+ }
694
+
695
+ if (matchScore > 0) {
696
+ matches.push({
697
+ path: fullPath,
698
+ name: entry.name,
699
+ type: entryType,
700
+ matchScore,
701
+ });
702
+ }
703
+
704
+ if (isDir) {
705
+ queue.push({ dirPath: fullPath, depth: current.depth + 1 });
706
+ }
707
+ }
708
+ } catch {
709
+ // Skip directories we can't read
710
+ }
711
+ }
712
+
713
+ matches.sort((a, b) => b.matchScore - a.matchScore);
714
+
715
+ return {
716
+ searchPath: resolvedSearchPath,
717
+ searchName: name,
718
+ matches: matches.slice(0, 10),
719
+ totalFound: matches.length,
720
+ };
721
+ }
722
+
316
723
  export const fileTools: ToolDefinition[] = [
317
724
  {
318
725
  name: "file_list",
319
- description: "List files/folders under an allowed path. Supports optional recursion and wildcard name pattern.",
726
+ description: "List files and folders at a given path. Use to explore directory contents, find files by pattern, or see what's inside a folder before organizing. Supports recursive listing with optional wildcard (e.g. pattern: '*.pdf') and optional time filter: use modifiedInLastDays (e.g. 7 for 'last 7 days') to only include files modified within that period—directories are always included so you can navigate.",
320
727
  inputSchema: {
321
728
  type: "object",
322
729
  required: ["path"],
323
730
  properties: {
324
- path: { type: "string" },
325
- recursive: { type: "boolean" },
326
- pattern: { type: "string" },
327
- limit: { type: "number" },
731
+ path: { type: "string", description: "Directory path to list" },
732
+ recursive: { type: "boolean", description: "If true, list all nested subdirectories" },
733
+ pattern: { type: "string", description: "Wildcard pattern to filter results (e.g. '*.txt', '*.icns')" },
734
+ limit: { type: "number", description: "Maximum number of entries to return (default 200)" },
735
+ modifiedInLastDays: { type: "number", description: "If set, only include files modified in the last N days (e.g. 7 for last week). Directories are always included." },
328
736
  },
329
737
  additionalProperties: false,
330
738
  },
@@ -333,14 +741,14 @@ export const fileTools: ToolDefinition[] = [
333
741
  },
334
742
  {
335
743
  name: "file_move",
336
- description: "Move a file/folder from source to destination.",
744
+ description: "Move or rename a file or folder. Use to reorganize files into different folders, rename items, or relocate content. Parent directories are created automatically if they don't exist.",
337
745
  inputSchema: {
338
746
  type: "object",
339
747
  required: ["source", "destination"],
340
748
  properties: {
341
- source: { type: "string" },
342
- destination: { type: "string" },
343
- overwrite: { type: "boolean" },
749
+ source: { type: "string", description: "Current path of the file/folder to move" },
750
+ destination: { type: "string", description: "New path (including new name if renaming)" },
751
+ overwrite: { type: "boolean", description: "If true, overwrite existing file at destination" },
344
752
  },
345
753
  additionalProperties: false,
346
754
  },
@@ -349,14 +757,14 @@ export const fileTools: ToolDefinition[] = [
349
757
  },
350
758
  {
351
759
  name: "file_copy",
352
- description: "Copy a file from source to destination.",
760
+ description: "Copy a file to another location. Use to duplicate files, create backups, or copy files into new folders. Parent directories are created automatically.",
353
761
  inputSchema: {
354
762
  type: "object",
355
763
  required: ["source", "destination"],
356
764
  properties: {
357
- source: { type: "string" },
358
- destination: { type: "string" },
359
- overwrite: { type: "boolean" },
765
+ source: { type: "string", description: "Path of the file to copy" },
766
+ destination: { type: "string", description: "Path where the copy should be created" },
767
+ overwrite: { type: "boolean", description: "If true, overwrite existing file at destination" },
360
768
  },
361
769
  additionalProperties: false,
362
770
  },
@@ -365,13 +773,13 @@ export const fileTools: ToolDefinition[] = [
365
773
  },
366
774
  {
367
775
  name: "file_mkdir",
368
- description: "Create a folder path.",
776
+ description: "Create a new folder or nested folder structure. Use to set up directory organization, create subfolders for categorizing files, or prepare folders before moving files into them. By default creates all parent directories if they don't exist.",
369
777
  inputSchema: {
370
778
  type: "object",
371
779
  required: ["path"],
372
780
  properties: {
373
- path: { type: "string" },
374
- recursive: { type: "boolean" },
781
+ path: { type: "string", description: "Path of the folder to create (e.g. '/Users/me/Documents/Projects/2024')" },
782
+ recursive: { type: "boolean", description: "If true (default), create parent directories as needed" },
375
783
  },
376
784
  additionalProperties: false,
377
785
  },
@@ -380,16 +788,60 @@ export const fileTools: ToolDefinition[] = [
380
788
  },
381
789
  {
382
790
  name: "file_delete_to_trash",
383
- description: "Move a file/folder to Trash (macOS).",
791
+ description: "Move a file or folder to Trash (safe delete on macOS). Items can be recovered from Trash if needed.",
384
792
  inputSchema: {
385
793
  type: "object",
386
794
  required: ["path"],
387
795
  properties: {
388
- path: { type: "string" },
796
+ path: { type: "string", description: "Path of the file or folder to move to Trash" },
389
797
  },
390
798
  additionalProperties: false,
391
799
  },
392
800
  risk: "destructive",
393
801
  execute: runFileDeleteToTrash,
394
802
  },
803
+ {
804
+ name: "file_batch_move",
805
+ description: "Move multiple files in a single operation. Use when moving many files into one folder: pass { destination: \"<folder path>\", sources: [\"<path1>\", \"<path2>\", ...] }—each file is moved into that folder keeping its name. Or pass { operations: [ { source, destination }, ... ] } for full control. Prefer one call over many file_move calls.",
806
+ inputSchema: {
807
+ type: "object",
808
+ required: [],
809
+ properties: {
810
+ operations: {
811
+ type: "array",
812
+ description: "Array of { source, destination } for each move; or array of source paths if destination is also provided",
813
+ items: {
814
+ type: "object",
815
+ properties: {
816
+ source: { type: "string", description: "Current path of the file" },
817
+ destination: { type: "string", description: "New path for the file" },
818
+ },
819
+ },
820
+ },
821
+ destination: { type: "string", description: "When moving many files into one folder, target folder path (use with sources or operations as path list)" },
822
+ sources: { type: "array", items: { type: "string" }, description: "Paths of files to move into destination (each keeps its filename)" },
823
+ overwrite: { type: "boolean", description: "If true, overwrite existing files at destinations" },
824
+ },
825
+ additionalProperties: true,
826
+ },
827
+ risk: "write",
828
+ execute: runFileBatchMove,
829
+ },
830
+ {
831
+ name: "file_find",
832
+ description: "Find files or folders by name with fuzzy matching. Handles case, underscores, hyphens, spaces, and numbers (e.g. 'us debt clock' finds 'US_Debt_Clock', 'UsDebtClock'). Use when the user gives a name that might not match exactly. Prefer searchPath '~' to search all allowed roots when the location is unknown.",
833
+ inputSchema: {
834
+ type: "object",
835
+ required: ["searchPath", "name"],
836
+ properties: {
837
+ searchPath: { type: "string", description: "Directory to search within (e.g. ~/Downloads). Use '~' to search all allowed roots (Desktop, Documents, Downloads, etc.) in one call." },
838
+ name: { type: "string", description: "Name to search for (fuzzy matching: case, underscores, spaces ignored)" },
839
+ type: { type: "string", enum: ["file", "directory", "any"], description: "Filter by type (default: any)" },
840
+ maxDepth: { type: "number", description: "How deep to search in subdirectories (default: 3, max: 10)" },
841
+ },
842
+ additionalProperties: false,
843
+ },
844
+ risk: "read",
845
+ execute: runFileFind,
846
+ },
395
847
  ];
@@ -8,6 +8,8 @@ type CalendarCreateInput = {
8
8
  end?: string;
9
9
  location?: string;
10
10
  notes?: string;
11
+ /** Calendar name to create the event on (e.g. "Personal", "Work"). If omitted, uses default calendar. */
12
+ calendar?: string;
11
13
  };
12
14
 
13
15
  type CalendarListInput = {
@@ -606,6 +608,7 @@ async function runCalendarCreateEvent(input: unknown, context: ToolExecutionCont
606
608
  const end = endInput || start;
607
609
  const location = typeof payload.location === "string" ? payload.location.trim() : "";
608
610
  const notes = typeof payload.notes === "string" ? payload.notes.trim() : "";
611
+ const calendarName = typeof payload.calendar === "string" && payload.calendar.trim() ? payload.calendar.trim() : "";
609
612
  const startParts = parseDateTimeParts(start, { defaultHour: 9, defaultMinute: 0 });
610
613
  if (!startParts) {
611
614
  throw new Error(`Invalid start date/time: ${start}`);
@@ -631,6 +634,7 @@ async function runCalendarCreateEvent(input: unknown, context: ToolExecutionCont
631
634
  endParts,
632
635
  location,
633
636
  notes,
637
+ calendar: calendarName || null,
634
638
  };
635
639
  }
636
640
 
@@ -649,11 +653,27 @@ async function runCalendarCreateEvent(input: unknown, context: ToolExecutionCont
649
653
  'set endMinute to (item 11 of argv) as integer\n' +
650
654
  'set eventLocation to item 12 of argv\n' +
651
655
  'set eventNotes to item 13 of argv\n' +
656
+ 'set requestedCalendar to item 14 of argv\n' +
652
657
  'tell application "Calendar"\n' +
653
658
  'set targetCalendar to missing value\n' +
659
+ // First try the requested calendar name (case-insensitive)
660
+ 'if requestedCalendar is not "" then\n' +
661
+ 'repeat with cal in calendars\n' +
662
+ 'ignoring case\n' +
663
+ 'if (name of cal as text) is equal to requestedCalendar then\n' +
664
+ 'set targetCalendar to cal\n' +
665
+ 'exit repeat\n' +
666
+ 'end if\n' +
667
+ 'end ignoring\n' +
668
+ 'end repeat\n' +
669
+ 'end if\n' +
670
+ // Fallback: try "Home" calendar if no calendar specified
671
+ 'if targetCalendar is missing value and requestedCalendar is "" then\n' +
654
672
  'try\n' +
655
673
  'set targetCalendar to calendar "Home"\n' +
656
674
  'end try\n' +
675
+ 'end if\n' +
676
+ // Last fallback: first available calendar
657
677
  'if targetCalendar is missing value then\n' +
658
678
  'try\n' +
659
679
  'set targetCalendar to first calendar\n' +
@@ -673,9 +693,9 @@ async function runCalendarCreateEvent(input: unknown, context: ToolExecutionCont
673
693
  'tell targetCalendar\n' +
674
694
  'set createdEvent to make new event with properties {summary:eventTitle, start date:startDate, end date:endDate, location:eventLocation, description:eventNotes}\n' +
675
695
  "end tell\n" +
676
- 'set calendarName to (name of targetCalendar as text)\n' +
696
+ 'set calendarUsed to (name of targetCalendar as text)\n' +
677
697
  "end tell\n" +
678
- 'return "created" & tab & calendarName\n' +
698
+ 'return "created" & tab & calendarUsed\n' +
679
699
  "end run";
680
700
  const output = await runAppleScript(
681
701
  script,
@@ -693,10 +713,11 @@ async function runCalendarCreateEvent(input: unknown, context: ToolExecutionCont
693
713
  String(endParts.minute),
694
714
  location,
695
715
  notes,
716
+ calendarName,
696
717
  ],
697
718
  context.signal,
698
719
  );
699
- const [, calendarName] = output.split("\t");
720
+ const [, calendarUsed] = output.split("\t");
700
721
  return {
701
722
  created: true,
702
723
  title,
@@ -712,7 +733,7 @@ async function runCalendarCreateEvent(input: unknown, context: ToolExecutionCont
712
733
  ).padStart(2, "0")}T${String(endParts.hour).padStart(2, "0")}:${String(
713
734
  endParts.minute,
714
735
  ).padStart(2, "0")}:00`,
715
- calendar: calendarName?.trim() || null,
736
+ calendar: calendarUsed?.trim() || null,
716
737
  location,
717
738
  };
718
739
  }
@@ -1244,7 +1265,7 @@ export const scheduleTools: ToolDefinition[] = [
1244
1265
  },
1245
1266
  {
1246
1267
  name: "calendar_create_event",
1247
- description: "Create a Calendar event. Use explicit dates/times (e.g. YYYY-MM-DD or 'Friday at 2pm') so the event is created on the correct day.",
1268
+ description: "Create a Calendar event. Use explicit dates/times (e.g. YYYY-MM-DD or 'Friday at 2pm') so the event is created on the correct day. Specify calendar name (e.g. 'Personal', 'Work') to target a specific calendar.",
1248
1269
  inputSchema: {
1249
1270
  type: "object",
1250
1271
  required: ["title", "start"],
@@ -1254,6 +1275,7 @@ export const scheduleTools: ToolDefinition[] = [
1254
1275
  end: { type: "string" },
1255
1276
  location: { type: "string" },
1256
1277
  notes: { type: "string" },
1278
+ calendar: { type: "string", description: "Calendar name (e.g. 'Personal', 'Work'). Case-insensitive. If omitted, defaults to Home or first calendar." },
1257
1279
  },
1258
1280
  additionalProperties: false,
1259
1281
  },