├── .github └── FUNDING.yml ├── .gitignore ├── CLAUDE.md ├── LICENSE ├── README.md ├── bun.lockb ├── index.ts ├── package.json ├── tools.ts ├── tsconfig.json └── utils ├── calendar.ts ├── contacts.ts ├── mail.ts ├── maps.ts ├── message.ts ├── notes.ts ├── reminders.ts └── webSearch.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: dhravya 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # MCP related files/directories 18 | mcp-debug-tools/ 19 | debugmcp 20 | howtomcp 21 | howtocalendar 22 | cursor 23 | seyub 24 | debug 25 | mcp 26 | index-safe.ts 27 | setup-global-command.sh 28 | update-command.sh 29 | 30 | # Diagnostic reports (https://nodejs.org/api/report.html) 31 | 32 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 33 | 34 | # Runtime data 35 | 36 | pids 37 | _.pid 38 | _.seed 39 | *.pid.lock 40 | 41 | # Directory for instrumented libs generated by jscoverage/JSCover 42 | 43 | lib-cov 44 | 45 | # Coverage directory used by tools like istanbul 46 | 47 | coverage 48 | *.lcov 49 | 50 | # nyc test coverage 51 | 52 | .nyc_output 53 | 54 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 55 | 56 | .grunt 57 | 58 | # Bower dependency directory (https://bower.io/) 59 | 60 | bower_components 61 | 62 | # node-waf configuration 63 | 64 | .lock-wscript 65 | 66 | # Compiled binary addons (https://nodejs.org/api/addons.html) 67 | 68 | build/Release 69 | 70 | # Dependency directories 71 | 72 | node_modules/ 73 | jspm_packages/ 74 | 75 | # Snowpack dependency directory (https://snowpack.dev/) 76 | 77 | web_modules/ 78 | 79 | # TypeScript cache 80 | 81 | *.tsbuildinfo 82 | 83 | # Optional npm cache directory 84 | 85 | .npm 86 | 87 | # Optional eslint cache 88 | 89 | .eslintcache 90 | 91 | # Optional stylelint cache 92 | 93 | .stylelintcache 94 | 95 | # Microbundle cache 96 | 97 | .rpt2_cache/ 98 | .rts2_cache_cjs/ 99 | .rts2_cache_es/ 100 | .rts2_cache_umd/ 101 | 102 | # Optional REPL history 103 | 104 | .node_repl_history 105 | 106 | # Output of 'npm pack' 107 | 108 | *.tgz 109 | 110 | # Yarn Integrity file 111 | 112 | .yarn-integrity 113 | 114 | # dotenv environment variable files 115 | 116 | .env 117 | .env.development.local 118 | .env.test.local 119 | .env.production.local 120 | .env.local 121 | 122 | # parcel-bundler cache (https://parceljs.org/) 123 | 124 | .parcel-cache 125 | 126 | # Next.js build output 127 | 128 | .next 129 | out 130 | 131 | # Nuxt.js build / generate output 132 | 133 | .nuxt 134 | dist 135 | 136 | # Gatsby files 137 | 138 | # Comment in the public line in if your project uses Gatsby and not Next.js 139 | 140 | # https://nextjs.org/blog/next-9-1#public-directory-support 141 | 142 | # public 143 | 144 | # vuepress build output 145 | 146 | .vuepress/dist 147 | 148 | # vuepress v2.x temp and cache directory 149 | 150 | .temp 151 | 152 | # Docusaurus cache and generated files 153 | 154 | .docusaurus 155 | 156 | # Serverless directories 157 | 158 | .serverless/ 159 | 160 | # FuseBox cache 161 | 162 | .fusebox/ 163 | 164 | # DynamoDB Local files 165 | 166 | .dynamodb/ 167 | 168 | # TernJS port file 169 | 170 | .tern-port 171 | 172 | # Stores VSCode versions used for testing VSCode extensions 173 | 174 | .vscode-test 175 | 176 | # yarn v2 177 | 178 | .yarn/cache 179 | .yarn/unplugged 180 | .yarn/build-state.yml 181 | .yarn/install-state.gz 182 | .pnp.* 183 | 184 | # IntelliJ based IDEs 185 | .idea 186 | 187 | # Finder (MacOS) folder config 188 | .DS_Store 189 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # apple-mcp Development Guidelines 2 | 3 | ## Commands 4 | - `bun run dev` - Start the development server 5 | - No specific test or lint commands defined in package.json 6 | 7 | ## Code Style 8 | 9 | ### TypeScript Configuration 10 | - Target: ESNext 11 | - Module: ESNext 12 | - Strict mode enabled 13 | - Bundler module resolution 14 | 15 | ### Formatting & Structure 16 | - Use 2-space indentation (based on existing code) 17 | - Keep lines under 100 characters 18 | - Use explicit type annotations for function parameters and returns 19 | 20 | ### Naming Conventions 21 | - PascalCase for types, interfaces and Tool constants (e.g., `CONTACTS_TOOL`) 22 | - camelCase for variables and functions 23 | - Use descriptive names that reflect purpose 24 | 25 | ### Imports 26 | - Use ESM import syntax with `.js` extensions 27 | - Organize imports: external packages first, then internal modules 28 | 29 | ### Error Handling 30 | - Use try/catch blocks around applescript execution and external operations 31 | - Return both success status and detailed error messages 32 | - Check for required parameters before operations 33 | 34 | ### Type Safety 35 | - Define strong types for all function parameters 36 | - Use type guard functions for validating incoming arguments 37 | - Provide detailed TypeScript interfaces for complex objects 38 | 39 | ### MCP Tool Structure 40 | - Follow established pattern for creating tool definitions 41 | - Include detailed descriptions and proper input schema 42 | - Organize related functionality into separate utility modules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Dhravya Shah 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apple MCP tools 2 | 3 | [![smithery badge](https://smithery.ai/badge/@Dhravya/apple-mcp)](https://smithery.ai/server/@Dhravya/apple-mcp) 4 | 5 | This is a collection of apple-native tools for the [MCP protocol](https://modelcontextprotocol.com/docs/mcp-protocol). 6 | 7 | Here's a step-by-step video about how to set this up, with a demo. - https://x.com/DhravyaShah/status/1892694077679763671 8 | 9 | 10 | Apple Server MCP server 11 | 12 | 13 | ![image](https://github.com/user-attachments/assets/56a5ccfa-cb1a-4226-80c5-6cc794cefc34) 14 | 15 | 16 |
17 | Here's the JSON to copy 18 | 19 | ``` 20 | { 21 | "mcpServers": { 22 | "apple-mcp": { 23 | "command": "bunx", 24 | "args": ["--no-cache", "apple-mcp@latest"] 25 | } 26 | } 27 | 28 | ``` 29 | 30 |
31 | 32 | #### Quick install 33 | 34 | To install Apple MCP for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@Dhravya/apple-mcp): 35 | 36 | ```bash 37 | npx -y @smithery/cli@latest install @Dhravya/apple-mcp --client claude 38 | ``` 39 | 40 | ... and for cursor, you can do: 41 | 42 | ```bash 43 | npx -y @smithery/cli@latest install @Dhravya/apple-mcp --client cursor 44 | ``` 45 | 46 | 47 | ## Features 48 | 49 | - Messages: 50 | - Send messages using the Apple Messages app 51 | - Read out messages 52 | - Notes: 53 | - List notes 54 | - Search & read notes in Apple Notes app 55 | - Contacts: 56 | - Search contacts for sending messages 57 | - Emails: 58 | - Send emails with multiple recipients (to, cc, bcc) and file attachments 59 | - Search emails with custom queries, mailbox selection, and result limits 60 | - Schedule emails for future delivery 61 | - List and manage scheduled emails 62 | - Check unread email counts globally or per mailbox 63 | - Reminders: 64 | - List all reminders and reminder lists 65 | - Search for reminders by text 66 | - Create new reminders with optional due dates and notes 67 | - Open the Reminders app to view specific reminders 68 | - Calendar: 69 | - Search calendar events with customizable date ranges 70 | - List upcoming events 71 | - Create new calendar events with details like title, location, and notes 72 | - Open calendar events in the Calendar app 73 | - Web Search: 74 | - Search the web using DuckDuckGo 75 | - Retrieve and process content from search results 76 | - Maps: 77 | - Search for locations and addresses 78 | - Save locations to favorites 79 | - Get directions between locations 80 | - Drop pins on the map 81 | - Create and list guides 82 | - Add places to guides 83 | 84 | - TODO: Search and open photos in Apple Photos app 85 | - TODO: Search and open music in Apple Music app 86 | 87 | 88 | You can also daisy-chain commands to create a workflow. Like: 89 | "can you please read the note about people i met in the conference, find their contacts and emails, and send them a message saying thank you for the time." 90 | 91 | (it works!) 92 | 93 | 94 | #### Manual installation 95 | 96 | You just need bun, install with `brew install oven-sh/bun/bun` 97 | 98 | Now, edit your `claude_desktop_config.json` with this: 99 | 100 | ```claude_desktop_config.json 101 | { 102 | "mcpServers": { 103 | "apple-mcp": { 104 | "command": "bunx", 105 | "args": ["@dhravya/apple-mcp@latest"] 106 | } 107 | } 108 | } 109 | ``` 110 | 111 | ### Usage 112 | 113 | Now, ask Claude to use the `apple-mcp` tool. 114 | 115 | ``` 116 | Can you send a message to John Doe? 117 | ``` 118 | 119 | ``` 120 | find all the notes related to AI and send it to my girlfriend 121 | ``` 122 | 123 | ``` 124 | create a reminder to "Buy groceries" for tomorrow at 5pm 125 | ``` 126 | 127 | ## Local Development 128 | 129 | ```bash 130 | git clone https://github.com/dhravya/apple-mcp.git 131 | cd apple-mcp 132 | bun install 133 | bun run index.ts 134 | ``` 135 | 136 | enjoy! 137 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dhravya/apple-mcp/ec3018b43e7768b643d2327fa52659e1f9baa166/bun.lockb -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 4 | import { 5 | CallToolRequestSchema, 6 | ListToolsRequestSchema, 7 | } from "@modelcontextprotocol/sdk/types.js"; 8 | import { runAppleScript } from "run-applescript"; 9 | import tools from "./tools"; 10 | 11 | interface WebSearchArgs { 12 | query: string; 13 | } 14 | 15 | // Safe mode implementation - lazy loading of modules 16 | let useEagerLoading = true; 17 | let loadingTimeout: NodeJS.Timeout | null = null; 18 | let safeModeFallback = false; 19 | 20 | console.error("Starting apple-mcp server..."); 21 | 22 | // Placeholders for modules - will either be loaded eagerly or lazily 23 | let contacts: typeof import('./utils/contacts').default | null = null; 24 | let notes: typeof import('./utils/notes').default | null = null; 25 | let message: typeof import('./utils/message').default | null = null; 26 | let mail: typeof import('./utils/mail').default | null = null; 27 | let reminders: typeof import('./utils/reminders').default | null = null; 28 | let webSearch: typeof import('./utils/webSearch').default | null = null; 29 | let calendar: typeof import('./utils/calendar').default | null = null; 30 | let maps: typeof import('./utils/maps').default | null = null; 31 | 32 | // Type map for module names to their types 33 | type ModuleMap = { 34 | contacts: typeof import('./utils/contacts').default; 35 | notes: typeof import('./utils/notes').default; 36 | message: typeof import('./utils/message').default; 37 | mail: typeof import('./utils/mail').default; 38 | reminders: typeof import('./utils/reminders').default; 39 | webSearch: typeof import('./utils/webSearch').default; 40 | calendar: typeof import('./utils/calendar').default; 41 | maps: typeof import('./utils/maps').default; 42 | }; 43 | 44 | // Helper function for lazy module loading 45 | async function loadModule(moduleName: T): Promise { 46 | if (safeModeFallback) { 47 | console.error(`Loading ${moduleName} module on demand (safe mode)...`); 48 | } 49 | 50 | try { 51 | switch (moduleName) { 52 | case 'contacts': 53 | if (!contacts) contacts = (await import('./utils/contacts')).default; 54 | return contacts as ModuleMap[T]; 55 | case 'notes': 56 | if (!notes) notes = (await import('./utils/notes')).default; 57 | return notes as ModuleMap[T]; 58 | case 'message': 59 | if (!message) message = (await import('./utils/message')).default; 60 | return message as ModuleMap[T]; 61 | case 'mail': 62 | if (!mail) mail = (await import('./utils/mail')).default; 63 | return mail as ModuleMap[T]; 64 | case 'reminders': 65 | if (!reminders) reminders = (await import('./utils/reminders')).default; 66 | return reminders as ModuleMap[T]; 67 | case 'webSearch': 68 | if (!webSearch) webSearch = (await import('./utils/webSearch')).default; 69 | return webSearch as ModuleMap[T]; 70 | case 'calendar': 71 | if (!calendar) calendar = (await import('./utils/calendar')).default; 72 | return calendar as ModuleMap[T]; 73 | case 'maps': 74 | if (!maps) maps = (await import('./utils/maps')).default; 75 | return maps as ModuleMap[T]; 76 | default: 77 | throw new Error(`Unknown module: ${moduleName}`); 78 | } 79 | } catch (e) { 80 | console.error(`Error loading module ${moduleName}:`, e); 81 | throw e; 82 | } 83 | } 84 | 85 | // Set a timeout to switch to safe mode if initialization takes too long 86 | loadingTimeout = setTimeout(() => { 87 | console.error("Loading timeout reached. Switching to safe mode (lazy loading...)"); 88 | useEagerLoading = false; 89 | safeModeFallback = true; 90 | 91 | // Clear the references to any modules that might be in a bad state 92 | contacts = null; 93 | notes = null; 94 | message = null; 95 | mail = null; 96 | reminders = null; 97 | webSearch = null; 98 | calendar = null; 99 | 100 | // Proceed with server setup 101 | initServer(); 102 | }, 5000); // 5 second timeout 103 | 104 | // Eager loading attempt 105 | async function attemptEagerLoading() { 106 | try { 107 | console.error("Attempting to eagerly load modules..."); 108 | 109 | // Try to import all modules 110 | contacts = (await import('./utils/contacts')).default; 111 | console.error("- Contacts module loaded successfully"); 112 | 113 | notes = (await import('./utils/notes')).default; 114 | console.error("- Notes module loaded successfully"); 115 | 116 | message = (await import('./utils/message')).default; 117 | console.error("- Message module loaded successfully"); 118 | 119 | mail = (await import('./utils/mail')).default; 120 | console.error("- Mail module loaded successfully"); 121 | 122 | reminders = (await import('./utils/reminders')).default; 123 | console.error("- Reminders module loaded successfully"); 124 | 125 | webSearch = (await import('./utils/webSearch')).default; 126 | console.error("- WebSearch module loaded successfully"); 127 | 128 | calendar = (await import('./utils/calendar')).default; 129 | console.error("- Calendar module loaded successfully"); 130 | 131 | maps = (await import('./utils/maps')).default; 132 | console.error("- Maps module loaded successfully"); 133 | 134 | // If we get here, clear the timeout and proceed with eager loading 135 | if (loadingTimeout) { 136 | clearTimeout(loadingTimeout); 137 | loadingTimeout = null; 138 | } 139 | 140 | console.error("All modules loaded successfully, using eager loading mode"); 141 | initServer(); 142 | } catch (error) { 143 | console.error("Error during eager loading:", error); 144 | console.error("Switching to safe mode (lazy loading)..."); 145 | 146 | // Clear any timeout if it exists 147 | if (loadingTimeout) { 148 | clearTimeout(loadingTimeout); 149 | loadingTimeout = null; 150 | } 151 | 152 | // Switch to safe mode 153 | useEagerLoading = false; 154 | safeModeFallback = true; 155 | 156 | // Clear the references to any modules that might be in a bad state 157 | contacts = null; 158 | notes = null; 159 | message = null; 160 | mail = null; 161 | reminders = null; 162 | webSearch = null; 163 | calendar = null; 164 | maps = null; 165 | 166 | // Initialize the server in safe mode 167 | initServer(); 168 | } 169 | } 170 | 171 | // Attempt eager loading first 172 | attemptEagerLoading(); 173 | 174 | // Main server object 175 | let server: Server; 176 | 177 | // Initialize the server and set up handlers 178 | function initServer() { 179 | console.error(`Initializing server in ${safeModeFallback ? 'safe' : 'standard'} mode...`); 180 | 181 | server = new Server( 182 | { 183 | name: "Apple MCP tools", 184 | version: "1.0.0", 185 | }, 186 | { 187 | capabilities: { 188 | tools: {}, 189 | }, 190 | } 191 | ); 192 | 193 | server.setRequestHandler(ListToolsRequestSchema, async () => ({ 194 | tools 195 | })); 196 | 197 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 198 | try { 199 | const { name, arguments: args } = request.params; 200 | 201 | if (!args) { 202 | throw new Error("No arguments provided"); 203 | } 204 | 205 | switch (name) { 206 | case "contacts": { 207 | if (!isContactsArgs(args)) { 208 | throw new Error("Invalid arguments for contacts tool"); 209 | } 210 | 211 | try { 212 | const contactsModule = await loadModule('contacts'); 213 | 214 | if (args.name) { 215 | const numbers = await contactsModule.findNumber(args.name); 216 | return { 217 | content: [{ 218 | type: "text", 219 | text: numbers.length ? 220 | `${args.name}: ${numbers.join(", ")}` : 221 | `No contact found for "${args.name}". Try a different name or use no name parameter to list all contacts.` 222 | }], 223 | isError: false 224 | }; 225 | } else { 226 | const allNumbers = await contactsModule.getAllNumbers(); 227 | const contactCount = Object.keys(allNumbers).length; 228 | 229 | if (contactCount === 0) { 230 | return { 231 | content: [{ 232 | type: "text", 233 | text: "No contacts found in the address book. Please make sure you have granted access to Contacts." 234 | }], 235 | isError: false 236 | }; 237 | } 238 | 239 | const formattedContacts = Object.entries(allNumbers) 240 | .filter(([_, phones]) => phones.length > 0) 241 | .map(([name, phones]) => `${name}: ${phones.join(", ")}`); 242 | 243 | return { 244 | content: [{ 245 | type: "text", 246 | text: formattedContacts.length > 0 ? 247 | `Found ${contactCount} contacts:\n\n${formattedContacts.join("\n")}` : 248 | "Found contacts but none have phone numbers. Try searching by name to see more details." 249 | }], 250 | isError: false 251 | }; 252 | } 253 | } catch (error) { 254 | return { 255 | content: [{ 256 | type: "text", 257 | text: `Error accessing contacts: ${error instanceof Error ? error.message : String(error)}` 258 | }], 259 | isError: true 260 | }; 261 | } 262 | } 263 | 264 | case "notes": { 265 | if (!isNotesArgs(args)) { 266 | throw new Error("Invalid arguments for notes tool"); 267 | } 268 | 269 | try { 270 | const notesModule = await loadModule('notes'); 271 | const { operation } = args; 272 | 273 | switch (operation) { 274 | case "search": { 275 | if (!args.searchText) { 276 | throw new Error("Search text is required for search operation"); 277 | } 278 | 279 | const foundNotes = await notesModule.findNote(args.searchText); 280 | return { 281 | content: [{ 282 | type: "text", 283 | text: foundNotes.length ? 284 | foundNotes.map(note => `${note.name}:\n${note.content}`).join("\n\n") : 285 | `No notes found for "${args.searchText}"` 286 | }], 287 | isError: false 288 | }; 289 | } 290 | 291 | case "list": { 292 | const allNotes = await notesModule.getAllNotes(); 293 | return { 294 | content: [{ 295 | type: "text", 296 | text: allNotes.length ? 297 | allNotes.map((note) => `${note.name}:\n${note.content}`) 298 | .join("\n\n") : 299 | "No notes exist." 300 | }], 301 | isError: false 302 | }; 303 | } 304 | 305 | case "create": { 306 | if (!args.title || !args.body) { 307 | throw new Error("Title and body are required for create operation"); 308 | } 309 | 310 | const result = await notesModule.createNote(args.title, args.body, args.folderName); 311 | 312 | return { 313 | content: [{ 314 | type: "text", 315 | text: result.success ? 316 | `Created note "${args.title}" in folder "${result.folderName}"${result.usedDefaultFolder ? ' (created new folder)' : ''}.` : 317 | `Failed to create note: ${result.message}` 318 | }], 319 | isError: !result.success 320 | }; 321 | } 322 | 323 | default: 324 | throw new Error(`Unknown operation: ${operation}`); 325 | } 326 | } catch (error) { 327 | return { 328 | content: [{ 329 | type: "text", 330 | text: `Error accessing notes: ${error instanceof Error ? error.message : String(error)}` 331 | }], 332 | isError: true 333 | }; 334 | } 335 | } 336 | 337 | case "messages": { 338 | if (!isMessagesArgs(args)) { 339 | throw new Error("Invalid arguments for messages tool"); 340 | } 341 | 342 | try { 343 | const messageModule = await loadModule('message'); 344 | 345 | switch (args.operation) { 346 | case "send": { 347 | if (!args.phoneNumber || !args.message) { 348 | throw new Error("Phone number and message are required for send operation"); 349 | } 350 | await messageModule.sendMessage(args.phoneNumber, args.message); 351 | return { 352 | content: [{ type: "text", text: `Message sent to ${args.phoneNumber}` }], 353 | isError: false 354 | }; 355 | } 356 | 357 | case "read": { 358 | if (!args.phoneNumber) { 359 | throw new Error("Phone number is required for read operation"); 360 | } 361 | const messages = await messageModule.readMessages(args.phoneNumber, args.limit); 362 | return { 363 | content: [{ 364 | type: "text", 365 | text: messages.length > 0 ? 366 | messages.map(msg => 367 | `[${new Date(msg.date).toLocaleString()}] ${msg.is_from_me ? 'Me' : msg.sender}: ${msg.content}` 368 | ).join("\n") : 369 | "No messages found" 370 | }], 371 | isError: false 372 | }; 373 | } 374 | 375 | case "schedule": { 376 | if (!args.phoneNumber || !args.message || !args.scheduledTime) { 377 | throw new Error("Phone number, message, and scheduled time are required for schedule operation"); 378 | } 379 | const scheduledMsg = await messageModule.scheduleMessage( 380 | args.phoneNumber, 381 | args.message, 382 | new Date(args.scheduledTime) 383 | ); 384 | return { 385 | content: [{ 386 | type: "text", 387 | text: `Message scheduled to be sent to ${args.phoneNumber} at ${scheduledMsg.scheduledTime}` 388 | }], 389 | isError: false 390 | }; 391 | } 392 | 393 | case "unread": { 394 | const messages = await messageModule.getUnreadMessages(args.limit); 395 | 396 | // Look up contact names for all messages 397 | const contactsModule = await loadModule('contacts'); 398 | const messagesWithNames = await Promise.all( 399 | messages.map(async msg => { 400 | // Only look up names for messages not from me 401 | if (!msg.is_from_me) { 402 | const contactName = await contactsModule.findContactByPhone(msg.sender); 403 | return { 404 | ...msg, 405 | displayName: contactName || msg.sender // Use contact name if found, otherwise use phone/email 406 | }; 407 | } 408 | return { 409 | ...msg, 410 | displayName: 'Me' 411 | }; 412 | }) 413 | ); 414 | 415 | return { 416 | content: [{ 417 | type: "text", 418 | text: messagesWithNames.length > 0 ? 419 | `Found ${messagesWithNames.length} unread message(s):\n` + 420 | messagesWithNames.map(msg => 421 | `[${new Date(msg.date).toLocaleString()}] From ${msg.displayName}:\n${msg.content}` 422 | ).join("\n\n") : 423 | "No unread messages found" 424 | }], 425 | isError: false 426 | }; 427 | } 428 | 429 | default: 430 | throw new Error(`Unknown operation: ${args.operation}`); 431 | } 432 | } catch (error) { 433 | return { 434 | content: [{ 435 | type: "text", 436 | text: `Error with messages operation: ${error instanceof Error ? error.message : String(error)}` 437 | }], 438 | isError: true 439 | }; 440 | } 441 | } 442 | 443 | case "mail": { 444 | if (!isMailArgs(args)) { 445 | throw new Error("Invalid arguments for mail tool"); 446 | } 447 | 448 | try { 449 | const mailModule = await loadModule('mail'); 450 | 451 | switch (args.operation) { 452 | case "unread": { 453 | // If an account is specified, we'll try to search specifically in that account 454 | let emails; 455 | if (args.account) { 456 | console.error(`Getting unread emails for account: ${args.account}`); 457 | // Use AppleScript to get unread emails from specific account 458 | const script = ` 459 | tell application "Mail" 460 | set resultList to {} 461 | try 462 | set targetAccount to first account whose name is "${args.account.replace(/"/g, '\\"')}" 463 | 464 | -- Get mailboxes for this account 465 | set acctMailboxes to every mailbox of targetAccount 466 | 467 | -- If mailbox is specified, only search in that mailbox 468 | set mailboxesToSearch to acctMailboxes 469 | ${args.mailbox ? ` 470 | set mailboxesToSearch to {} 471 | repeat with mb in acctMailboxes 472 | if name of mb is "${args.mailbox.replace(/"/g, '\\"')}" then 473 | set mailboxesToSearch to {mb} 474 | exit repeat 475 | end if 476 | end repeat 477 | ` : ''} 478 | 479 | -- Search specified mailboxes 480 | repeat with mb in mailboxesToSearch 481 | try 482 | set unreadMessages to (messages of mb whose read status is false) 483 | if (count of unreadMessages) > 0 then 484 | set msgLimit to ${args.limit || 10} 485 | if (count of unreadMessages) < msgLimit then 486 | set msgLimit to (count of unreadMessages) 487 | end if 488 | 489 | repeat with i from 1 to msgLimit 490 | try 491 | set currentMsg to item i of unreadMessages 492 | set msgData to {subject:(subject of currentMsg), sender:(sender of currentMsg), ¬ 493 | date:(date sent of currentMsg) as string, mailbox:(name of mb)} 494 | 495 | -- Try to get content if possible 496 | try 497 | set msgContent to content of currentMsg 498 | if length of msgContent > 500 then 499 | set msgContent to (text 1 thru 500 of msgContent) & "..." 500 | end if 501 | set msgData to msgData & {content:msgContent} 502 | on error 503 | set msgData to msgData & {content:"[Content not available]"} 504 | end try 505 | 506 | set end of resultList to msgData 507 | on error 508 | -- Skip problematic messages 509 | end try 510 | end repeat 511 | 512 | if (count of resultList) ≥ ${args.limit || 10} then exit repeat 513 | end if 514 | on error 515 | -- Skip problematic mailboxes 516 | end try 517 | end repeat 518 | on error errMsg 519 | return "Error: " & errMsg 520 | end try 521 | 522 | return resultList 523 | end tell`; 524 | 525 | try { 526 | const asResult = await runAppleScript(script); 527 | if (asResult && asResult.startsWith('Error:')) { 528 | throw new Error(asResult); 529 | } 530 | 531 | // Parse the results - similar to general getUnreadMails 532 | const emailData = []; 533 | const matches = asResult.match(/\{([^}]+)\}/g); 534 | if (matches && matches.length > 0) { 535 | for (const match of matches) { 536 | try { 537 | const props = match.substring(1, match.length - 1).split(','); 538 | const email: any = {}; 539 | 540 | props.forEach(prop => { 541 | const parts = prop.split(':'); 542 | if (parts.length >= 2) { 543 | const key = parts[0].trim(); 544 | const value = parts.slice(1).join(':').trim(); 545 | email[key] = value; 546 | } 547 | }); 548 | 549 | if (email.subject || email.sender) { 550 | emailData.push({ 551 | subject: email.subject || "No subject", 552 | sender: email.sender || "Unknown sender", 553 | dateSent: email.date || new Date().toString(), 554 | content: email.content || "[Content not available]", 555 | isRead: false, 556 | mailbox: `${args.account} - ${email.mailbox || "Unknown"}` 557 | }); 558 | } 559 | } catch (parseError) { 560 | console.error('Error parsing email match:', parseError); 561 | } 562 | } 563 | } 564 | 565 | emails = emailData; 566 | } catch (error) { 567 | console.error('Error getting account-specific emails:', error); 568 | // Fallback to general method if specific account fails 569 | emails = await mailModule.getUnreadMails(args.limit); 570 | } 571 | } else { 572 | // No account specified, use the general method 573 | emails = await mailModule.getUnreadMails(args.limit); 574 | } 575 | 576 | return { 577 | content: [{ 578 | type: "text", 579 | text: emails.length > 0 ? 580 | `Found ${emails.length} unread email(s)${args.account ? ` in account "${args.account}"` : ''}${args.mailbox ? ` and mailbox "${args.mailbox}"` : ''}:\n\n` + 581 | emails.map((email: any) => 582 | `[${email.dateSent}] From: ${email.sender}\nMailbox: ${email.mailbox}\nSubject: ${email.subject}\n${email.content.substring(0, 500)}${email.content.length > 500 ? '...' : ''}` 583 | ).join("\n\n") : 584 | `No unread emails found${args.account ? ` in account "${args.account}"` : ''}${args.mailbox ? ` and mailbox "${args.mailbox}"` : ''}` 585 | }], 586 | isError: false 587 | }; 588 | } 589 | 590 | case "search": { 591 | if (!args.searchTerm) { 592 | throw new Error("Search term is required for search operation"); 593 | } 594 | const emails = await mailModule.searchMails(args.searchTerm, args.limit); 595 | return { 596 | content: [{ 597 | type: "text", 598 | text: emails.length > 0 ? 599 | `Found ${emails.length} email(s) for "${args.searchTerm}"${args.account ? ` in account "${args.account}"` : ''}${args.mailbox ? ` and mailbox "${args.mailbox}"` : ''}:\n\n` + 600 | emails.map((email: any) => 601 | `[${email.dateSent}] From: ${email.sender}\nMailbox: ${email.mailbox}\nSubject: ${email.subject}\n${email.content.substring(0, 200)}${email.content.length > 200 ? '...' : ''}` 602 | ).join("\n\n") : 603 | `No emails found for "${args.searchTerm}"${args.account ? ` in account "${args.account}"` : ''}${args.mailbox ? ` and mailbox "${args.mailbox}"` : ''}` 604 | }], 605 | isError: false 606 | }; 607 | } 608 | 609 | case "send": { 610 | if (!args.to || !args.subject || !args.body) { 611 | throw new Error("Recipient (to), subject, and body are required for send operation"); 612 | } 613 | const result = await mailModule.sendMail(args.to, args.subject, args.body, args.cc, args.bcc); 614 | return { 615 | content: [{ type: "text", text: result }], 616 | isError: false 617 | }; 618 | } 619 | 620 | case "mailboxes": { 621 | if (args.account) { 622 | const mailboxes = await mailModule.getMailboxesForAccount(args.account); 623 | return { 624 | content: [{ 625 | type: "text", 626 | text: mailboxes.length > 0 ? 627 | `Found ${mailboxes.length} mailboxes for account "${args.account}":\n\n${mailboxes.join("\n")}` : 628 | `No mailboxes found for account "${args.account}". Make sure the account name is correct.` 629 | }], 630 | isError: false 631 | }; 632 | } else { 633 | const mailboxes = await mailModule.getMailboxes(); 634 | return { 635 | content: [{ 636 | type: "text", 637 | text: mailboxes.length > 0 ? 638 | `Found ${mailboxes.length} mailboxes:\n\n${mailboxes.join("\n")}` : 639 | "No mailboxes found. Make sure Mail app is running and properly configured." 640 | }], 641 | isError: false 642 | }; 643 | } 644 | } 645 | 646 | case "accounts": { 647 | const accounts = await mailModule.getAccounts(); 648 | return { 649 | content: [{ 650 | type: "text", 651 | text: accounts.length > 0 ? 652 | `Found ${accounts.length} email accounts:\n\n${accounts.join("\n")}` : 653 | "No email accounts found. Make sure Mail app is configured with at least one account." 654 | }], 655 | isError: false 656 | }; 657 | } 658 | 659 | default: 660 | throw new Error(`Unknown operation: ${args.operation}`); 661 | } 662 | } catch (error) { 663 | return { 664 | content: [{ 665 | type: "text", 666 | text: `Error with mail operation: ${error instanceof Error ? error.message : String(error)}` 667 | }], 668 | isError: true 669 | }; 670 | } 671 | } 672 | 673 | case "reminders": { 674 | if (!isRemindersArgs(args)) { 675 | throw new Error("Invalid arguments for reminders tool"); 676 | } 677 | 678 | try { 679 | const remindersModule = await loadModule('reminders'); 680 | 681 | const { operation } = args; 682 | 683 | if (operation === "list") { 684 | // List all reminders 685 | const lists = await remindersModule.getAllLists(); 686 | const allReminders = await remindersModule.getAllReminders(); 687 | return { 688 | content: [{ 689 | type: "text", 690 | text: `Found ${lists.length} lists and ${allReminders.length} reminders.` 691 | }], 692 | lists, 693 | reminders: allReminders, 694 | isError: false 695 | }; 696 | } 697 | else if (operation === "search") { 698 | // Search for reminders 699 | const { searchText } = args; 700 | const results = await remindersModule.searchReminders(searchText!); 701 | return { 702 | content: [{ 703 | type: "text", 704 | text: results.length > 0 705 | ? `Found ${results.length} reminders matching "${searchText}".` 706 | : `No reminders found matching "${searchText}".` 707 | }], 708 | reminders: results, 709 | isError: false 710 | }; 711 | } 712 | else if (operation === "open") { 713 | // Open a reminder 714 | const { searchText } = args; 715 | const result = await remindersModule.openReminder(searchText!); 716 | return { 717 | content: [{ 718 | type: "text", 719 | text: result.success 720 | ? `Opened Reminders app. Found reminder: ${result.reminder?.name}` 721 | : result.message 722 | }], 723 | ...result, 724 | isError: !result.success 725 | }; 726 | } 727 | else if (operation === "create") { 728 | // Create a reminder 729 | const { name, listName, notes, dueDate } = args; 730 | const result = await remindersModule.createReminder(name!, listName, notes, dueDate); 731 | return { 732 | content: [{ 733 | type: "text", 734 | text: `Created reminder "${result.name}" ${listName ? `in list "${listName}"` : ''}.` 735 | }], 736 | success: true, 737 | reminder: result, 738 | isError: false 739 | }; 740 | } 741 | else if (operation === "listById") { 742 | // Get reminders from a specific list by ID 743 | const { listId, props } = args; 744 | const results = await remindersModule.getRemindersFromListById(listId!, props); 745 | return { 746 | content: [{ 747 | type: "text", 748 | text: results.length > 0 749 | ? `Found ${results.length} reminders in list with ID "${listId}".` 750 | : `No reminders found in list with ID "${listId}".` 751 | }], 752 | reminders: results, 753 | isError: false 754 | }; 755 | } 756 | 757 | return { 758 | content: [{ 759 | type: "text", 760 | text: "Unknown operation" 761 | }], 762 | isError: true 763 | }; 764 | } catch (error) { 765 | console.error("Error in reminders tool:", error); 766 | return { 767 | content: [{ 768 | type: "text", 769 | text: `Error in reminders tool: ${error}` 770 | }], 771 | isError: true 772 | }; 773 | } 774 | } 775 | 776 | case "webSearch": { 777 | if (!isWebSearchArgs(args)) { 778 | throw new Error("Invalid arguments for web search tool"); 779 | } 780 | 781 | const webSearchModule = await loadModule('webSearch'); 782 | const result = await webSearchModule.webSearch(args.query); 783 | return { 784 | content: [{ 785 | type: "text", 786 | text: result.results.length > 0 ? 787 | `Found ${result.results.length} results for "${args.query}". ${result.results.map(r => `[${r.displayUrl}] ${r.title} - ${r.snippet} \n content: ${r.content}`).join("\n")}` : 788 | `No results found for "${args.query}".` 789 | }], 790 | isError: false 791 | }; 792 | } 793 | 794 | case "calendar": { 795 | if (!isCalendarArgs(args)) { 796 | throw new Error("Invalid arguments for calendar tool"); 797 | } 798 | 799 | try { 800 | const calendarModule = await loadModule("calendar"); 801 | const { operation } = args; 802 | 803 | 804 | switch (operation) { 805 | case "search": { 806 | const { searchText, limit, fromDate, toDate } = args; 807 | const events = await calendarModule.searchEvents(searchText!, limit, fromDate, toDate); 808 | 809 | return { 810 | content: [{ 811 | type: "text", 812 | text: events.length > 0 ? 813 | `Found ${events.length} events matching "${searchText}":\n\n${events.map(event => 814 | `${event.title} (${new Date(event.startDate!).toLocaleString()} - ${new Date(event.endDate!).toLocaleString()})\n` + 815 | `Location: ${event.location || 'Not specified'}\n` + 816 | `Calendar: ${event.calendarName}\n` + 817 | `ID: ${event.id}\n` + 818 | `${event.notes ? `Notes: ${event.notes}\n` : ''}` 819 | ).join("\n\n")}` : 820 | `No events found matching "${searchText}".` 821 | }], 822 | isError: false 823 | }; 824 | } 825 | 826 | case "open": { 827 | const { eventId } = args; 828 | const result = await calendarModule.openEvent(eventId!); 829 | 830 | return { 831 | content: [{ 832 | type: "text", 833 | text: result.success ? 834 | result.message : 835 | `Error opening event: ${result.message}` 836 | }], 837 | isError: !result.success 838 | }; 839 | } 840 | 841 | case "list": { 842 | const { limit, fromDate, toDate } = args; 843 | const events = await calendarModule.getEvents(limit, fromDate, toDate); 844 | 845 | const startDateText = fromDate ? new Date(fromDate).toLocaleDateString() : 'today'; 846 | const endDateText = toDate ? new Date(toDate).toLocaleDateString() : 'next 7 days'; 847 | 848 | return { 849 | content: [{ 850 | type: "text", 851 | text: events.length > 0 ? 852 | `Found ${events.length} events from ${startDateText} to ${endDateText}:\n\n${events.map(event => 853 | `${event.title} (${new Date(event.startDate!).toLocaleString()} - ${new Date(event.endDate!).toLocaleString()})\n` + 854 | `Location: ${event.location || 'Not specified'}\n` + 855 | `Calendar: ${event.calendarName}\n` + 856 | `ID: ${event.id}` 857 | ).join("\n\n")}` : 858 | `No events found from ${startDateText} to ${endDateText}.` 859 | }], 860 | isError: false 861 | }; 862 | } 863 | 864 | case "create": { 865 | const { title, startDate, endDate, location, notes, isAllDay, calendarName } = args; 866 | const result = await calendarModule.createEvent(title!, startDate!, endDate!, location, notes, isAllDay, calendarName); 867 | return { 868 | content: [{ 869 | type: "text", 870 | text: result.success ? 871 | `${result.message} Event scheduled from ${new Date(startDate!).toLocaleString()} to ${new Date(endDate!).toLocaleString()}${result.eventId ? `\nEvent ID: ${result.eventId}` : ''}` : 872 | `Error creating event: ${result.message}` 873 | }], 874 | isError: !result.success 875 | }; 876 | } 877 | 878 | default: 879 | throw new Error(`Unknown calendar operation: ${operation}`); 880 | } 881 | } catch (error) { 882 | return { 883 | content: [{ 884 | type: "text", 885 | text: `Error in calendar tool: ${error instanceof Error ? error.message : String(error)}` 886 | }], 887 | isError: true 888 | }; 889 | } 890 | } 891 | 892 | case "maps": { 893 | if (!isMapsArgs(args)) { 894 | throw new Error("Invalid arguments for maps tool"); 895 | } 896 | 897 | try { 898 | const mapsModule = await loadModule("maps"); 899 | const { operation } = args; 900 | 901 | switch (operation) { 902 | case "search": { 903 | const { query, limit } = args; 904 | if (!query) { 905 | throw new Error("Search query is required for search operation"); 906 | } 907 | 908 | const result = await mapsModule.searchLocations(query, limit); 909 | 910 | return { 911 | content: [{ 912 | type: "text", 913 | text: result.success ? 914 | `${result.message}\n\n${result.locations.map(location => 915 | `Name: ${location.name}\n` + 916 | `Address: ${location.address}\n` + 917 | `${location.latitude && location.longitude ? `Coordinates: ${location.latitude}, ${location.longitude}\n` : ''}` 918 | ).join("\n\n")}` : 919 | `${result.message}` 920 | }], 921 | isError: !result.success 922 | }; 923 | } 924 | 925 | case "save": { 926 | const { name, address } = args; 927 | if (!name || !address) { 928 | throw new Error("Name and address are required for save operation"); 929 | } 930 | 931 | const result = await mapsModule.saveLocation(name, address); 932 | 933 | return { 934 | content: [{ 935 | type: "text", 936 | text: result.message 937 | }], 938 | isError: !result.success 939 | }; 940 | } 941 | 942 | case "pin": { 943 | const { name, address } = args; 944 | if (!name || !address) { 945 | throw new Error("Name and address are required for pin operation"); 946 | } 947 | 948 | const result = await mapsModule.dropPin(name, address); 949 | 950 | return { 951 | content: [{ 952 | type: "text", 953 | text: result.message 954 | }], 955 | isError: !result.success 956 | }; 957 | } 958 | 959 | case "directions": { 960 | const { fromAddress, toAddress, transportType } = args; 961 | if (!fromAddress || !toAddress) { 962 | throw new Error("From and to addresses are required for directions operation"); 963 | } 964 | 965 | const result = await mapsModule.getDirections(fromAddress, toAddress, transportType as 'driving' | 'walking' | 'transit'); 966 | 967 | return { 968 | content: [{ 969 | type: "text", 970 | text: result.message 971 | }], 972 | isError: !result.success 973 | }; 974 | } 975 | 976 | case "listGuides": { 977 | const result = await mapsModule.listGuides(); 978 | 979 | return { 980 | content: [{ 981 | type: "text", 982 | text: result.message 983 | }], 984 | isError: !result.success 985 | }; 986 | } 987 | 988 | case "addToGuide": { 989 | const { address, guideName } = args; 990 | if (!address || !guideName) { 991 | throw new Error("Address and guideName are required for addToGuide operation"); 992 | } 993 | 994 | const result = await mapsModule.addToGuide(address, guideName); 995 | 996 | return { 997 | content: [{ 998 | type: "text", 999 | text: result.message 1000 | }], 1001 | isError: !result.success 1002 | }; 1003 | } 1004 | 1005 | case "createGuide": { 1006 | const { guideName } = args; 1007 | if (!guideName) { 1008 | throw new Error("Guide name is required for createGuide operation"); 1009 | } 1010 | 1011 | const result = await mapsModule.createGuide(guideName); 1012 | 1013 | return { 1014 | content: [{ 1015 | type: "text", 1016 | text: result.message 1017 | }], 1018 | isError: !result.success 1019 | }; 1020 | } 1021 | 1022 | default: 1023 | throw new Error(`Unknown maps operation: ${operation}`); 1024 | } 1025 | } catch (error) { 1026 | return { 1027 | content: [{ 1028 | type: "text", 1029 | text: `Error in maps tool: ${error instanceof Error ? error.message : String(error)}` 1030 | }], 1031 | isError: true 1032 | }; 1033 | } 1034 | } 1035 | 1036 | default: 1037 | return { 1038 | content: [{ type: "text", text: `Unknown tool: ${name}` }], 1039 | isError: true, 1040 | }; 1041 | } 1042 | } catch (error) { 1043 | return { 1044 | content: [ 1045 | { 1046 | type: "text", 1047 | text: `Error: ${error instanceof Error ? error.message : String(error)}`, 1048 | }, 1049 | ], 1050 | isError: true, 1051 | }; 1052 | } 1053 | }); 1054 | 1055 | // Start the server transport 1056 | console.error("Setting up MCP server transport..."); 1057 | 1058 | (async () => { 1059 | try { 1060 | console.error("Initializing transport..."); 1061 | const transport = new StdioServerTransport(); 1062 | 1063 | // Ensure stdout is only used for JSON messages 1064 | console.error("Setting up stdout filter..."); 1065 | const originalStdoutWrite = process.stdout.write.bind(process.stdout); 1066 | process.stdout.write = (chunk: any, encoding?: any, callback?: any) => { 1067 | // Only allow JSON messages to pass through 1068 | if (typeof chunk === "string" && !chunk.startsWith("{")) { 1069 | console.error("Filtering non-JSON stdout message"); 1070 | return true; // Silently skip non-JSON messages 1071 | } 1072 | return originalStdoutWrite(chunk, encoding, callback); 1073 | }; 1074 | 1075 | console.error("Connecting transport to server..."); 1076 | await server.connect(transport); 1077 | console.error("Server connected successfully!"); 1078 | } catch (error) { 1079 | console.error("Failed to initialize MCP server:", error); 1080 | process.exit(1); 1081 | } 1082 | })(); 1083 | } 1084 | 1085 | // Helper functions for argument type checking 1086 | function isContactsArgs(args: unknown): args is { name?: string } { 1087 | return ( 1088 | typeof args === "object" && 1089 | args !== null && 1090 | (!("name" in args) || typeof (args as { name: string }).name === "string") 1091 | ); 1092 | } 1093 | 1094 | function isNotesArgs(args: unknown): args is { 1095 | operation: "search" | "list" | "create"; 1096 | searchText?: string; 1097 | title?: string; 1098 | body?: string; 1099 | folderName?: string; 1100 | } { 1101 | if (typeof args !== "object" || args === null) { 1102 | return false; 1103 | } 1104 | 1105 | const { operation } = args as { operation?: unknown }; 1106 | if (typeof operation !== "string") { 1107 | return false; 1108 | } 1109 | 1110 | if (!["search", "list", "create"].includes(operation)) { 1111 | return false; 1112 | } 1113 | 1114 | // Validate fields based on operation 1115 | if (operation === "search") { 1116 | const { searchText } = args as { searchText?: unknown }; 1117 | if (typeof searchText !== "string" || searchText === "") { 1118 | return false; 1119 | } 1120 | } 1121 | 1122 | if (operation === "create") { 1123 | const { title, body } = args as { title?: unknown, body?: unknown }; 1124 | if (typeof title !== "string" || title === "" || 1125 | typeof body !== "string") { 1126 | return false; 1127 | } 1128 | 1129 | // Check folderName if provided 1130 | const { folderName } = args as { folderName?: unknown }; 1131 | if (folderName !== undefined && (typeof folderName !== "string" || folderName === "")) { 1132 | return false; 1133 | } 1134 | } 1135 | 1136 | return true; 1137 | } 1138 | 1139 | function isMessagesArgs(args: unknown): args is { 1140 | operation: "send" | "read" | "schedule" | "unread"; 1141 | phoneNumber?: string; 1142 | message?: string; 1143 | limit?: number; 1144 | scheduledTime?: string; 1145 | } { 1146 | if (typeof args !== "object" || args === null) return false; 1147 | 1148 | const { operation, phoneNumber, message, limit, scheduledTime } = args as any; 1149 | 1150 | if (!operation || !["send", "read", "schedule", "unread"].includes(operation)) { 1151 | return false; 1152 | } 1153 | 1154 | // Validate required fields based on operation 1155 | switch (operation) { 1156 | case "send": 1157 | case "schedule": 1158 | if (!phoneNumber || !message) return false; 1159 | if (operation === "schedule" && !scheduledTime) return false; 1160 | break; 1161 | case "read": 1162 | if (!phoneNumber) return false; 1163 | break; 1164 | case "unread": 1165 | // No additional required fields 1166 | break; 1167 | } 1168 | 1169 | // Validate field types if present 1170 | if (phoneNumber && typeof phoneNumber !== "string") return false; 1171 | if (message && typeof message !== "string") return false; 1172 | if (limit && typeof limit !== "number") return false; 1173 | if (scheduledTime && typeof scheduledTime !== "string") return false; 1174 | 1175 | return true; 1176 | } 1177 | 1178 | function isMailArgs(args: unknown): args is { 1179 | operation: "unread" | "search" | "send" | "mailboxes" | "accounts"; 1180 | account?: string; 1181 | mailbox?: string; 1182 | limit?: number; 1183 | searchTerm?: string; 1184 | to?: string; 1185 | subject?: string; 1186 | body?: string; 1187 | cc?: string; 1188 | bcc?: string; 1189 | } { 1190 | if (typeof args !== "object" || args === null) return false; 1191 | 1192 | const { operation, account, mailbox, limit, searchTerm, to, subject, body, cc, bcc } = args as any; 1193 | 1194 | if (!operation || !["unread", "search", "send", "mailboxes", "accounts"].includes(operation)) { 1195 | return false; 1196 | } 1197 | 1198 | // Validate required fields based on operation 1199 | switch (operation) { 1200 | case "search": 1201 | if (!searchTerm || typeof searchTerm !== "string") return false; 1202 | break; 1203 | case "send": 1204 | if (!to || typeof to !== "string" || 1205 | !subject || typeof subject !== "string" || 1206 | !body || typeof body !== "string") return false; 1207 | break; 1208 | case "unread": 1209 | case "mailboxes": 1210 | case "accounts": 1211 | // No additional required fields 1212 | break; 1213 | } 1214 | 1215 | // Validate field types if present 1216 | if (account && typeof account !== "string") return false; 1217 | if (mailbox && typeof mailbox !== "string") return false; 1218 | if (limit && typeof limit !== "number") return false; 1219 | if (cc && typeof cc !== "string") return false; 1220 | if (bcc && typeof bcc !== "string") return false; 1221 | 1222 | return true; 1223 | } 1224 | 1225 | function isRemindersArgs(args: unknown): args is { 1226 | operation: "list" | "search" | "open" | "create" | "listById"; 1227 | searchText?: string; 1228 | name?: string; 1229 | listName?: string; 1230 | listId?: string; 1231 | props?: string[]; 1232 | notes?: string; 1233 | dueDate?: string; 1234 | } { 1235 | if (typeof args !== "object" || args === null) { 1236 | return false; 1237 | } 1238 | 1239 | const { operation } = args as any; 1240 | if (typeof operation !== "string") { 1241 | return false; 1242 | } 1243 | 1244 | if (!["list", "search", "open", "create", "listById"].includes(operation)) { 1245 | return false; 1246 | } 1247 | 1248 | // For search and open operations, searchText is required 1249 | if ((operation === "search" || operation === "open") && 1250 | (typeof (args as any).searchText !== "string" || (args as any).searchText === "")) { 1251 | return false; 1252 | } 1253 | 1254 | // For create operation, name is required 1255 | if (operation === "create" && 1256 | (typeof (args as any).name !== "string" || (args as any).name === "")) { 1257 | return false; 1258 | } 1259 | 1260 | // For listById operation, listId is required 1261 | if (operation === "listById" && 1262 | (typeof (args as any).listId !== "string" || (args as any).listId === "")) { 1263 | return false; 1264 | } 1265 | 1266 | return true; 1267 | } 1268 | 1269 | function isWebSearchArgs(args: unknown): args is WebSearchArgs { 1270 | return ( 1271 | typeof args === "object" && 1272 | args !== null && 1273 | typeof (args as WebSearchArgs).query === "string" 1274 | ); 1275 | } 1276 | 1277 | function isCalendarArgs(args: unknown): args is { 1278 | operation: "search" | "open" | "list" | "create"; 1279 | searchText?: string; 1280 | eventId?: string; 1281 | limit?: number; 1282 | fromDate?: string; 1283 | toDate?: string; 1284 | title?: string; 1285 | startDate?: string; 1286 | endDate?: string; 1287 | location?: string; 1288 | notes?: string; 1289 | isAllDay?: boolean; 1290 | calendarName?: string; 1291 | } { 1292 | if (typeof args !== "object" || args === null) { 1293 | return false; 1294 | } 1295 | 1296 | const { operation } = args as { operation?: unknown }; 1297 | if (typeof operation !== "string") { 1298 | return false; 1299 | } 1300 | 1301 | if (!["search", "open", "list", "create"].includes(operation)) { 1302 | return false; 1303 | } 1304 | 1305 | // Check that required parameters are present for each operation 1306 | if (operation === "search") { 1307 | const { searchText } = args as { searchText?: unknown }; 1308 | if (typeof searchText !== "string") { 1309 | return false; 1310 | } 1311 | } 1312 | 1313 | if (operation === "open") { 1314 | const { eventId } = args as { eventId?: unknown }; 1315 | if (typeof eventId !== "string") { 1316 | return false; 1317 | } 1318 | } 1319 | 1320 | if (operation === "create") { 1321 | const { title, startDate, endDate } = args as { 1322 | title?: unknown; 1323 | startDate?: unknown; 1324 | endDate?: unknown; 1325 | }; 1326 | 1327 | if (typeof title !== "string" || typeof startDate !== "string" || typeof endDate !== "string") { 1328 | return false; 1329 | } 1330 | } 1331 | 1332 | return true; 1333 | } 1334 | 1335 | function isMapsArgs(args: unknown): args is { 1336 | operation: "search" | "save" | "directions" | "pin" | "listGuides" | "addToGuide" | "createGuide"; 1337 | query?: string; 1338 | limit?: number; 1339 | name?: string; 1340 | address?: string; 1341 | fromAddress?: string; 1342 | toAddress?: string; 1343 | transportType?: string; 1344 | guideName?: string; 1345 | } { 1346 | if (typeof args !== "object" || args === null) { 1347 | return false; 1348 | } 1349 | 1350 | const { operation } = args as { operation?: unknown }; 1351 | if (typeof operation !== "string") { 1352 | return false; 1353 | } 1354 | 1355 | if (!["search", "save", "directions", "pin", "listGuides", "addToGuide", "createGuide"].includes(operation)) { 1356 | return false; 1357 | } 1358 | 1359 | // Check that required parameters are present for each operation 1360 | if (operation === "search") { 1361 | const { query } = args as { query?: unknown }; 1362 | if (typeof query !== "string" || query === "") { 1363 | return false; 1364 | } 1365 | } 1366 | 1367 | if (operation === "save" || operation === "pin") { 1368 | const { name, address } = args as { name?: unknown; address?: unknown }; 1369 | if (typeof name !== "string" || name === "" || typeof address !== "string" || address === "") { 1370 | return false; 1371 | } 1372 | } 1373 | 1374 | if (operation === "directions") { 1375 | const { fromAddress, toAddress } = args as { fromAddress?: unknown; toAddress?: unknown }; 1376 | if (typeof fromAddress !== "string" || fromAddress === "" || typeof toAddress !== "string" || toAddress === "") { 1377 | return false; 1378 | } 1379 | 1380 | // Check transportType if provided 1381 | const { transportType } = args as { transportType?: unknown }; 1382 | if (transportType !== undefined && 1383 | (typeof transportType !== "string" || !["driving", "walking", "transit"].includes(transportType))) { 1384 | return false; 1385 | } 1386 | } 1387 | 1388 | if (operation === "createGuide") { 1389 | const { guideName } = args as { guideName?: unknown }; 1390 | if (typeof guideName !== "string" || guideName === "") { 1391 | return false; 1392 | } 1393 | } 1394 | 1395 | if (operation === "addToGuide") { 1396 | const { address, guideName } = args as { address?: unknown; guideName?: unknown }; 1397 | if (typeof address !== "string" || address === "" || typeof guideName !== "string" || guideName === "") { 1398 | return false; 1399 | } 1400 | } 1401 | 1402 | return true; 1403 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apple-mcp", 3 | "version": "0.2.7", 4 | "module": "index.ts", 5 | "type": "module", 6 | "description": "Apple MCP tools for contacts, notes, messages, and mail integration", 7 | "author": "Dhravya Shah", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/dhravya/apple-mcp.git" 12 | }, 13 | "keywords": [ 14 | "mcp", 15 | "apple", 16 | "contacts", 17 | "notes", 18 | "messages", 19 | "mail", 20 | "claude" 21 | ], 22 | "bin": { 23 | "apple-mcp": "./index.ts" 24 | }, 25 | "scripts": { 26 | "dev": "bun run index.ts" 27 | }, 28 | "devDependencies": { 29 | "@types/bun": "latest", 30 | "@types/node": "^22.13.4" 31 | }, 32 | "peerDependencies": { 33 | "typescript": "^5.0.0" 34 | }, 35 | "dependencies": { 36 | "@hono/node-server": "^1.13.8", 37 | "@jxa/global-type": "^1.3.6", 38 | "@jxa/run": "^1.3.6", 39 | "@modelcontextprotocol/sdk": "^1.5.0", 40 | "@types/express": "^5.0.0", 41 | "mcp-proxy": "^2.4.0", 42 | "run-applescript": "^7.0.0", 43 | "zod": "^3.24.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tools.ts: -------------------------------------------------------------------------------- 1 | import { type Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | 3 | const CONTACTS_TOOL: Tool = { 4 | name: "contacts", 5 | description: "Search and retrieve contacts from Apple Contacts app", 6 | inputSchema: { 7 | type: "object", 8 | properties: { 9 | name: { 10 | type: "string", 11 | description: "Name to search for (optional - if not provided, returns all contacts). Can be partial name to search." 12 | } 13 | } 14 | } 15 | }; 16 | 17 | const NOTES_TOOL: Tool = { 18 | name: "notes", 19 | description: "Search, retrieve and create notes in Apple Notes app", 20 | inputSchema: { 21 | type: "object", 22 | properties: { 23 | operation: { 24 | type: "string", 25 | description: "Operation to perform: 'search', 'list', or 'create'", 26 | enum: ["search", "list", "create"] 27 | }, 28 | searchText: { 29 | type: "string", 30 | description: "Text to search for in notes (required for search operation)" 31 | }, 32 | title: { 33 | type: "string", 34 | description: "Title of the note to create (required for create operation)" 35 | }, 36 | body: { 37 | type: "string", 38 | description: "Content of the note to create (required for create operation)" 39 | }, 40 | folderName: { 41 | type: "string", 42 | description: "Name of the folder to create the note in (optional for create operation, defaults to 'Claude')" 43 | } 44 | }, 45 | required: ["operation"] 46 | } 47 | }; 48 | 49 | const MESSAGES_TOOL: Tool = { 50 | name: "messages", 51 | description: "Interact with Apple Messages app - send, read, schedule messages and check unread messages", 52 | inputSchema: { 53 | type: "object", 54 | properties: { 55 | operation: { 56 | type: "string", 57 | description: "Operation to perform: 'send', 'read', 'schedule', or 'unread'", 58 | enum: ["send", "read", "schedule", "unread"] 59 | }, 60 | phoneNumber: { 61 | type: "string", 62 | description: "Phone number to send message to (required for send, read, and schedule operations)" 63 | }, 64 | message: { 65 | type: "string", 66 | description: "Message to send (required for send and schedule operations)" 67 | }, 68 | limit: { 69 | type: "number", 70 | description: "Number of messages to read (optional, for read and unread operations)" 71 | }, 72 | scheduledTime: { 73 | type: "string", 74 | description: "ISO string of when to send the message (required for schedule operation)" 75 | } 76 | }, 77 | required: ["operation"] 78 | } 79 | }; 80 | 81 | const MAIL_TOOL: Tool = { 82 | name: "mail", 83 | description: "Interact with Apple Mail app - read unread emails, search emails, and send emails", 84 | inputSchema: { 85 | type: "object", 86 | properties: { 87 | operation: { 88 | type: "string", 89 | description: "Operation to perform: 'unread', 'search', 'send', 'mailboxes', or 'accounts'", 90 | enum: ["unread", "search", "send", "mailboxes", "accounts"] 91 | }, 92 | account: { 93 | type: "string", 94 | description: "Email account to use (optional - if not provided, searches across all accounts)" 95 | }, 96 | mailbox: { 97 | type: "string", 98 | description: "Mailbox to use (optional - if not provided, uses inbox or searches across all mailboxes)" 99 | }, 100 | limit: { 101 | type: "number", 102 | description: "Number of emails to retrieve (optional, for unread and search operations)" 103 | }, 104 | searchTerm: { 105 | type: "string", 106 | description: "Text to search for in emails (required for search operation)" 107 | }, 108 | to: { 109 | type: "string", 110 | description: "Recipient email address (required for send operation)" 111 | }, 112 | subject: { 113 | type: "string", 114 | description: "Email subject (required for send operation)" 115 | }, 116 | body: { 117 | type: "string", 118 | description: "Email body content (required for send operation)" 119 | }, 120 | cc: { 121 | type: "string", 122 | description: "CC email address (optional for send operation)" 123 | }, 124 | bcc: { 125 | type: "string", 126 | description: "BCC email address (optional for send operation)" 127 | } 128 | }, 129 | required: ["operation"] 130 | } 131 | }; 132 | 133 | const REMINDERS_TOOL: Tool = { 134 | name: "reminders", 135 | description: "Search, create, and open reminders in Apple Reminders app", 136 | inputSchema: { 137 | type: "object", 138 | properties: { 139 | operation: { 140 | type: "string", 141 | description: "Operation to perform: 'list', 'search', 'open', 'create', or 'listById'", 142 | enum: ["list", "search", "open", "create", "listById"] 143 | }, 144 | searchText: { 145 | type: "string", 146 | description: "Text to search for in reminders (required for search and open operations)" 147 | }, 148 | name: { 149 | type: "string", 150 | description: "Name of the reminder to create (required for create operation)" 151 | }, 152 | listName: { 153 | type: "string", 154 | description: "Name of the list to create the reminder in (optional for create operation)" 155 | }, 156 | listId: { 157 | type: "string", 158 | description: "ID of the list to get reminders from (required for listById operation)" 159 | }, 160 | props: { 161 | type: "array", 162 | items: { 163 | type: "string" 164 | }, 165 | description: "Properties to include in the reminders (optional for listById operation)" 166 | }, 167 | notes: { 168 | type: "string", 169 | description: "Additional notes for the reminder (optional for create operation)" 170 | }, 171 | dueDate: { 172 | type: "string", 173 | description: "Due date for the reminder in ISO format (optional for create operation)" 174 | } 175 | }, 176 | required: ["operation"] 177 | } 178 | }; 179 | 180 | const WEB_SEARCH_TOOL: Tool = { 181 | name: "webSearch", 182 | description: "Search the web using DuckDuckGo and retrieve content from search results", 183 | inputSchema: { 184 | type: "object", 185 | properties: { 186 | query: { 187 | type: "string", 188 | description: "Search query to look up" 189 | } 190 | }, 191 | required: ["query"] 192 | } 193 | }; 194 | 195 | const CALENDAR_TOOL: Tool = { 196 | name: "calendar", 197 | description: "Search, create, and open calendar events in Apple Calendar app", 198 | inputSchema: { 199 | type: "object", 200 | properties: { 201 | operation: { 202 | type: "string", 203 | description: "Operation to perform: 'search', 'open', 'list', or 'create'", 204 | enum: ["search", "open", "list", "create"] 205 | }, 206 | searchText: { 207 | type: "string", 208 | description: "Text to search for in event titles, locations, and notes (required for search operation)" 209 | }, 210 | eventId: { 211 | type: "string", 212 | description: "ID of the event to open (required for open operation)" 213 | }, 214 | limit: { 215 | type: "number", 216 | description: "Number of events to retrieve (optional, default 10)" 217 | }, 218 | fromDate: { 219 | type: "string", 220 | description: "Start date for search range in ISO format (optional, default is today)" 221 | }, 222 | toDate: { 223 | type: "string", 224 | description: "End date for search range in ISO format (optional, default is 30 days from now for search, 7 days for list)" 225 | }, 226 | title: { 227 | type: "string", 228 | description: "Title of the event to create (required for create operation)" 229 | }, 230 | startDate: { 231 | type: "string", 232 | description: "Start date/time of the event in ISO format (required for create operation)" 233 | }, 234 | endDate: { 235 | type: "string", 236 | description: "End date/time of the event in ISO format (required for create operation)" 237 | }, 238 | location: { 239 | type: "string", 240 | description: "Location of the event (optional for create operation)" 241 | }, 242 | notes: { 243 | type: "string", 244 | description: "Additional notes for the event (optional for create operation)" 245 | }, 246 | isAllDay: { 247 | type: "boolean", 248 | description: "Whether the event is an all-day event (optional for create operation, default is false)" 249 | }, 250 | calendarName: { 251 | type: "string", 252 | description: "Name of the calendar to create the event in (optional for create operation, uses default calendar if not specified)" 253 | } 254 | }, 255 | required: ["operation"] 256 | } 257 | }; 258 | 259 | const MAPS_TOOL: Tool = { 260 | name: "maps", 261 | description: "Search locations, manage guides, save favorites, and get directions using Apple Maps", 262 | inputSchema: { 263 | type: "object", 264 | properties: { 265 | operation: { 266 | type: "string", 267 | description: "Operation to perform with Maps", 268 | enum: ["search", "save", "directions", "pin", "listGuides", "addToGuide", "createGuide"] 269 | }, 270 | query: { 271 | type: "string", 272 | description: "Search query for locations (required for search)" 273 | }, 274 | limit: { 275 | type: "number", 276 | description: "Maximum number of results to return (optional for search)" 277 | }, 278 | name: { 279 | type: "string", 280 | description: "Name of the location (required for save and pin)" 281 | }, 282 | address: { 283 | type: "string", 284 | description: "Address of the location (required for save, pin, addToGuide)" 285 | }, 286 | fromAddress: { 287 | type: "string", 288 | description: "Starting address for directions (required for directions)" 289 | }, 290 | toAddress: { 291 | type: "string", 292 | description: "Destination address for directions (required for directions)" 293 | }, 294 | transportType: { 295 | type: "string", 296 | description: "Type of transport to use (optional for directions)", 297 | enum: ["driving", "walking", "transit"] 298 | }, 299 | guideName: { 300 | type: "string", 301 | description: "Name of the guide (required for createGuide and addToGuide)" 302 | } 303 | }, 304 | required: ["operation"] 305 | } 306 | }; 307 | 308 | const tools = [CONTACTS_TOOL, NOTES_TOOL, MESSAGES_TOOL, MAIL_TOOL, REMINDERS_TOOL, WEB_SEARCH_TOOL, CALENDAR_TOOL, MAPS_TOOL]; 309 | 310 | export default tools; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | "types": ["@jxa/global-type", "node"], 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /utils/calendar.ts: -------------------------------------------------------------------------------- 1 | import { run } from '@jxa/run'; 2 | 3 | // Define types for our calendar events 4 | interface CalendarEvent { 5 | id: string; 6 | title: string; 7 | location: string | null; 8 | notes: string | null; 9 | startDate: string | null; 10 | endDate: string | null; 11 | calendarName: string; 12 | isAllDay: boolean; 13 | url: string | null; 14 | } 15 | 16 | // Configuration for timeouts and limits 17 | const CONFIG = { 18 | // Maximum time (in ms) to wait for calendar operations 19 | TIMEOUT_MS: 8000, 20 | // Maximum number of events to process per calendar 21 | MAX_EVENTS_PER_CALENDAR: 50, 22 | // Maximum number of calendars to process 23 | MAX_CALENDARS: 1 24 | }; 25 | 26 | /** 27 | * Check if the Calendar app is accessible 28 | * @returns Promise resolving to true if Calendar is accessible, throws error otherwise 29 | */ 30 | async function checkCalendarAccess(): Promise { 31 | try { 32 | // Try to access Calendar app as a simple test 33 | const result = await run(() => { 34 | try { 35 | // Try to directly access Calendar without launching it first 36 | const Calendar = Application("Calendar"); 37 | Calendar.name(); // Just try to get the name to test access 38 | return true; 39 | } catch (e) { 40 | // Don't use console.log in JXA 41 | throw new Error("Cannot access Calendar app"); 42 | } 43 | }) as boolean; 44 | 45 | return result; 46 | } catch (error) { 47 | console.error(`Cannot access Calendar app: ${error instanceof Error ? error.message : String(error)}`); 48 | return false; 49 | } 50 | } 51 | 52 | /** 53 | * Search for calendar events that match the search text 54 | * @param searchText Text to search for in event titles, locations, and notes 55 | * @param limit Optional limit on the number of results (default 10) 56 | * @param fromDate Optional start date for search range in ISO format (default: today) 57 | * @param toDate Optional end date for search range in ISO format (default: 30 days from now) 58 | * @returns Array of calendar events matching the search criteria 59 | */ 60 | async function searchEvents( 61 | searchText: string, 62 | limit = 10, 63 | fromDate?: string, 64 | toDate?: string 65 | ): Promise { 66 | try { 67 | if (!await checkCalendarAccess()) { 68 | return []; 69 | } 70 | 71 | console.error(`searchEvents - Processing calendars for search: "${searchText}"`); 72 | 73 | const events = await run((args: { 74 | searchText: string, 75 | limit: number, 76 | fromDate?: string, 77 | toDate?: string, 78 | maxEventsPerCalendar: number 79 | }) => { 80 | try { 81 | const Calendar = Application("Calendar"); 82 | 83 | // Set default date range if not provided (today to 30 days from now) 84 | const today = new Date(); 85 | const defaultStartDate = today; 86 | const defaultEndDate = new Date(); 87 | defaultEndDate.setDate(today.getDate() + 30); 88 | 89 | const startDate = args.fromDate ? new Date(args.fromDate) : defaultStartDate; 90 | const endDate = args.toDate ? new Date(args.toDate) : defaultEndDate; 91 | 92 | // Array to store matching events 93 | const matchingEvents: CalendarEvent[] = []; 94 | 95 | // Get all calendars at once 96 | const allCalendars = Calendar.calendars(); 97 | 98 | // Search in each calendar 99 | for (let i = 0; i < allCalendars.length && matchingEvents.length < args.limit; i++) { 100 | try { 101 | const calendar = allCalendars[i]; 102 | const calendarName = calendar.name(); 103 | 104 | // Get all events from this calendar 105 | const events = calendar.events.whose({ 106 | _and: [ 107 | { startDate: { _greaterThan: startDate }}, 108 | { endDate: { _lessThan: endDate}}, 109 | { summary: { _contains: args.searchText}} 110 | ] 111 | }); 112 | 113 | const convertedEvents = events(); 114 | 115 | // Limit the number of events to process 116 | const eventCount = Math.min(convertedEvents.length, args.maxEventsPerCalendar); 117 | 118 | // Filter events by date range and search text 119 | for (let j = 0; j < eventCount && matchingEvents.length < args.limit; j++) { 120 | const event = convertedEvents[j]; 121 | 122 | try { 123 | const eventStartDate = new Date(event.startDate()); 124 | const eventEndDate = new Date(event.endDate()); 125 | 126 | // Skip events outside our date range 127 | if (eventEndDate < startDate || eventStartDate > endDate) { 128 | continue; 129 | } 130 | 131 | // Get event details 132 | let title = ""; 133 | let location = ""; 134 | let notes = ""; 135 | 136 | try { title = event.summary(); } catch (e) { title = "Unknown Title"; } 137 | try { location = event.location() || ""; } catch (e) { location = ""; } 138 | try { notes = event.description() || ""; } catch (e) { notes = ""; } 139 | 140 | // Check if event matches search text 141 | if ( 142 | title.toLowerCase().includes(args.searchText.toLowerCase()) || 143 | location.toLowerCase().includes(args.searchText.toLowerCase()) || 144 | notes.toLowerCase().includes(args.searchText.toLowerCase()) 145 | ) { 146 | // Create event object 147 | const eventData: CalendarEvent = { 148 | id: "", 149 | title: title, 150 | location: location, 151 | notes: notes, 152 | startDate: null, 153 | endDate: null, 154 | calendarName: calendarName, 155 | isAllDay: false, 156 | url: null 157 | }; 158 | 159 | try { eventData.id = event.uid(); } 160 | catch (e) { eventData.id = `unknown-${Date.now()}-${Math.random()}`; } 161 | 162 | try { eventData.startDate = eventStartDate.toISOString(); } 163 | catch (e) { /* Keep as null */ } 164 | 165 | try { eventData.endDate = eventEndDate.toISOString(); } 166 | catch (e) { /* Keep as null */ } 167 | 168 | try { eventData.isAllDay = event.alldayEvent(); } 169 | catch (e) { /* Keep as false */ } 170 | 171 | try { eventData.url = event.url(); } 172 | catch (e) { /* Keep as null */ } 173 | 174 | matchingEvents.push(eventData); 175 | } 176 | } catch (e) { 177 | // Skip events we can't process 178 | console.log("searchEvents - Error processing events: ----0----", JSON.stringify(e)); 179 | } 180 | } 181 | } catch (e) { 182 | // Skip calendars we can't access 183 | console.log("searchEvents - Error processing calendars: ----1----", JSON.stringify(e)); 184 | } 185 | } 186 | 187 | return matchingEvents; 188 | } catch (e) { 189 | return []; // Return empty array on any error 190 | } 191 | }, { 192 | searchText, 193 | limit, 194 | fromDate, 195 | toDate, 196 | maxEventsPerCalendar: CONFIG.MAX_EVENTS_PER_CALENDAR 197 | }) as CalendarEvent[]; 198 | 199 | // If no events found, create dummy events 200 | if (events.length === 0) { 201 | console.error("searchEvents - No events found, creating dummy events"); 202 | return []; 203 | } 204 | 205 | return events; 206 | } catch (error) { 207 | console.error(`Error searching events: ${error instanceof Error ? error.message : String(error)}`); 208 | // Fall back to dummy events on error 209 | return []; 210 | } 211 | } 212 | 213 | 214 | /** 215 | * Open a specific calendar event in the Calendar app 216 | * @param eventId ID of the event to open 217 | * @returns Result object indicating success or failure 218 | */ 219 | async function openEvent(eventId: string): Promise<{ success: boolean; message: string }> { 220 | try { 221 | if (!await checkCalendarAccess()) { 222 | return { 223 | success: false, 224 | message: "Cannot access Calendar app. Please grant access in System Settings > Privacy & Security > Automation." 225 | }; 226 | } 227 | 228 | console.error(`openEvent - Attempting to open event with ID: ${eventId}`); 229 | 230 | const result = await run((args: { 231 | eventId: string, 232 | maxEventsPerCalendar: number 233 | }) => { 234 | try { 235 | const Calendar = Application("Calendar"); 236 | 237 | // Get all calendars at once 238 | const allCalendars = Calendar.calendars(); 239 | 240 | // Search in each calendar 241 | for (let i = 0; i < allCalendars.length; i++) { 242 | try { 243 | const calendar = allCalendars[i]; 244 | 245 | // Get the event from this calendar 246 | const events = calendar.events.whose({ 247 | uid: { _equals: args.eventId } 248 | }); 249 | 250 | const event = events[0] 251 | 252 | if(event.uid() === args.eventId) { 253 | Calendar.activate(); 254 | event.show(); 255 | return { 256 | success: true, 257 | message: `Successfully opened event: ${event.summary()}` 258 | }; 259 | } 260 | 261 | } catch (e) { 262 | // Skip calendars we can't access 263 | console.log("openEvent - Error processing calendars: ----2----", JSON.stringify(e)); 264 | } 265 | } 266 | 267 | return { 268 | success: false, 269 | message: `No event found with ID: ${args.eventId}` 270 | }; 271 | } catch (e) { 272 | return { 273 | success: false, 274 | message: "Error opening event" 275 | }; 276 | } 277 | }, { 278 | eventId, 279 | maxEventsPerCalendar: CONFIG.MAX_EVENTS_PER_CALENDAR 280 | }) as { success: boolean; message: string }; 281 | 282 | return result; 283 | } catch (error) { 284 | return { 285 | success: false, 286 | message: `Error opening event: ${error instanceof Error ? error.message : String(error)}` 287 | }; 288 | } 289 | } 290 | 291 | /** 292 | * Get all calendar events in a specified date range 293 | * @param limit Optional limit on the number of results (default 10) 294 | * @param fromDate Optional start date for search range in ISO format (default: today) 295 | * @param toDate Optional end date for search range in ISO format (default: 7 days from now) 296 | * @returns Array of calendar events in the specified date range 297 | */ 298 | async function getEvents( 299 | limit = 10, 300 | fromDate?: string, 301 | toDate?: string 302 | ): Promise { 303 | try { 304 | console.error("getEvents - Starting to fetch calendar events"); 305 | 306 | if (!await checkCalendarAccess()) { 307 | console.error("getEvents - Failed to access Calendar app"); 308 | return []; 309 | } 310 | console.error("getEvents - Calendar access check passed"); 311 | 312 | const events = await run((args: { 313 | limit: number, 314 | fromDate?: string, 315 | toDate?: string, 316 | maxEventsPerCalendar: number 317 | }) => { 318 | try { 319 | // Access the Calendar app directly 320 | const Calendar = Application("Calendar"); 321 | 322 | // Set default date range if not provided (today to 7 days from now) 323 | const today = new Date(); 324 | const defaultStartDate = today; 325 | const defaultEndDate = new Date(); 326 | defaultEndDate.setDate(today.getDate() + 7); 327 | 328 | const startDate = args.fromDate ? new Date(args.fromDate) : defaultStartDate; 329 | const endDate = args.toDate ? new Date(args.toDate) : defaultEndDate; 330 | 331 | const calendars = Calendar.calendars(); 332 | 333 | // Array to store events 334 | const events: CalendarEvent[] = []; 335 | 336 | // Get events from each calendar 337 | for (const calender of calendars) { 338 | if (events.length >= args.limit) break; 339 | 340 | try { 341 | // Get all events from this calendar 342 | const calendarEvents = calender.events.whose({ 343 | _and: [ 344 | { startDate: { _greaterThan: startDate }}, 345 | { endDate: { _lessThan: endDate}} 346 | ] 347 | }); 348 | const convertedEvents = calendarEvents(); 349 | 350 | // Limit the number of events to process 351 | const eventCount = Math.min(convertedEvents.length, args.maxEventsPerCalendar); 352 | 353 | // Process events 354 | for (let i = 0; i < eventCount && events.length < args.limit; i++) { 355 | const event = convertedEvents[i]; 356 | 357 | try { 358 | const eventStartDate = new Date(event.startDate()); 359 | const eventEndDate = new Date(event.endDate()); 360 | 361 | // Skip events outside our date range 362 | if (eventEndDate < startDate || eventStartDate > endDate) { 363 | continue; 364 | } 365 | 366 | // Create event object 367 | const eventData: CalendarEvent = { 368 | id: "", 369 | title: "Unknown Title", 370 | location: null, 371 | notes: null, 372 | startDate: null, 373 | endDate: null, 374 | calendarName: calender.name(), 375 | isAllDay: false, 376 | url: null 377 | }; 378 | 379 | try { eventData.id = event.uid(); } 380 | catch (e) { eventData.id = `unknown-${Date.now()}-${Math.random()}`; } 381 | 382 | try { eventData.title = event.summary(); } 383 | catch (e) { /* Keep default title */ } 384 | 385 | try { eventData.location = event.location(); } 386 | catch (e) { /* Keep as null */ } 387 | 388 | try { eventData.notes = event.description(); } 389 | catch (e) { /* Keep as null */ } 390 | 391 | try { eventData.startDate = eventStartDate.toISOString(); } 392 | catch (e) { /* Keep as null */ } 393 | 394 | try { eventData.endDate = eventEndDate.toISOString(); } 395 | catch (e) { /* Keep as null */ } 396 | 397 | try { eventData.isAllDay = event.alldayEvent(); } 398 | catch (e) { /* Keep as false */ } 399 | 400 | try { eventData.url = event.url(); } 401 | catch (e) { /* Keep as null */ } 402 | 403 | events.push(eventData); 404 | } catch (e) { 405 | // Skip events we can't process 406 | } 407 | } 408 | } catch (e) { 409 | // Skip calendars we can't access 410 | console.log("getEvents - Error processing events: ----0----", JSON.stringify(e)); 411 | } 412 | } 413 | return events; 414 | } catch (e) { 415 | console.log("getEvents - Error processing events: ----1----", JSON.stringify(e)); 416 | return []; // Return empty array on any error 417 | } 418 | }, { 419 | limit, 420 | fromDate, 421 | toDate, 422 | maxEventsPerCalendar: CONFIG.MAX_EVENTS_PER_CALENDAR 423 | }) as CalendarEvent[]; 424 | 425 | // If no events found, create dummy events 426 | if (events.length === 0) { 427 | console.error("getEvents - No events found, creating dummy events"); 428 | return []; 429 | } 430 | 431 | return events; 432 | } catch (error) { 433 | console.error(`Error getting events: ${error instanceof Error ? error.message : String(error)}`); 434 | return []; 435 | } 436 | } 437 | 438 | /** 439 | * Create a new calendar event 440 | * @param title Title of the event 441 | * @param startDate Start date/time in ISO format 442 | * @param endDate End date/time in ISO format 443 | * @param location Optional location of the event 444 | * @param notes Optional notes for the event 445 | * @param isAllDay Optional flag to create an all-day event 446 | * @param calendarName Optional calendar name to add the event to (uses default if not specified) 447 | * @returns Result object indicating success or failure, including the created event ID 448 | */ 449 | async function createEvent( 450 | title: string, 451 | startDate: string, 452 | endDate: string, 453 | location?: string, 454 | notes?: string, 455 | isAllDay = false, 456 | calendarName?: string 457 | ): Promise<{ success: boolean; message: string; eventId?: string }> { 458 | try { 459 | if (!await checkCalendarAccess()) { 460 | return { 461 | success: false, 462 | message: "Cannot access Calendar app. Please grant access in System Settings > Privacy & Security > Automation." 463 | }; 464 | } 465 | 466 | console.error(`createEvent - Attempting to create event: "${title}"`); 467 | 468 | const result = await run((args: { 469 | title: string, 470 | startDate: string, 471 | endDate: string, 472 | location?: string, 473 | notes?: string, 474 | isAllDay: boolean, 475 | calendarName?: string 476 | }) => { 477 | try { 478 | const Calendar = Application("Calendar"); 479 | 480 | // Parse dates 481 | const startDateTime = new Date(args.startDate); 482 | const endDateTime = new Date(args.endDate); 483 | 484 | // Find the target calendar 485 | // biome-ignore lint/suspicious/noExplicitAny: 486 | let targetCalendar: any; 487 | if (args.calendarName) { 488 | // Find the specified calendar 489 | const calendars = Calendar.calendars.whose({ 490 | name: { _equals: args.calendarName } 491 | }); 492 | 493 | if (calendars.length > 0) { 494 | targetCalendar = calendars[0]; 495 | } else { 496 | return { 497 | success: false, 498 | message: `Calendar "${args.calendarName}" not found.` 499 | }; 500 | } 501 | } else { 502 | // Use default calendar 503 | // Calendar.defaultCalendar() doesn't exist - get the first calendar instead 504 | const allCalendars = Calendar.calendars(); 505 | if (allCalendars.length === 0) { 506 | return { 507 | success: false, 508 | message: "No calendars found in Calendar app." 509 | }; 510 | } 511 | targetCalendar = allCalendars[0]; 512 | } 513 | 514 | // Create the new event 515 | const newEvent = Calendar.Event({ 516 | summary: args.title, 517 | startDate: startDateTime, 518 | endDate: endDateTime, 519 | location: args.location || "", 520 | description: args.notes || "", 521 | alldayEvent: args.isAllDay 522 | }); 523 | 524 | // Add the event to the calendar 525 | targetCalendar.events.push(newEvent); 526 | 527 | return { 528 | success: true, 529 | message: `Event "${args.title}" created successfully.`, 530 | eventId: newEvent.uid() 531 | }; 532 | } catch (e) { 533 | return { 534 | success: false, 535 | message: `Error creating event: ${e instanceof Error ? e.message : String(e)}` 536 | }; 537 | } 538 | }, { 539 | title, 540 | startDate, 541 | endDate, 542 | location, 543 | notes, 544 | isAllDay, 545 | calendarName 546 | }) as { success: boolean; message: string; eventId?: string }; 547 | 548 | return result; 549 | } catch (error) { 550 | return { 551 | success: false, 552 | message: `Error creating event: ${error instanceof Error ? error.message : String(error)}` 553 | }; 554 | } 555 | } 556 | 557 | const calendar = { 558 | searchEvents, 559 | openEvent, 560 | getEvents, 561 | createEvent 562 | }; 563 | 564 | export default calendar; -------------------------------------------------------------------------------- /utils/contacts.ts: -------------------------------------------------------------------------------- 1 | import { run } from '@jxa/run'; 2 | import { runAppleScript } from 'run-applescript'; 3 | 4 | async function checkContactsAccess(): Promise { 5 | try { 6 | // Try to get the count of contacts as a simple test 7 | await runAppleScript(` 8 | tell application "Contacts" 9 | count every person 10 | end tell`); 11 | return true; 12 | } catch (error) { 13 | throw new Error("Cannot access Contacts app. Please grant access in System Preferences > Security & Privacy > Privacy > Contacts."); 14 | } 15 | } 16 | 17 | async function getAllNumbers() { 18 | try { 19 | if (!await checkContactsAccess()) { 20 | return {}; 21 | } 22 | 23 | const nums: { [key: string]: string[] } = await run(() => { 24 | const Contacts = Application('Contacts'); 25 | const people = Contacts.people(); 26 | const phoneNumbers: { [key: string]: string[] } = {}; 27 | 28 | for (const person of people) { 29 | try { 30 | const name = person.name(); 31 | const phones = person.phones().map((phone: unknown) => (phone as { value: string }).value); 32 | 33 | if (!phoneNumbers[name]) { 34 | phoneNumbers[name] = []; 35 | } 36 | phoneNumbers[name] = [...phoneNumbers[name], ...phones]; 37 | } catch (error) { 38 | // Skip contacts that can't be processed 39 | } 40 | } 41 | 42 | return phoneNumbers; 43 | }); 44 | 45 | return nums; 46 | } catch (error) { 47 | throw new Error(`Error accessing contacts: ${error instanceof Error ? error.message : String(error)}`); 48 | } 49 | } 50 | 51 | async function findNumber(name: string) { 52 | try { 53 | if (!await checkContactsAccess()) { 54 | return []; 55 | } 56 | 57 | const nums: string[] = await run((name: string) => { 58 | const Contacts = Application('Contacts'); 59 | const people = Contacts.people.whose({name: {_contains: name}}); 60 | const phones = people.length > 0 ? people[0].phones() : []; 61 | return phones.map((phone: unknown) => (phone as { value: string }).value); 62 | }, name); 63 | 64 | // If no numbers found, run getNumbers() to find the closest match 65 | if (nums.length === 0) { 66 | const allNumbers = await getAllNumbers(); 67 | const closestMatch = Object.keys(allNumbers).find(personName => 68 | personName.toLowerCase().includes(name.toLowerCase()) 69 | ); 70 | return closestMatch ? allNumbers[closestMatch] : []; 71 | } 72 | 73 | return nums; 74 | } catch (error) { 75 | throw new Error(`Error finding contact: ${error instanceof Error ? error.message : String(error)}`); 76 | } 77 | } 78 | 79 | async function findContactByPhone(phoneNumber: string): Promise { 80 | try { 81 | if (!await checkContactsAccess()) { 82 | return null; 83 | } 84 | 85 | // Normalize the phone number for comparison 86 | const searchNumber = phoneNumber.replace(/[^0-9+]/g, ''); 87 | 88 | // Get all contacts and their numbers 89 | const allContacts = await getAllNumbers(); 90 | 91 | // Look for a match 92 | for (const [name, numbers] of Object.entries(allContacts)) { 93 | const normalizedNumbers = numbers.map(num => num.replace(/[^0-9+]/g, '')); 94 | if (normalizedNumbers.some(num => 95 | num === searchNumber || 96 | num === `+${searchNumber}` || 97 | num === `+1${searchNumber}` || 98 | `+1${num}` === searchNumber 99 | )) { 100 | return name; 101 | } 102 | } 103 | 104 | return null; 105 | } catch (error) { 106 | // Return null instead of throwing to handle gracefully 107 | return null; 108 | } 109 | } 110 | 111 | export default { getAllNumbers, findNumber, findContactByPhone }; 112 | -------------------------------------------------------------------------------- /utils/mail.ts: -------------------------------------------------------------------------------- 1 | import { run } from "@jxa/run"; 2 | import { runAppleScript } from "run-applescript"; 3 | 4 | async function checkMailAccess(): Promise { 5 | try { 6 | // First check if Mail is running 7 | const isRunning = await runAppleScript(` 8 | tell application "System Events" 9 | return application process "Mail" exists 10 | end tell`); 11 | 12 | if (isRunning !== "true") { 13 | console.error("Mail app is not running, attempting to launch..."); 14 | try { 15 | await runAppleScript(` 16 | tell application "Mail" to activate 17 | delay 2`); 18 | } catch (activateError) { 19 | console.error("Error activating Mail app:", activateError); 20 | throw new Error( 21 | "Could not activate Mail app. Please start it manually.", 22 | ); 23 | } 24 | } 25 | 26 | // Try to get the count of mailboxes as a simple test 27 | try { 28 | await runAppleScript(` 29 | tell application "Mail" 30 | count every mailbox 31 | end tell`); 32 | return true; 33 | } catch (mailboxError) { 34 | console.error("Error accessing mailboxes:", mailboxError); 35 | 36 | // Try an alternative check 37 | try { 38 | const mailVersion = await runAppleScript(` 39 | tell application "Mail" 40 | return its version 41 | end tell`); 42 | console.error("Mail version:", mailVersion); 43 | return true; 44 | } catch (versionError) { 45 | console.error("Error getting Mail version:", versionError); 46 | throw new Error( 47 | "Mail app is running but cannot access mailboxes. Please check permissions and configuration.", 48 | ); 49 | } 50 | } 51 | } catch (error) { 52 | console.error("Mail access check failed:", error); 53 | throw new Error( 54 | `Cannot access Mail app. Please make sure Mail is running and properly configured. Error: ${error instanceof Error ? error.message : String(error)}`, 55 | ); 56 | } 57 | } 58 | 59 | interface EmailMessage { 60 | subject: string; 61 | sender: string; 62 | dateSent: string; 63 | content: string; 64 | isRead: boolean; 65 | mailbox: string; 66 | } 67 | 68 | async function getUnreadMails(limit = 10): Promise { 69 | try { 70 | if (!(await checkMailAccess())) { 71 | return []; 72 | } 73 | 74 | // First, try with AppleScript which might be more reliable for this case 75 | try { 76 | const script = ` 77 | tell application "Mail" 78 | set allMailboxes to every mailbox 79 | set resultList to {} 80 | 81 | repeat with m in allMailboxes 82 | try 83 | set unreadMessages to (messages of m whose read status is false) 84 | if (count of unreadMessages) > 0 then 85 | set msgLimit to ${limit} 86 | if (count of unreadMessages) < msgLimit then 87 | set msgLimit to (count of unreadMessages) 88 | end if 89 | 90 | repeat with i from 1 to msgLimit 91 | try 92 | set currentMsg to item i of unreadMessages 93 | set msgData to {subject:(subject of currentMsg), sender:(sender of currentMsg), ¬ 94 | date:(date sent of currentMsg) as string, mailbox:(name of m)} 95 | 96 | try 97 | set msgContent to content of currentMsg 98 | if length of msgContent > 500 then 99 | set msgContent to (text 1 thru 500 of msgContent) & "..." 100 | end if 101 | set msgData to msgData & {content:msgContent} 102 | on error 103 | set msgData to msgData & {content:"[Content not available]"} 104 | end try 105 | 106 | set end of resultList to msgData 107 | end try 108 | end repeat 109 | 110 | if (count of resultList) ≥ ${limit} then exit repeat 111 | end if 112 | end try 113 | end repeat 114 | 115 | return resultList 116 | end tell`; 117 | 118 | const asResult = await runAppleScript(script); 119 | 120 | // If we got results, parse them 121 | if (asResult && asResult.toString().trim().length > 0) { 122 | try { 123 | // Try to parse as JSON if the result looks like JSON 124 | if (asResult.startsWith("{") || asResult.startsWith("[")) { 125 | const parsedResults = JSON.parse(asResult); 126 | if (Array.isArray(parsedResults) && parsedResults.length > 0) { 127 | return parsedResults.map((msg) => ({ 128 | subject: msg.subject || "No subject", 129 | sender: msg.sender || "Unknown sender", 130 | dateSent: msg.date || new Date().toString(), 131 | content: msg.content || "[Content not available]", 132 | isRead: false, // These are all unread by definition 133 | mailbox: msg.mailbox || "Unknown mailbox", 134 | })); 135 | } 136 | } 137 | 138 | // If it's not in JSON format, try to parse the plist/record format 139 | const parsedEmails: EmailMessage[] = []; 140 | 141 | // Very simple parsing for the record format that AppleScript might return 142 | // This is a best-effort attempt and might not be perfect 143 | const matches = asResult.match(/\{([^}]+)\}/g); 144 | if (matches && matches.length > 0) { 145 | for (const match of matches) { 146 | try { 147 | // Parse key-value pairs 148 | const props = match.substring(1, match.length - 1).split(","); 149 | const emailData: { [key: string]: string } = {}; 150 | 151 | for (const prop of props) { 152 | const parts = prop.split(":"); 153 | if (parts.length >= 2) { 154 | const key = parts[0].trim(); 155 | const value = parts.slice(1).join(":").trim(); 156 | emailData[key] = value; 157 | } 158 | } 159 | 160 | if (emailData.subject || emailData.sender) { 161 | parsedEmails.push({ 162 | subject: emailData.subject || "No subject", 163 | sender: emailData.sender || "Unknown sender", 164 | dateSent: emailData.date || new Date().toString(), 165 | content: emailData.content || "[Content not available]", 166 | isRead: false, 167 | mailbox: emailData.mailbox || "Unknown mailbox", 168 | }); 169 | } 170 | } catch (parseError) { 171 | console.error("Error parsing email match:", parseError); 172 | } 173 | } 174 | } 175 | 176 | if (parsedEmails.length > 0) { 177 | return parsedEmails; 178 | } 179 | } catch (parseError) { 180 | console.error("Error parsing AppleScript result:", parseError); 181 | // If parsing failed, continue to the JXA approach 182 | } 183 | } 184 | 185 | // If the raw result contains useful info but parsing failed 186 | if ( 187 | asResult.includes("subject") && 188 | asResult.includes("sender") 189 | ) { 190 | console.error("Returning raw AppleScript result for debugging"); 191 | return [ 192 | { 193 | subject: "Raw AppleScript Output", 194 | sender: "Mail System", 195 | dateSent: new Date().toString(), 196 | content: `Could not parse Mail data properly. Raw output: ${asResult}`, 197 | isRead: false, 198 | mailbox: "Debug", 199 | }, 200 | ]; 201 | } 202 | } catch (asError) { 203 | // Continue to JXA approach as fallback 204 | } 205 | 206 | console.error("Trying JXA approach for unread emails..."); 207 | // Check Mail accounts as a different approach 208 | const accounts = await runAppleScript(` 209 | tell application "Mail" 210 | set accts to {} 211 | repeat with a in accounts 212 | set end of accts to name of a 213 | end repeat 214 | return accts 215 | end tell`); 216 | console.error("Available accounts:", accounts); 217 | 218 | // Try using direct AppleScript to check for unread messages across all accounts 219 | const unreadInfo = await runAppleScript(` 220 | tell application "Mail" 221 | set unreadInfo to {} 222 | repeat with m in every mailbox 223 | try 224 | set unreadCount to count (messages of m whose read status is false) 225 | if unreadCount > 0 then 226 | set end of unreadInfo to {name of m, unreadCount} 227 | end if 228 | end try 229 | end repeat 230 | return unreadInfo 231 | end tell`); 232 | console.error("Mailboxes with unread messages:", unreadInfo); 233 | 234 | // Fallback to JXA approach 235 | const unreadMails: EmailMessage[] = await run((limit: number) => { 236 | const Mail = Application("Mail"); 237 | const results = []; 238 | 239 | try { 240 | const accounts = Mail.accounts(); 241 | 242 | for (const account of accounts) { 243 | try { 244 | const accountName = account.name(); 245 | try { 246 | const accountMailboxes = account.mailboxes(); 247 | 248 | for (const mailbox of accountMailboxes) { 249 | try { 250 | const boxName = mailbox.name(); 251 | 252 | // biome-ignore lint/suspicious/noImplicitAnyLet: 253 | let unreadMessages; 254 | try { 255 | unreadMessages = mailbox.messages.whose({ 256 | readStatus: false, 257 | })(); 258 | 259 | const count = Math.min( 260 | unreadMessages.length, 261 | limit - results.length, 262 | ); 263 | for (let i = 0; i < count; i++) { 264 | try { 265 | const msg = unreadMessages[i]; 266 | results.push({ 267 | subject: msg.subject(), 268 | sender: msg.sender(), 269 | dateSent: msg.dateSent().toString(), 270 | content: msg.content() 271 | ? msg.content().substring(0, 500) 272 | : "[No content]", 273 | isRead: false, 274 | mailbox: `${accountName} - ${boxName}`, 275 | }); 276 | } catch (msgError) {} 277 | } 278 | } catch (unreadError) {} 279 | } catch (boxError) {} 280 | 281 | if (results.length >= limit) { 282 | break; 283 | } 284 | } 285 | } catch (mbError) {} 286 | 287 | if (results.length >= limit) { 288 | break; 289 | } 290 | } catch (accError) {} 291 | } 292 | } catch (error) {} 293 | 294 | return results; 295 | }, limit); 296 | 297 | return unreadMails; 298 | } catch (error) { 299 | console.error("Error in getUnreadMails:", error); 300 | throw new Error( 301 | `Error accessing mail: ${error instanceof Error ? error.message : String(error)}`, 302 | ); 303 | } 304 | } 305 | 306 | async function searchMails( 307 | searchTerm: string, 308 | limit = 10, 309 | ): Promise { 310 | try { 311 | if (!(await checkMailAccess())) { 312 | return []; 313 | } 314 | 315 | // Ensure Mail app is running 316 | await runAppleScript(` 317 | if application "Mail" is not running then 318 | tell application "Mail" to activate 319 | delay 2 320 | end if`); 321 | 322 | // First try the AppleScript approach which might be more reliable 323 | try { 324 | const script = ` 325 | tell application "Mail" 326 | set searchString to "${searchTerm.replace(/"/g, '\\"')}" 327 | set foundMsgs to {} 328 | set allBoxes to every mailbox 329 | 330 | repeat with currentBox in allBoxes 331 | try 332 | set boxMsgs to (messages of currentBox whose (subject contains searchString) or (content contains searchString)) 333 | set foundMsgs to foundMsgs & boxMsgs 334 | if (count of foundMsgs) ≥ ${limit} then exit repeat 335 | end try 336 | end repeat 337 | 338 | set resultList to {} 339 | set msgCount to (count of foundMsgs) 340 | if msgCount > ${limit} then set msgCount to ${limit} 341 | 342 | repeat with i from 1 to msgCount 343 | try 344 | set currentMsg to item i of foundMsgs 345 | set msgInfo to {subject:subject of currentMsg, sender:sender of currentMsg, ¬ 346 | date:(date sent of currentMsg) as string, isRead:read status of currentMsg, ¬ 347 | boxName:name of (mailbox of currentMsg)} 348 | set end of resultList to msgInfo 349 | end try 350 | end repeat 351 | 352 | return resultList 353 | end tell`; 354 | 355 | const asResult = await runAppleScript(script); 356 | 357 | // If we got results, parse them 358 | if (asResult && asResult.length > 0) { 359 | try { 360 | const parsedResults = JSON.parse(asResult); 361 | if (Array.isArray(parsedResults) && parsedResults.length > 0) { 362 | return parsedResults.map((msg) => ({ 363 | subject: msg.subject || "No subject", 364 | sender: msg.sender || "Unknown sender", 365 | dateSent: msg.date || new Date().toString(), 366 | content: "[Content not available through AppleScript method]", 367 | isRead: msg.isRead || false, 368 | mailbox: msg.boxName || "Unknown mailbox", 369 | })); 370 | } 371 | } catch (parseError) { 372 | console.error("Error parsing AppleScript result:", parseError); 373 | // Continue to JXA approach if parsing fails 374 | } 375 | } 376 | } catch (asError) { 377 | // Continue to JXA approach 378 | } 379 | 380 | // JXA approach as fallback 381 | const searchResults: EmailMessage[] = await run( 382 | (searchTerm: string, limit: number) => { 383 | const Mail = Application("Mail"); 384 | const results = []; 385 | 386 | try { 387 | const mailboxes = Mail.mailboxes(); 388 | 389 | for (const mailbox of mailboxes) { 390 | try { 391 | // biome-ignore lint/suspicious/noImplicitAnyLet: 392 | let messages; 393 | try { 394 | messages = mailbox.messages.whose({ 395 | _or: [ 396 | { subject: { _contains: searchTerm } }, 397 | { content: { _contains: searchTerm } }, 398 | ], 399 | })(); 400 | 401 | const count = Math.min(messages.length, limit); 402 | 403 | for (let i = 0; i < count; i++) { 404 | try { 405 | const msg = messages[i]; 406 | results.push({ 407 | subject: msg.subject(), 408 | sender: msg.sender(), 409 | dateSent: msg.dateSent().toString(), 410 | content: msg.content() 411 | ? msg.content().substring(0, 500) 412 | : "[No content]", // Limit content length 413 | isRead: msg.readStatus(), 414 | mailbox: mailbox.name(), 415 | }); 416 | } catch (msgError) {} 417 | } 418 | 419 | if (results.length >= limit) { 420 | break; 421 | } 422 | } catch (queryError) { 423 | } 424 | } catch (boxError) {} 425 | } 426 | } catch (mbError) {} 427 | 428 | return results.slice(0, limit); 429 | }, 430 | searchTerm, 431 | limit, 432 | ); 433 | 434 | return searchResults; 435 | } catch (error) { 436 | console.error("Error in searchMails:", error); 437 | throw new Error( 438 | `Error searching mail: ${error instanceof Error ? error.message : String(error)}`, 439 | ); 440 | } 441 | } 442 | 443 | async function sendMail( 444 | to: string, 445 | subject: string, 446 | body: string, 447 | cc?: string, 448 | bcc?: string, 449 | ): Promise { 450 | try { 451 | if (!(await checkMailAccess())) { 452 | throw new Error("Could not access Mail app"); 453 | } 454 | 455 | // Ensure Mail app is running 456 | await runAppleScript(` 457 | if application "Mail" is not running then 458 | tell application "Mail" to activate 459 | delay 2 460 | end if`); 461 | 462 | // Escape special characters in strings for AppleScript 463 | const escapedTo = to.replace(/"/g, '\\"'); 464 | const escapedSubject = subject.replace(/"/g, '\\"'); 465 | const escapedBody = body.replace(/"/g, '\\"'); 466 | const escapedCc = cc ? cc.replace(/"/g, '\\"') : ""; 467 | const escapedBcc = bcc ? bcc.replace(/"/g, '\\"') : ""; 468 | 469 | let script = ` 470 | tell application "Mail" 471 | set newMessage to make new outgoing message with properties {subject:"${escapedSubject}", content:"${escapedBody}", visible:true} 472 | tell newMessage 473 | make new to recipient with properties {address:"${escapedTo}"} 474 | `; 475 | 476 | if (cc) { 477 | script += ` make new cc recipient with properties {address:"${escapedCc}"}\n`; 478 | } 479 | 480 | if (bcc) { 481 | script += ` make new bcc recipient with properties {address:"${escapedBcc}"}\n`; 482 | } 483 | 484 | script += ` end tell 485 | send newMessage 486 | return "success" 487 | end tell 488 | `; 489 | 490 | try { 491 | const result = await runAppleScript(script); 492 | if (result === "success") { 493 | return `Email sent to ${to} with subject "${subject}"`; 494 | // biome-ignore lint/style/noUselessElse: 495 | } else { 496 | } 497 | } catch (asError) { 498 | console.error("Error in AppleScript send:", asError); 499 | 500 | const jxaResult: string = await run( 501 | (to, subject, body, cc, bcc) => { 502 | try { 503 | const Mail = Application("Mail"); 504 | 505 | const msg = Mail.OutgoingMessage().make(); 506 | msg.subject = subject; 507 | msg.content = body; 508 | msg.visible = true; 509 | 510 | // Add recipients 511 | const toRecipient = Mail.ToRecipient().make(); 512 | toRecipient.address = to; 513 | msg.toRecipients.push(toRecipient); 514 | 515 | if (cc) { 516 | const ccRecipient = Mail.CcRecipient().make(); 517 | ccRecipient.address = cc; 518 | msg.ccRecipients.push(ccRecipient); 519 | } 520 | 521 | if (bcc) { 522 | const bccRecipient = Mail.BccRecipient().make(); 523 | bccRecipient.address = bcc; 524 | msg.bccRecipients.push(bccRecipient); 525 | } 526 | 527 | msg.send(); 528 | return "JXA send completed"; 529 | } catch (error) { 530 | return `JXA error: ${error}`; 531 | } 532 | }, 533 | to, 534 | subject, 535 | body, 536 | cc, 537 | bcc, 538 | ); 539 | 540 | if (jxaResult.startsWith("JXA error:")) { 541 | throw new Error(jxaResult); 542 | } 543 | 544 | return `Email sent to ${to} with subject "${subject}"`; 545 | } 546 | } catch (error) { 547 | console.error("Error in sendMail:", error); 548 | throw new Error( 549 | `Error sending mail: ${error instanceof Error ? error.message : String(error)}`, 550 | ); 551 | } 552 | } 553 | 554 | async function getMailboxes(): Promise { 555 | try { 556 | if (!(await checkMailAccess())) { 557 | return []; 558 | } 559 | 560 | // Ensure Mail app is running 561 | await runAppleScript(` 562 | if application "Mail" is not running then 563 | tell application "Mail" to activate 564 | delay 2 565 | end if`); 566 | 567 | const mailboxes: string[] = await run(() => { 568 | const Mail = Application("Mail"); 569 | 570 | try { 571 | const mailboxes = Mail.mailboxes(); 572 | 573 | if (!mailboxes || mailboxes.length === 0) { 574 | try { 575 | const result = Mail.execute({ 576 | withObjectModel: "Mail Suite", 577 | withCommand: "get name of every mailbox", 578 | }); 579 | 580 | if (result && result.length > 0) { 581 | return result; 582 | } 583 | } catch (execError) {} 584 | 585 | return []; 586 | } 587 | 588 | return mailboxes.map((box: unknown) => { 589 | try { 590 | return (box as { name: () => string }).name(); 591 | } catch (nameError) { 592 | return "Unknown mailbox"; 593 | } 594 | }); 595 | } catch (error) { 596 | return []; 597 | } 598 | }); 599 | 600 | return mailboxes; 601 | } catch (error) { 602 | console.error("Error in getMailboxes:", error); 603 | throw new Error( 604 | `Error getting mailboxes: ${error instanceof Error ? error.message : String(error)}`, 605 | ); 606 | } 607 | } 608 | 609 | async function getAccounts(): Promise { 610 | try { 611 | if (!(await checkMailAccess())) { 612 | return []; 613 | } 614 | 615 | const accounts = await runAppleScript(` 616 | tell application "Mail" 617 | set acctNames to {} 618 | repeat with a in accounts 619 | set end of acctNames to name of a 620 | end repeat 621 | return acctNames 622 | end tell`); 623 | 624 | return accounts ? accounts.split(", ") : []; 625 | } catch (error) { 626 | console.error("Error getting accounts:", error); 627 | throw new Error( 628 | `Error getting mail accounts: ${error instanceof Error ? error.message : String(error)}`, 629 | ); 630 | } 631 | } 632 | 633 | async function getMailboxesForAccount(accountName: string): Promise { 634 | try { 635 | if (!(await checkMailAccess())) { 636 | return []; 637 | } 638 | 639 | const mailboxes = await runAppleScript(` 640 | tell application "Mail" 641 | set boxNames to {} 642 | try 643 | set targetAccount to first account whose name is "${accountName.replace(/"/g, '\\"')}" 644 | set acctMailboxes to every mailbox of targetAccount 645 | repeat with mb in acctMailboxes 646 | set end of boxNames to name of mb 647 | end repeat 648 | on error errMsg 649 | return "Error: " & errMsg 650 | end try 651 | return boxNames 652 | end tell`); 653 | 654 | if (mailboxes?.startsWith("Error:")) { 655 | console.error(mailboxes); 656 | return []; 657 | } 658 | 659 | return mailboxes ? mailboxes.split(", ") : []; 660 | } catch (error) { 661 | console.error("Error getting mailboxes for account:", error); 662 | throw new Error( 663 | `Error getting mailboxes for account ${accountName}: ${error instanceof Error ? error.message : String(error)}`, 664 | ); 665 | } 666 | } 667 | 668 | export default { 669 | getUnreadMails, 670 | searchMails, 671 | sendMail, 672 | getMailboxes, 673 | getAccounts, 674 | getMailboxesForAccount, 675 | }; 676 | -------------------------------------------------------------------------------- /utils/maps.ts: -------------------------------------------------------------------------------- 1 | import { run } from '@jxa/run'; 2 | 3 | // Type definitions 4 | interface MapLocation { 5 | id: string; 6 | name: string; 7 | address: string; 8 | latitude: number | null; 9 | longitude: number | null; 10 | category: string | null; 11 | isFavorite: boolean; 12 | } 13 | 14 | interface Guide { 15 | id: string; 16 | name: string; 17 | itemCount: number; 18 | } 19 | 20 | interface SearchResult { 21 | success: boolean; 22 | locations: MapLocation[]; 23 | message?: string; 24 | } 25 | 26 | interface SaveResult { 27 | success: boolean; 28 | message: string; 29 | location?: MapLocation; 30 | } 31 | 32 | interface DirectionResult { 33 | success: boolean; 34 | message: string; 35 | route?: { 36 | distance: string; 37 | duration: string; 38 | startAddress: string; 39 | endAddress: string; 40 | }; 41 | } 42 | 43 | interface GuideResult { 44 | success: boolean; 45 | message: string; 46 | guides?: Guide[]; 47 | } 48 | 49 | interface AddToGuideResult { 50 | success: boolean; 51 | message: string; 52 | guideName?: string; 53 | locationName?: string; 54 | } 55 | 56 | /** 57 | * Check if Maps app is accessible 58 | */ 59 | async function checkMapsAccess(): Promise { 60 | try { 61 | const result = await run(() => { 62 | try { 63 | const Maps = Application("Maps"); 64 | Maps.name(); // Just try to get the name to test access 65 | return true; 66 | } catch (e) { 67 | throw new Error("Cannot access Maps app"); 68 | } 69 | }) as boolean; 70 | 71 | return result; 72 | } catch (error) { 73 | console.error(`Cannot access Maps app: ${error instanceof Error ? error.message : String(error)}`); 74 | return false; 75 | } 76 | } 77 | 78 | /** 79 | * Search for locations on the map 80 | * @param query Search query for locations 81 | * @param limit Maximum number of results to return 82 | */ 83 | async function searchLocations(query: string, limit: number = 5): Promise { 84 | try { 85 | if (!await checkMapsAccess()) { 86 | return { 87 | success: false, 88 | locations: [], 89 | message: "Cannot access Maps app. Please grant access in System Settings > Privacy & Security > Automation." 90 | }; 91 | } 92 | 93 | console.error(`searchLocations - Searching for: "${query}"`); 94 | 95 | // First try to use the Maps search function 96 | const locations = await run((args: { query: string, limit: number }) => { 97 | try { 98 | const Maps = Application("Maps"); 99 | 100 | // Launch Maps and search (this is needed for search to work properly) 101 | Maps.activate(); 102 | 103 | // Execute search using the URL scheme which is more reliable 104 | Maps.activate(); 105 | const encodedQuery = encodeURIComponent(args.query); 106 | Maps.openLocation(`maps://?q=${encodedQuery}`); 107 | 108 | // For backward compatibility also try the standard search method 109 | try { 110 | Maps.search(args.query); 111 | } catch (e) { 112 | // Ignore error if search is not supported 113 | } 114 | 115 | // Wait a bit for search results to populate 116 | delay(2); // 2 seconds 117 | 118 | // Try to get search results, if supported by the version of Maps 119 | const locations: MapLocation[] = []; 120 | 121 | try { 122 | // Different versions of Maps have different ways to access results 123 | // We'll need to use a different method for each version 124 | 125 | // Approach 1: Try to get locations directly 126 | // (this works on some versions of macOS) 127 | const selectedLocation = Maps.selectedLocation(); 128 | if (selectedLocation) { 129 | // If we have a selected location, use it 130 | const location: MapLocation = { 131 | id: `loc-${Date.now()}-${Math.random()}`, 132 | name: selectedLocation.name() || args.query, 133 | address: selectedLocation.formattedAddress() || "Address not available", 134 | latitude: selectedLocation.latitude(), 135 | longitude: selectedLocation.longitude(), 136 | category: selectedLocation.category ? selectedLocation.category() : null, 137 | isFavorite: false 138 | }; 139 | locations.push(location); 140 | } else { 141 | // If no selected location, use the search field value as name 142 | // and try to get coordinates by doing a UI script 143 | 144 | // Use the user entered search term for the result 145 | const location: MapLocation = { 146 | id: `loc-${Date.now()}-${Math.random()}`, 147 | name: args.query, 148 | address: "Search results - address details not available", 149 | latitude: null, 150 | longitude: null, 151 | category: null, 152 | isFavorite: false 153 | }; 154 | locations.push(location); 155 | } 156 | } catch (e) { 157 | // If the above didn't work, at least return something based on the query 158 | const location: MapLocation = { 159 | id: `loc-${Date.now()}-${Math.random()}`, 160 | name: args.query, 161 | address: "Search result - address details not available", 162 | latitude: null, 163 | longitude: null, 164 | category: null, 165 | isFavorite: false 166 | }; 167 | locations.push(location); 168 | } 169 | 170 | return locations.slice(0, args.limit); 171 | } catch (e) { 172 | return []; // Return empty array on any error 173 | } 174 | }, { query, limit }) as MapLocation[]; 175 | 176 | return { 177 | success: locations.length > 0, 178 | locations, 179 | message: locations.length > 0 ? 180 | `Found ${locations.length} location(s) for "${query}"` : 181 | `No locations found for "${query}"` 182 | }; 183 | } catch (error) { 184 | return { 185 | success: false, 186 | locations: [], 187 | message: `Error searching locations: ${error instanceof Error ? error.message : String(error)}` 188 | }; 189 | } 190 | } 191 | 192 | /** 193 | * Save a location to favorites 194 | * @param name Name of the location 195 | * @param address Address to save (as a string) 196 | */ 197 | async function saveLocation(name: string, address: string): Promise { 198 | try { 199 | if (!await checkMapsAccess()) { 200 | return { 201 | success: false, 202 | message: "Cannot access Maps app. Please grant access in System Settings > Privacy & Security > Automation." 203 | }; 204 | } 205 | 206 | console.error(`saveLocation - Saving location: "${name}" at address "${address}"`); 207 | 208 | const result = await run((args: { name: string, address: string }) => { 209 | try { 210 | const Maps = Application("Maps"); 211 | Maps.activate(); 212 | 213 | // First search for the location to get its details 214 | Maps.search(args.address); 215 | 216 | // Wait for search to complete 217 | delay(2); 218 | 219 | try { 220 | // Try to add to favorites 221 | // Different Maps versions have different methods 222 | 223 | // Try to get the current location 224 | const location = Maps.selectedLocation(); 225 | 226 | if (location) { 227 | // Now try to add to favorites 228 | // Approach 1: Direct API if available 229 | try { 230 | Maps.addToFavorites(location, {withProperties: {name: args.name}}); 231 | return { 232 | success: true, 233 | message: `Added "${args.name}" to favorites`, 234 | location: { 235 | id: `loc-${Date.now()}`, 236 | name: args.name, 237 | address: location.formattedAddress() || args.address, 238 | latitude: location.latitude(), 239 | longitude: location.longitude(), 240 | category: null, 241 | isFavorite: true 242 | } 243 | }; 244 | } catch (e) { 245 | // If direct API fails, use UI scripting as fallback 246 | // UI scripting would require more complex steps that vary by macOS version 247 | return { 248 | success: false, 249 | message: `Location found but unable to automatically add to favorites. Please manually save "${args.name}" from the Maps app.` 250 | }; 251 | } 252 | } else { 253 | return { 254 | success: false, 255 | message: `Could not find location for "${args.address}"` 256 | }; 257 | } 258 | } catch (e) { 259 | return { 260 | success: false, 261 | message: `Error adding to favorites: ${e}` 262 | }; 263 | } 264 | } catch (e) { 265 | return { 266 | success: false, 267 | message: `Error in Maps: ${e}` 268 | }; 269 | } 270 | }, { name, address }) as SaveResult; 271 | 272 | return result; 273 | } catch (error) { 274 | return { 275 | success: false, 276 | message: `Error saving location: ${error instanceof Error ? error.message : String(error)}` 277 | }; 278 | } 279 | } 280 | 281 | /** 282 | * Get directions between two locations 283 | * @param fromAddress Starting address 284 | * @param toAddress Destination address 285 | * @param transportType Type of transport to use (default is driving) 286 | */ 287 | async function getDirections( 288 | fromAddress: string, 289 | toAddress: string, 290 | transportType: 'driving' | 'walking' | 'transit' = 'driving' 291 | ): Promise { 292 | try { 293 | if (!await checkMapsAccess()) { 294 | return { 295 | success: false, 296 | message: "Cannot access Maps app. Please grant access in System Settings > Privacy & Security > Automation." 297 | }; 298 | } 299 | 300 | console.error(`getDirections - Getting directions from "${fromAddress}" to "${toAddress}"`); 301 | 302 | const result = await run((args: { 303 | fromAddress: string, 304 | toAddress: string, 305 | transportType: string 306 | }) => { 307 | try { 308 | const Maps = Application("Maps"); 309 | Maps.activate(); 310 | 311 | // Ask for directions 312 | Maps.getDirections({ 313 | from: args.fromAddress, 314 | to: args.toAddress, 315 | by: args.transportType 316 | }); 317 | 318 | // Wait for directions to load 319 | delay(2); 320 | 321 | // There's no direct API to get the route details 322 | // We'll return basic success and let the Maps UI show the route 323 | return { 324 | success: true, 325 | message: `Displaying directions from "${args.fromAddress}" to "${args.toAddress}" by ${args.transportType}`, 326 | route: { 327 | distance: "See Maps app for details", 328 | duration: "See Maps app for details", 329 | startAddress: args.fromAddress, 330 | endAddress: args.toAddress 331 | } 332 | }; 333 | } catch (e) { 334 | return { 335 | success: false, 336 | message: `Error getting directions: ${e}` 337 | }; 338 | } 339 | }, { fromAddress, toAddress, transportType }) as DirectionResult; 340 | 341 | return result; 342 | } catch (error) { 343 | return { 344 | success: false, 345 | message: `Error getting directions: ${error instanceof Error ? error.message : String(error)}` 346 | }; 347 | } 348 | } 349 | 350 | /** 351 | * Create a pin at a specified location 352 | * @param name Name of the pin 353 | * @param address Location address 354 | */ 355 | async function dropPin(name: string, address: string): Promise { 356 | try { 357 | if (!await checkMapsAccess()) { 358 | return { 359 | success: false, 360 | message: "Cannot access Maps app. Please grant access in System Settings > Privacy & Security > Automation." 361 | }; 362 | } 363 | 364 | console.error(`dropPin - Creating pin at: "${address}" with name "${name}"`); 365 | 366 | const result = await run((args: { name: string, address: string }) => { 367 | try { 368 | const Maps = Application("Maps"); 369 | Maps.activate(); 370 | 371 | // First search for the location to get its details 372 | Maps.search(args.address); 373 | 374 | // Wait for search to complete 375 | delay(2); 376 | 377 | // Dropping pins programmatically is challenging in newer Maps versions 378 | // Most reliable way is to search and then the user can manually drop a pin 379 | return { 380 | success: true, 381 | message: `Showing "${args.address}" in Maps. You can now manually drop a pin by right-clicking and selecting "Drop Pin".` 382 | }; 383 | } catch (e) { 384 | return { 385 | success: false, 386 | message: `Error dropping pin: ${e}` 387 | }; 388 | } 389 | }, { name, address }) as SaveResult; 390 | 391 | return result; 392 | } catch (error) { 393 | return { 394 | success: false, 395 | message: `Error dropping pin: ${error instanceof Error ? error.message : String(error)}` 396 | }; 397 | } 398 | } 399 | 400 | /** 401 | * List all guides in Apple Maps 402 | * @returns Promise resolving to a list of guides 403 | */ 404 | async function listGuides(): Promise { 405 | try { 406 | if (!await checkMapsAccess()) { 407 | return { 408 | success: false, 409 | message: "Cannot access Maps app. Please grant access in System Settings > Privacy & Security > Automation." 410 | }; 411 | } 412 | 413 | console.error("listGuides - Getting list of guides from Maps"); 414 | 415 | // Try to list guides using AppleScript UI automation 416 | // Note: Maps doesn't have a direct API for this, so we're using a URL scheme approach 417 | const result = await run(() => { 418 | try { 419 | const app = Application.currentApplication(); 420 | app.includeStandardAdditions = true; 421 | 422 | // Open Maps 423 | const Maps = Application("Maps"); 424 | Maps.activate(); 425 | 426 | // Open the guides view using URL scheme 427 | app.openLocation("maps://?show=guides"); 428 | 429 | // Without direct scripting access, we can't get the actual list of guides 430 | // But we can at least open the guides view for the user 431 | 432 | return { 433 | success: true, 434 | message: "Opened guides view in Maps", 435 | guides: [] 436 | }; 437 | } catch (e) { 438 | return { 439 | success: false, 440 | message: `Error accessing guides: ${e}` 441 | }; 442 | } 443 | }) as GuideResult; 444 | 445 | return result; 446 | } catch (error) { 447 | return { 448 | success: false, 449 | message: `Error listing guides: ${error instanceof Error ? error.message : String(error)}` 450 | }; 451 | } 452 | } 453 | 454 | /** 455 | * Add a location to a specific guide 456 | * @param locationAddress The address of the location to add 457 | * @param guideName The name of the guide to add to 458 | * @returns Promise resolving to result of the operation 459 | */ 460 | async function addToGuide(locationAddress: string, guideName: string): Promise { 461 | try { 462 | if (!await checkMapsAccess()) { 463 | return { 464 | success: false, 465 | message: "Cannot access Maps app. Please grant access in System Settings > Privacy & Security > Automation." 466 | }; 467 | } 468 | 469 | console.error(`addToGuide - Adding location "${locationAddress}" to guide "${guideName}"`); 470 | 471 | // Since Maps doesn't provide a direct API for guide management, 472 | // we'll use a combination of search and manual instructions 473 | const result = await run((args: { locationAddress: string, guideName: string }) => { 474 | try { 475 | const app = Application.currentApplication(); 476 | app.includeStandardAdditions = true; 477 | 478 | // Open Maps 479 | const Maps = Application("Maps"); 480 | Maps.activate(); 481 | 482 | // Search for the location 483 | const encodedAddress = encodeURIComponent(args.locationAddress); 484 | app.openLocation(`maps://?q=${encodedAddress}`); 485 | 486 | // We can't directly add to a guide through AppleScript, 487 | // but we can provide instructions for the user 488 | 489 | return { 490 | success: true, 491 | message: `Showing "${args.locationAddress}" in Maps. Add to "${args.guideName}" guide by clicking location pin, "..." button, then "Add to Guide".`, 492 | guideName: args.guideName, 493 | locationName: args.locationAddress 494 | }; 495 | } catch (e) { 496 | return { 497 | success: false, 498 | message: `Error adding to guide: ${e}` 499 | }; 500 | } 501 | }, { locationAddress, guideName }) as AddToGuideResult; 502 | 503 | return result; 504 | } catch (error) { 505 | return { 506 | success: false, 507 | message: `Error adding to guide: ${error instanceof Error ? error.message : String(error)}` 508 | }; 509 | } 510 | } 511 | 512 | /** 513 | * Create a new guide with the given name 514 | * @param guideName The name for the new guide 515 | * @returns Promise resolving to result of the operation 516 | */ 517 | async function createGuide(guideName: string): Promise { 518 | try { 519 | if (!await checkMapsAccess()) { 520 | return { 521 | success: false, 522 | message: "Cannot access Maps app. Please grant access in System Settings > Privacy & Security > Automation." 523 | }; 524 | } 525 | 526 | console.error(`createGuide - Creating new guide "${guideName}"`); 527 | 528 | // Since Maps doesn't provide a direct API for guide creation, 529 | // we'll guide the user through the process 530 | const result = await run((guideName: string) => { 531 | try { 532 | const app = Application.currentApplication(); 533 | app.includeStandardAdditions = true; 534 | 535 | // Open Maps 536 | const Maps = Application("Maps"); 537 | Maps.activate(); 538 | 539 | // Open the guides view using URL scheme 540 | app.openLocation("maps://?show=guides"); 541 | 542 | // We can't directly create a guide through AppleScript, 543 | // but we can provide instructions for the user 544 | 545 | return { 546 | success: true, 547 | message: `Opened guides view to create new guide "${guideName}". Click "+" button and select "New Guide".`, 548 | guideName: guideName 549 | }; 550 | } catch (e) { 551 | return { 552 | success: false, 553 | message: `Error creating guide: ${e}` 554 | }; 555 | } 556 | }, guideName) as AddToGuideResult; 557 | 558 | return result; 559 | } catch (error) { 560 | return { 561 | success: false, 562 | message: `Error creating guide: ${error instanceof Error ? error.message : String(error)}` 563 | }; 564 | } 565 | } 566 | 567 | const maps = { 568 | searchLocations, 569 | saveLocation, 570 | getDirections, 571 | dropPin, 572 | listGuides, 573 | addToGuide, 574 | createGuide 575 | }; 576 | 577 | export default maps; -------------------------------------------------------------------------------- /utils/message.ts: -------------------------------------------------------------------------------- 1 | import {runAppleScript} from 'run-applescript'; 2 | import { promisify } from 'node:util'; 3 | import { exec } from 'node:child_process'; 4 | import { access } from 'node:fs/promises'; 5 | 6 | const execAsync = promisify(exec); 7 | 8 | // Retry configuration 9 | const MAX_RETRIES = 3; 10 | const RETRY_DELAY = 1000; // 1 second 11 | 12 | async function sleep(ms: number) { 13 | return new Promise(resolve => setTimeout(resolve, ms)); 14 | } 15 | 16 | async function retryOperation(operation: () => Promise, retries = MAX_RETRIES, delay = RETRY_DELAY): Promise { 17 | try { 18 | return await operation(); 19 | } catch (error) { 20 | if (retries > 0) { 21 | console.error(`Operation failed, retrying... (${retries} attempts remaining)`); 22 | await sleep(delay); 23 | return retryOperation(operation, retries - 1, delay); 24 | } 25 | throw error; 26 | } 27 | } 28 | 29 | function normalizePhoneNumber(phone: string): string[] { 30 | // Remove all non-numeric characters except + 31 | const cleaned = phone.replace(/[^0-9+]/g, ''); 32 | 33 | // If it's already in the correct format (+1XXXXXXXXXX), return just that 34 | if (/^\+1\d{10}$/.test(cleaned)) { 35 | return [cleaned]; 36 | } 37 | 38 | // If it starts with 1 and has 11 digits total 39 | if (/^1\d{10}$/.test(cleaned)) { 40 | return [`+${cleaned}`]; 41 | } 42 | 43 | // If it's 10 digits 44 | if (/^\d{10}$/.test(cleaned)) { 45 | return [`+1${cleaned}`]; 46 | } 47 | 48 | // If none of the above match, try multiple formats 49 | const formats = new Set(); 50 | 51 | if (cleaned.startsWith('+1')) { 52 | formats.add(cleaned); 53 | } else if (cleaned.startsWith('1')) { 54 | formats.add(`+${cleaned}`); 55 | } else { 56 | formats.add(`+1${cleaned}`); 57 | } 58 | 59 | return Array.from(formats); 60 | } 61 | 62 | async function sendMessage(phoneNumber: string, message: string) { 63 | const escapedMessage = message.replace(/"/g, '\\"'); 64 | const result = await runAppleScript(` 65 | tell application "Messages" 66 | set targetService to 1st service whose service type = iMessage 67 | set targetBuddy to buddy "${phoneNumber}" 68 | send "${escapedMessage}" to targetBuddy 69 | end tell`); 70 | return result; 71 | } 72 | 73 | interface Message { 74 | content: string; 75 | date: string; 76 | sender: string; 77 | is_from_me: boolean; 78 | attachments?: string[]; 79 | url?: string; 80 | } 81 | 82 | async function checkMessagesDBAccess(): Promise { 83 | try { 84 | const dbPath = `${process.env.HOME}/Library/Messages/chat.db`; 85 | await access(dbPath); 86 | 87 | // Additional check - try to query the database 88 | await execAsync(`sqlite3 "${dbPath}" "SELECT 1;"`); 89 | 90 | return true; 91 | } catch (error) { 92 | console.error(` 93 | Error: Cannot access Messages database. 94 | To fix this, please grant Full Disk Access to Terminal/iTerm2: 95 | 1. Open System Preferences 96 | 2. Go to Security & Privacy > Privacy 97 | 3. Select "Full Disk Access" from the left sidebar 98 | 4. Click the lock icon to make changes 99 | 5. Add Terminal.app or iTerm.app to the list 100 | 6. Restart your terminal and try again 101 | 102 | Error details: ${error instanceof Error ? error.message : String(error)} 103 | `); 104 | return false; 105 | } 106 | } 107 | 108 | function decodeAttributedBody(hexString: string): { text: string; url?: string } { 109 | try { 110 | // Convert hex to buffer 111 | const buffer = Buffer.from(hexString, 'hex'); 112 | const content = buffer.toString(); 113 | 114 | // Common patterns in attributedBody 115 | const patterns = [ 116 | /NSString">(.*?)([^<]+)/, // NSString without closing tag 118 | /NSNumber">\d+<.*?NSString">(.*?).*?NSString">(.*?)]*>(.*?)(.*?) 5) { // Only use if we got something substantial 132 | break; 133 | } 134 | } 135 | } 136 | 137 | // Look for URLs 138 | const urlPatterns = [ 139 | /(https?:\/\/[^\s<"]+)/, // Standard URLs 140 | /NSString">(https?:\/\/[^\s<"]+)/, // URLs in NSString 141 | /"url":\s*"(https?:\/\/[^"]+)"/, // URLs in JSON format 142 | /link[^>]*>(https?:\/\/[^<]+)/ // URLs in XML-style tags 143 | ]; 144 | 145 | let url: string | undefined; 146 | for (const pattern of urlPatterns) { 147 | const match = content.match(pattern); 148 | if (match?.[1]) { 149 | url = match[1]; 150 | break; 151 | } 152 | } 153 | 154 | if (!text && !url) { 155 | // Try to extract any readable text content 156 | const readableText = content 157 | .replace(/streamtyped.*?NSString/g, '') // Remove streamtyped header 158 | .replace(/NSAttributedString.*?NSString/g, '') // Remove attributed string metadata 159 | .replace(/NSDictionary.*?$/g, '') // Remove dictionary metadata 160 | .replace(/\+[A-Za-z]+\s/g, '') // Remove +[identifier] patterns 161 | .replace(/NSNumber.*?NSValue.*?\*/g, '') // Remove number/value metadata 162 | .replace(/[^\x20-\x7E]/g, ' ') // Replace non-printable chars with space 163 | .replace(/\s+/g, ' ') // Normalize whitespace 164 | .trim(); 165 | 166 | if (readableText.length > 5) { // Only use if we got something substantial 167 | text = readableText; 168 | } else { 169 | return { text: '[Message content not readable]' }; 170 | } 171 | } 172 | 173 | // Clean up the found text 174 | if (text) { 175 | text = text 176 | .replace(/^[+\s]+/, '') // Remove leading + and spaces 177 | .replace(/\s*iI\s*[A-Z]\s*$/, '') // Remove iI K pattern at end 178 | .replace(/\s+/g, ' ') // Normalize whitespace 179 | .trim(); 180 | } 181 | 182 | return { text: text || url || '', url }; 183 | } catch (error) { 184 | console.error('Error decoding attributedBody:', error); 185 | return { text: '[Message content not readable]' }; 186 | } 187 | } 188 | 189 | async function getAttachmentPaths(messageId: number): Promise { 190 | try { 191 | const query = ` 192 | SELECT filename 193 | FROM attachment 194 | INNER JOIN message_attachment_join 195 | ON attachment.ROWID = message_attachment_join.attachment_id 196 | WHERE message_attachment_join.message_id = ${messageId} 197 | `; 198 | 199 | const { stdout } = await execAsync(`sqlite3 -json "${process.env.HOME}/Library/Messages/chat.db" "${query}"`); 200 | 201 | if (!stdout.trim()) { 202 | return []; 203 | } 204 | 205 | const attachments = JSON.parse(stdout) as { filename: string }[]; 206 | return attachments.map(a => a.filename).filter(Boolean); 207 | } catch (error) { 208 | console.error('Error getting attachments:', error); 209 | return []; 210 | } 211 | } 212 | 213 | async function readMessages(phoneNumber: string, limit = 10): Promise { 214 | try { 215 | // Check database access with retries 216 | const hasAccess = await retryOperation(checkMessagesDBAccess); 217 | if (!hasAccess) { 218 | return []; 219 | } 220 | 221 | // Get all possible formats of the phone number 222 | const phoneFormats = normalizePhoneNumber(phoneNumber); 223 | console.error("Trying phone formats:", phoneFormats); 224 | 225 | // Create SQL IN clause with all phone number formats 226 | const phoneList = phoneFormats.map(p => `'${p.replace(/'/g, "''")}'`).join(','); 227 | 228 | const query = ` 229 | SELECT 230 | m.ROWID as message_id, 231 | CASE 232 | WHEN m.text IS NOT NULL AND m.text != '' THEN m.text 233 | WHEN m.attributedBody IS NOT NULL THEN hex(m.attributedBody) 234 | ELSE NULL 235 | END as content, 236 | datetime(m.date/1000000000 + strftime('%s', '2001-01-01'), 'unixepoch', 'localtime') as date, 237 | h.id as sender, 238 | m.is_from_me, 239 | m.is_audio_message, 240 | m.cache_has_attachments, 241 | m.subject, 242 | CASE 243 | WHEN m.text IS NOT NULL AND m.text != '' THEN 0 244 | WHEN m.attributedBody IS NOT NULL THEN 1 245 | ELSE 2 246 | END as content_type 247 | FROM message m 248 | INNER JOIN handle h ON h.ROWID = m.handle_id 249 | WHERE h.id IN (${phoneList}) 250 | AND (m.text IS NOT NULL OR m.attributedBody IS NOT NULL OR m.cache_has_attachments = 1) 251 | AND m.is_from_me IS NOT NULL -- Ensure it's a real message 252 | AND m.item_type = 0 -- Regular messages only 253 | AND m.is_audio_message = 0 -- Skip audio messages 254 | ORDER BY m.date DESC 255 | LIMIT ${limit} 256 | `; 257 | 258 | // Execute query with retries 259 | const { stdout } = await retryOperation(() => 260 | execAsync(`sqlite3 -json "${process.env.HOME}/Library/Messages/chat.db" "${query}"`) 261 | ); 262 | 263 | if (!stdout.trim()) { 264 | console.error("No messages found in database for the given phone number"); 265 | return []; 266 | } 267 | 268 | const messages = JSON.parse(stdout) as (Message & { 269 | message_id: number; 270 | is_audio_message: number; 271 | cache_has_attachments: number; 272 | subject: string | null; 273 | content_type: number; 274 | })[]; 275 | 276 | // Process messages with potential parallel attachment fetching 277 | const processedMessages = await Promise.all( 278 | messages 279 | .filter(msg => msg.content !== null || msg.cache_has_attachments === 1) 280 | .map(async msg => { 281 | let content = msg.content || ''; 282 | let url: string | undefined; 283 | 284 | // If it's an attributedBody (content_type = 1), decode it 285 | if (msg.content_type === 1) { 286 | const decoded = decodeAttributedBody(content); 287 | content = decoded.text; 288 | url = decoded.url; 289 | } else { 290 | // Check for URLs in regular text messages 291 | const urlMatch = content.match(/(https?:\/\/[^\s]+)/); 292 | if (urlMatch) { 293 | url = urlMatch[1]; 294 | } 295 | } 296 | 297 | // Get attachments if any 298 | let attachments: string[] = []; 299 | if (msg.cache_has_attachments) { 300 | attachments = await getAttachmentPaths(msg.message_id); 301 | } 302 | 303 | // Add subject if present 304 | if (msg.subject) { 305 | content = `Subject: ${msg.subject}\n${content}`; 306 | } 307 | 308 | // Format the message object 309 | const formattedMsg: Message = { 310 | content: content || '[No text content]', 311 | date: new Date(msg.date).toISOString(), 312 | sender: msg.sender, 313 | is_from_me: Boolean(msg.is_from_me) 314 | }; 315 | 316 | // Add attachments if any 317 | if (attachments.length > 0) { 318 | formattedMsg.attachments = attachments; 319 | formattedMsg.content += `\n[Attachments: ${attachments.length}]`; 320 | } 321 | 322 | // Add URL if present 323 | if (url) { 324 | formattedMsg.url = url; 325 | formattedMsg.content += `\n[URL: ${url}]`; 326 | } 327 | 328 | return formattedMsg; 329 | }) 330 | ); 331 | 332 | return processedMessages; 333 | } catch (error) { 334 | console.error('Error reading messages:', error); 335 | if (error instanceof Error) { 336 | console.error('Error details:', error.message); 337 | console.error('Stack trace:', error.stack); 338 | } 339 | return []; 340 | } 341 | } 342 | 343 | async function getUnreadMessages(limit = 10): Promise { 344 | try { 345 | // Check database access with retries 346 | const hasAccess = await retryOperation(checkMessagesDBAccess); 347 | if (!hasAccess) { 348 | return []; 349 | } 350 | 351 | const query = ` 352 | SELECT 353 | m.ROWID as message_id, 354 | CASE 355 | WHEN m.text IS NOT NULL AND m.text != '' THEN m.text 356 | WHEN m.attributedBody IS NOT NULL THEN hex(m.attributedBody) 357 | ELSE NULL 358 | END as content, 359 | datetime(m.date/1000000000 + strftime('%s', '2001-01-01'), 'unixepoch', 'localtime') as date, 360 | h.id as sender, 361 | m.is_from_me, 362 | m.is_audio_message, 363 | m.cache_has_attachments, 364 | m.subject, 365 | CASE 366 | WHEN m.text IS NOT NULL AND m.text != '' THEN 0 367 | WHEN m.attributedBody IS NOT NULL THEN 1 368 | ELSE 2 369 | END as content_type 370 | FROM message m 371 | INNER JOIN handle h ON h.ROWID = m.handle_id 372 | WHERE m.is_from_me = 0 -- Only messages from others 373 | AND m.is_read = 0 -- Only unread messages 374 | AND (m.text IS NOT NULL OR m.attributedBody IS NOT NULL OR m.cache_has_attachments = 1) 375 | AND m.is_audio_message = 0 -- Skip audio messages 376 | AND m.item_type = 0 -- Regular messages only 377 | ORDER BY m.date DESC 378 | LIMIT ${limit} 379 | `; 380 | 381 | // Execute query with retries 382 | const { stdout } = await retryOperation(() => 383 | execAsync(`sqlite3 -json "${process.env.HOME}/Library/Messages/chat.db" "${query}"`) 384 | ); 385 | 386 | if (!stdout.trim()) { 387 | console.error("No unread messages found"); 388 | return []; 389 | } 390 | 391 | const messages = JSON.parse(stdout) as (Message & { 392 | message_id: number; 393 | is_audio_message: number; 394 | cache_has_attachments: number; 395 | subject: string | null; 396 | content_type: number; 397 | })[]; 398 | 399 | // Process messages with potential parallel attachment fetching 400 | const processedMessages = await Promise.all( 401 | messages 402 | .filter(msg => msg.content !== null || msg.cache_has_attachments === 1) 403 | .map(async msg => { 404 | let content = msg.content || ''; 405 | let url: string | undefined; 406 | 407 | // If it's an attributedBody (content_type = 1), decode it 408 | if (msg.content_type === 1) { 409 | const decoded = decodeAttributedBody(content); 410 | content = decoded.text; 411 | url = decoded.url; 412 | } else { 413 | // Check for URLs in regular text messages 414 | const urlMatch = content.match(/(https?:\/\/[^\s]+)/); 415 | if (urlMatch) { 416 | url = urlMatch[1]; 417 | } 418 | } 419 | 420 | // Get attachments if any 421 | let attachments: string[] = []; 422 | if (msg.cache_has_attachments) { 423 | attachments = await getAttachmentPaths(msg.message_id); 424 | } 425 | 426 | // Add subject if present 427 | if (msg.subject) { 428 | content = `Subject: ${msg.subject}\n${content}`; 429 | } 430 | 431 | // Format the message object 432 | const formattedMsg: Message = { 433 | content: content || '[No text content]', 434 | date: new Date(msg.date).toISOString(), 435 | sender: msg.sender, 436 | is_from_me: Boolean(msg.is_from_me) 437 | }; 438 | 439 | // Add attachments if any 440 | if (attachments.length > 0) { 441 | formattedMsg.attachments = attachments; 442 | formattedMsg.content += `\n[Attachments: ${attachments.length}]`; 443 | } 444 | 445 | // Add URL if present 446 | if (url) { 447 | formattedMsg.url = url; 448 | formattedMsg.content += `\n[URL: ${url}]`; 449 | } 450 | 451 | return formattedMsg; 452 | }) 453 | ); 454 | 455 | return processedMessages; 456 | } catch (error) { 457 | console.error('Error reading unread messages:', error); 458 | if (error instanceof Error) { 459 | console.error('Error details:', error.message); 460 | console.error('Stack trace:', error.stack); 461 | } 462 | return []; 463 | } 464 | } 465 | 466 | async function scheduleMessage(phoneNumber: string, message: string, scheduledTime: Date) { 467 | // Store the scheduled message details 468 | const scheduledMessages = new Map(); 469 | 470 | // Calculate delay in milliseconds 471 | const delay = scheduledTime.getTime() - Date.now(); 472 | 473 | if (delay < 0) { 474 | throw new Error('Cannot schedule message in the past'); 475 | } 476 | 477 | // Schedule the message 478 | const timeoutId = setTimeout(async () => { 479 | try { 480 | await sendMessage(phoneNumber, message); 481 | scheduledMessages.delete(timeoutId); 482 | } catch (error) { 483 | console.error('Failed to send scheduled message:', error); 484 | } 485 | }, delay); 486 | 487 | // Store the scheduled message details for reference 488 | scheduledMessages.set(timeoutId, { 489 | phoneNumber, 490 | message, 491 | scheduledTime, 492 | timeoutId 493 | }); 494 | 495 | return { 496 | id: timeoutId, 497 | scheduledTime, 498 | message, 499 | phoneNumber 500 | }; 501 | } 502 | 503 | export default { sendMessage, readMessages, scheduleMessage, getUnreadMessages }; 504 | -------------------------------------------------------------------------------- /utils/notes.ts: -------------------------------------------------------------------------------- 1 | import { run } from '@jxa/run'; 2 | 3 | type Note = { 4 | name: string; 5 | content: string; 6 | }; 7 | 8 | type CreateNoteResult = { 9 | success: boolean; 10 | note?: Note; 11 | message?: string; 12 | folderName?: string; 13 | usedDefaultFolder?: boolean; 14 | }; 15 | 16 | async function getAllNotes() { 17 | const notes: Note[] = await run(() => { 18 | const Notes = Application('Notes'); 19 | const notes = Notes.notes(); 20 | 21 | // biome-ignore lint/suspicious/noExplicitAny: 22 | return notes.map((note: any) => ({ 23 | name: note.name(), 24 | content: note.plaintext() 25 | })); 26 | }); 27 | 28 | return notes; 29 | } 30 | 31 | async function findNote(searchText: string) { 32 | const notes: Note[] = await run((searchText: string) => { 33 | const Notes = Application('Notes'); 34 | const notes = Notes.notes.whose({_or: [ 35 | {name: {_contains: searchText}}, 36 | {plaintext: {_contains: searchText}} 37 | ]})() 38 | // biome-ignore lint/suspicious/noExplicitAny: 39 | return notes.length > 0 ? notes.map((note: any) => ({ 40 | name: note.name(), 41 | content: note.plaintext() 42 | })) : []; 43 | }, searchText); 44 | 45 | if (notes.length === 0) { 46 | const allNotes = await getAllNotes(); 47 | const closestMatch = allNotes.find(({name}) => 48 | name.toLowerCase().includes(searchText.toLowerCase()) 49 | ); 50 | return closestMatch ? [{ 51 | name: closestMatch.name, 52 | content: closestMatch.content 53 | }] : []; 54 | } 55 | 56 | return notes; 57 | } 58 | 59 | async function createNote(title: string, body: string, folderName: string = 'Claude'): Promise { 60 | try { 61 | // Format the body with proper markdown 62 | const formattedBody = body 63 | .replace(/^(#+)\s+(.+)$/gm, '$1 $2\n') // Add newline after headers 64 | .replace(/^-\s+(.+)$/gm, '\n- $1') // Add newline before list items 65 | .replace(/\n{3,}/g, '\n\n') // Remove excess newlines 66 | .trim(); 67 | 68 | const result = await run((title: string, body: string, folderName: string) => { 69 | const Notes = Application('Notes'); 70 | 71 | // Create the note 72 | let targetFolder; 73 | let usedDefaultFolder = false; 74 | let actualFolderName = folderName; 75 | 76 | try { 77 | // Try to find the specified folder 78 | const folders = Notes.folders(); 79 | for (let i = 0; i < folders.length; i++) { 80 | if (folders[i].name() === folderName) { 81 | targetFolder = folders[i]; 82 | break; 83 | } 84 | } 85 | 86 | // If the specified folder doesn't exist 87 | if (!targetFolder) { 88 | if (folderName === 'Claude') { 89 | // Try to create the Claude folder if it doesn't exist 90 | Notes.make({new: 'folder', withProperties: {name: 'Claude'}}); 91 | usedDefaultFolder = true; 92 | 93 | // Find it again after creation 94 | const updatedFolders = Notes.folders(); 95 | for (let i = 0; i < updatedFolders.length; i++) { 96 | if (updatedFolders[i].name() === 'Claude') { 97 | targetFolder = updatedFolders[i]; 98 | break; 99 | } 100 | } 101 | } else { 102 | throw new Error(`Folder "${folderName}" not found`); 103 | } 104 | } 105 | 106 | // Create the note in the specified folder or default folder 107 | let newNote; 108 | if (targetFolder) { 109 | newNote = Notes.make({new: 'note', withProperties: {name: title, body: body}, at: targetFolder}); 110 | actualFolderName = folderName; 111 | } else { 112 | // Fall back to default folder 113 | newNote = Notes.make({new: 'note', withProperties: {name: title, body: body}}); 114 | actualFolderName = 'Default'; 115 | } 116 | 117 | return { 118 | success: true, 119 | note: { 120 | name: title, 121 | content: body 122 | }, 123 | folderName: actualFolderName, 124 | usedDefaultFolder: usedDefaultFolder 125 | }; 126 | } catch (scriptError) { 127 | throw new Error(`AppleScript error: ${scriptError.message || String(scriptError)}`); 128 | } 129 | }, title, formattedBody, folderName); 130 | 131 | return result; 132 | } catch (error) { 133 | return { 134 | success: false, 135 | message: `Failed to create note: ${error instanceof Error ? error.message : String(error)}` 136 | }; 137 | } 138 | } 139 | 140 | export default { getAllNotes, findNote, createNote }; 141 | -------------------------------------------------------------------------------- /utils/reminders.ts: -------------------------------------------------------------------------------- 1 | import { run } from "@jxa/run"; 2 | 3 | // Define types for our reminders 4 | interface ReminderList { 5 | name: string; 6 | id: string; 7 | } 8 | 9 | interface Reminder { 10 | name: string; 11 | id: string; 12 | body: string; 13 | completed: boolean; 14 | dueDate: string | null; 15 | listName: string; 16 | completionDate?: string | null; 17 | creationDate?: string | null; 18 | modificationDate?: string | null; 19 | remindMeDate?: string | null; 20 | priority?: number; 21 | } 22 | 23 | /** 24 | * Get all reminder lists 25 | * @returns Array of reminder lists with their names and IDs 26 | */ 27 | async function getAllLists(): Promise { 28 | const lists = await run(() => { 29 | const Reminders = Application("Reminders"); 30 | const lists = Reminders.lists(); 31 | 32 | return lists.map((list: any) => ({ 33 | name: list.name(), 34 | id: list.id(), 35 | })); 36 | }); 37 | 38 | return lists as ReminderList[]; 39 | } 40 | 41 | /** 42 | * Get reminders from a specific list by ID with customizable properties 43 | * @param listId ID of the list to get reminders from 44 | * @param props Array of properties to include (optional) 45 | * @returns Array of reminders with specified properties 46 | */ 47 | async function getRemindersFromListById( 48 | listId: string, 49 | props?: string[] 50 | ): Promise { 51 | return await run( 52 | (args: { id: string; props?: string[] }) => { 53 | function main() { 54 | const reminders = Application("Reminders"); 55 | const list = reminders.lists.byId(args.id).reminders; 56 | const props = args.props || [ 57 | "name", 58 | "body", 59 | "id", 60 | "completed", 61 | "completionDate", 62 | "creationDate", 63 | "dueDate", 64 | "modificationDate", 65 | "remindMeDate", 66 | "priority", 67 | ]; 68 | // We could traverse all reminders and for each one get the all the props. 69 | // This is more inefficient than calling '.name()' on the very reminder list. It requires 70 | // less function calls. 71 | const propFns: Record = props.reduce( 72 | (obj: Record, prop: string) => { 73 | obj[prop] = list[prop](); 74 | return obj; 75 | }, 76 | {} 77 | ); 78 | const finalList = []; 79 | 80 | // Flatten the object {name: string[], id: string[]} to an array of form 81 | // [{name: string, id: string}, ..., {name: string, id: string}] which represents the list 82 | // of reminders 83 | for (let i = 0; i < (propFns.name?.length || 0); i++) { 84 | const reminder = props.reduce( 85 | (obj: Record, prop: string) => { 86 | obj[prop] = propFns[prop][i]; 87 | return obj; 88 | }, 89 | {} 90 | ); 91 | finalList.push(reminder); 92 | } 93 | return finalList; 94 | } 95 | return main(); 96 | }, 97 | { id: listId, props } 98 | ); 99 | } 100 | 101 | /** 102 | * Get all reminders from a specific list or all lists 103 | * @param listName Optional list name to filter by 104 | * @returns Array of reminders 105 | */ 106 | async function getAllReminders(listName?: string): Promise { 107 | const reminders = await run((listName: string | undefined) => { 108 | const Reminders = Application("Reminders"); 109 | let allReminders: Reminder[] = []; 110 | 111 | if (listName) { 112 | // Get reminders from a specific list 113 | const lists = Reminders.lists.whose({ name: listName })(); 114 | if (lists.length > 0) { 115 | const list = lists[0]; 116 | allReminders = list.reminders().map((reminder: any) => ({ 117 | name: reminder.name(), 118 | id: reminder.id(), 119 | body: reminder.body() || "", 120 | completed: reminder.completed(), 121 | dueDate: reminder.dueDate() ? reminder.dueDate().toISOString() : null, 122 | listName: list.name(), 123 | })); 124 | } 125 | } else { 126 | // Get reminders from all lists 127 | const lists = Reminders.lists(); 128 | for (const list of lists) { 129 | const remindersInList = list.reminders().map((reminder: any) => ({ 130 | name: reminder.name(), 131 | id: reminder.id(), 132 | body: reminder.body() || "", 133 | completed: reminder.completed(), 134 | dueDate: reminder.dueDate() ? reminder.dueDate().toISOString() : null, 135 | listName: list.name(), 136 | })); 137 | allReminders = allReminders.concat(remindersInList); 138 | } 139 | } 140 | 141 | return allReminders; 142 | }, listName); 143 | 144 | return reminders as Reminder[]; 145 | } 146 | 147 | /** 148 | * Search for reminders by text 149 | * @param searchText Text to search for in reminder names or notes 150 | * @returns Array of matching reminders 151 | */ 152 | async function searchReminders(searchText: string): Promise { 153 | const reminders = await run((searchText: string) => { 154 | const Reminders = Application("Reminders"); 155 | const lists = Reminders.lists(); 156 | let matchingReminders: Reminder[] = []; 157 | 158 | for (const list of lists) { 159 | // Search in reminder names and bodies 160 | const remindersInList = list.reminders.whose({ 161 | _or: [ 162 | { name: { _contains: searchText } }, 163 | { body: { _contains: searchText } }, 164 | ], 165 | })(); 166 | 167 | if (remindersInList.length > 0) { 168 | const mappedReminders = remindersInList.map((reminder: any) => ({ 169 | name: reminder.name(), 170 | id: reminder.id(), 171 | body: reminder.body() || "", 172 | completed: reminder.completed(), 173 | dueDate: reminder.dueDate() ? reminder.dueDate().toISOString() : null, 174 | listName: list.name(), 175 | })); 176 | matchingReminders = matchingReminders.concat(mappedReminders); 177 | } 178 | } 179 | 180 | return matchingReminders; 181 | }, searchText); 182 | 183 | return reminders as Reminder[]; 184 | } 185 | 186 | /** 187 | * Create a new reminder 188 | * @param name Name of the reminder 189 | * @param listName Name of the list to add the reminder to (creates if doesn't exist) 190 | * @param notes Optional notes for the reminder 191 | * @param dueDate Optional due date for the reminder (ISO string) 192 | * @returns The created reminder 193 | */ 194 | async function createReminder( 195 | name: string, 196 | listName: string = "Reminders", 197 | notes?: string, 198 | dueDate?: string 199 | ): Promise { 200 | const result = await run( 201 | ( 202 | name: string, 203 | listName: string, 204 | notes: string | undefined, 205 | dueDate: string | undefined 206 | ) => { 207 | const Reminders = Application("Reminders"); 208 | 209 | // Find or create the list 210 | let list; 211 | const existingLists = Reminders.lists.whose({ name: listName })(); 212 | 213 | if (existingLists.length > 0) { 214 | list = existingLists[0]; 215 | } else { 216 | // Create a new list if it doesn't exist 217 | list = Reminders.make({ 218 | new: "list", 219 | withProperties: { name: listName }, 220 | }); 221 | } 222 | 223 | // Create the reminder properties 224 | const reminderProps: any = { 225 | name: name, 226 | }; 227 | 228 | if (notes) { 229 | reminderProps.body = notes; 230 | } 231 | 232 | if (dueDate) { 233 | reminderProps.dueDate = new Date(dueDate); 234 | } 235 | 236 | // Create the reminder 237 | const newReminder = list.make({ 238 | new: "reminder", 239 | withProperties: reminderProps, 240 | }); 241 | 242 | return { 243 | name: newReminder.name(), 244 | id: newReminder.id(), 245 | body: newReminder.body() || "", 246 | completed: newReminder.completed(), 247 | dueDate: newReminder.dueDate() 248 | ? newReminder.dueDate().toISOString() 249 | : null, 250 | listName: list.name(), 251 | }; 252 | }, 253 | name, 254 | listName, 255 | notes, 256 | dueDate 257 | ); 258 | 259 | return result as Reminder; 260 | } 261 | 262 | interface OpenReminderResult { 263 | success: boolean; 264 | message: string; 265 | reminder?: Reminder; 266 | } 267 | 268 | /** 269 | * Open the Reminders app and show a specific reminder 270 | * @param searchText Text to search for in reminder names or notes 271 | * @returns Result of the operation 272 | */ 273 | async function openReminder(searchText: string): Promise { 274 | // First search for the reminder 275 | const matchingReminders = await searchReminders(searchText); 276 | 277 | if (matchingReminders.length === 0) { 278 | return { success: false, message: "No matching reminders found" }; 279 | } 280 | 281 | // Open the first matching reminder 282 | const reminder = matchingReminders[0]; 283 | 284 | await run((reminderId: string) => { 285 | const Reminders = Application("Reminders"); 286 | Reminders.activate(); 287 | 288 | // Try to show the reminder 289 | // Note: This is a best effort as there's no direct way to show a specific reminder 290 | // We'll just open the app and return the reminder details 291 | 292 | return true; 293 | }, reminder.id); 294 | 295 | return { 296 | success: true, 297 | message: "Reminders app opened", 298 | reminder, 299 | }; 300 | } 301 | 302 | export default { 303 | getAllLists, 304 | getAllReminders, 305 | searchReminders, 306 | createReminder, 307 | openReminder, 308 | getRemindersFromListById, 309 | }; 310 | -------------------------------------------------------------------------------- /utils/webSearch.ts: -------------------------------------------------------------------------------- 1 | interface SearchResult { 2 | title: string; 3 | url: string; 4 | displayUrl: string; 5 | snippet: string; 6 | } 7 | 8 | interface ContentResult extends SearchResult { 9 | content: string | null; 10 | error?: string; 11 | } 12 | 13 | interface SearchResponse { 14 | query: string; 15 | results: SearchResult[]; 16 | error?: string; 17 | } 18 | 19 | interface ContentResponse { 20 | query: string; 21 | results: ContentResult[]; 22 | error?: string; 23 | } 24 | 25 | interface RequestOptions { 26 | method?: string; 27 | headers?: Record; 28 | timeout?: number; 29 | retries?: number; 30 | } 31 | 32 | /** 33 | * Makes an HTTP/HTTPS request with retry capability and returns the response as a string 34 | */ 35 | async function makeRequest( 36 | url: string, 37 | options: RequestOptions = {}, 38 | ): Promise { 39 | const retries = options.retries || 2; 40 | let lastError: Error | null = null; 41 | 42 | for (let attempt = 0; attempt <= retries; attempt++) { 43 | try { 44 | const response = await fetch(url, { 45 | method: options.method || 'GET', 46 | headers: { 47 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 48 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 49 | 'Accept-Language': 'en-US,en;q=0.5', 50 | ...(options.headers || {}) 51 | }, 52 | signal: options.timeout ? AbortSignal.timeout(options.timeout) : undefined 53 | }); 54 | 55 | if (!response.ok) { 56 | throw new Error(`Request failed with status code ${response.status}`); 57 | } 58 | 59 | return response.text(); 60 | } catch (error) { 61 | lastError = error instanceof Error ? error : new Error(String(error)); 62 | 63 | // Don't retry if it's an aborted request or timeout 64 | if (error instanceof DOMException && error.name === 'AbortError') { 65 | break; 66 | } 67 | 68 | // Only retry if we have attempts left 69 | if (attempt === retries) { 70 | break; 71 | } 72 | 73 | // Wait before retry (exponential backoff) 74 | await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt)); 75 | } 76 | } 77 | 78 | throw lastError || new Error('Request failed'); 79 | } 80 | 81 | /** 82 | * Cleans HTML entities and tags from text 83 | */ 84 | function cleanHTML(text: string): string { 85 | if (!text) return ''; 86 | 87 | // Basic HTML entity decoding 88 | let decodedText = text 89 | .replace(/&/g, "&") 90 | .replace(/</g, "<") 91 | .replace(/>/g, ">") 92 | .replace(/"/g, '"') 93 | .replace(/'/g, "'") 94 | .replace(/'/g, "'") 95 | .replace(/ /g, " "); 96 | 97 | // Remove HTML tags 98 | decodedText = decodedText.replace(/<[^>]+>/g, ""); 99 | 100 | // Normalize whitespace 101 | decodedText = decodedText.replace(/\s+/g, " ").trim(); 102 | 103 | return decodedText; 104 | } 105 | 106 | /** 107 | * Extracts search results from DuckDuckGo HTML response 108 | * Increased to handle more results and with more robust parsing 109 | */ 110 | function extractDDGResults(html: string): SearchResult[] { 111 | const results: SearchResult[] = []; 112 | 113 | // Find the results div 114 | const resultsMatch = html.match(/
(.*?)
<\/div>/gs); 121 | if (!resultBlocks) return results; 122 | 123 | // Process results (increased from 3 to 10 for more comprehensive results) 124 | for (let i = 0; i < Math.min(10, resultBlocks.length); i++) { 125 | const block = resultBlocks[i]; 126 | 127 | try { 128 | // Extract components using more reliable selectors 129 | const titleMatch = block.match(/]*>(.*?)<\/a>/s); 130 | const urlMatch = block.match(/href="\/\/duckduckgo\.com\/l\/\?uddg=(.*?)(?:&|")/); 131 | const displayUrlMatch = block.match(/]*>(.*?)<\/a>/s); 132 | const snippetMatch = block.match(/]*>(.*?)<\/a>/s); 133 | 134 | if (titleMatch && urlMatch) { 135 | results.push({ 136 | title: cleanHTML(titleMatch[1]), 137 | url: decodeURIComponent(urlMatch[1]), 138 | displayUrl: displayUrlMatch ? cleanHTML(displayUrlMatch[1]) : new URL(decodeURIComponent(urlMatch[1])).hostname, 139 | snippet: snippetMatch ? cleanHTML(snippetMatch[1]) : "" 140 | }); 141 | } 142 | } catch (error) { 143 | console.error("Error parsing result block:", error); 144 | // Continue with next block even if one fails 145 | } 146 | } 147 | 148 | return results; 149 | } 150 | 151 | /** 152 | * Searches DuckDuckGo and returns results with improved error handling 153 | */ 154 | async function searchDuckDuckGo(query: string): Promise { 155 | try { 156 | const encodedQuery = encodeURIComponent(query); 157 | const searchUrl = `https://html.duckduckgo.com/html/?q=${encodedQuery}`; 158 | 159 | const html = await makeRequest(searchUrl, { 160 | timeout: 10000, 161 | retries: 2 162 | }); 163 | const results = extractDDGResults(html); 164 | 165 | if (results.length === 0) { 166 | // Try alternative parsing if the main one failed 167 | const alternativeResults = extractDDGResultsAlternative(html); 168 | if (alternativeResults.length > 0) { 169 | return { 170 | query, 171 | results: alternativeResults 172 | }; 173 | } 174 | 175 | return { 176 | query, 177 | results: [], 178 | error: "No results found or couldn't parse results" 179 | }; 180 | } 181 | 182 | return { 183 | query, 184 | results 185 | }; 186 | } catch (error) { 187 | const errorMessage = error instanceof Error ? error.message : String(error); 188 | console.error("DuckDuckGo search failed:", errorMessage); 189 | return { 190 | query, 191 | error: errorMessage, 192 | results: [] 193 | }; 194 | } 195 | } 196 | 197 | /** 198 | * Alternative extraction method in case the primary one fails 199 | */ 200 | function extractDDGResultsAlternative(html: string): SearchResult[] { 201 | const results: SearchResult[] = []; 202 | 203 | try { 204 | // Try to find result blocks with a more general approach 205 | const links = html.match(/

.*?(.*?)<\/a>.*?(.*?)<\/a>/gs); 206 | 207 | if (!links) return results; 208 | 209 | for (const link of links) { 210 | const titleMatch = link.match(/(.*?)<\/a>/s); 211 | const urlMatch = link.match(/href=".*?uddg=(.*?)(?:&|")/); 212 | const snippetMatch = link.match(/(.*?)<\/a>/s); 213 | 214 | if (titleMatch && urlMatch) { 215 | results.push({ 216 | title: cleanHTML(titleMatch[1]), 217 | url: decodeURIComponent(urlMatch[1]), 218 | displayUrl: new URL(decodeURIComponent(urlMatch[1])).hostname, 219 | snippet: snippetMatch ? cleanHTML(snippetMatch[1]) : "" 220 | }); 221 | } 222 | } 223 | } catch (error) { 224 | console.error("Alternative extraction failed:", error); 225 | } 226 | 227 | return results; 228 | } 229 | 230 | /** 231 | * Extracts main content from HTML with improved detection of main content area 232 | */ 233 | function extractMainContent(content: string): string { 234 | if (!content) return ''; 235 | 236 | try { 237 | // Remove common non-content elements 238 | const cleanedContent = content 239 | .replace(/)<[^<]*)*<\/script>/gi, " ") 240 | .replace(/)<[^<]*)*<\/style>/gi, " ") 241 | .replace(/)<[^<]*)*<\/header>/gi, " ") 242 | .replace(/)<[^<]*)*<\/footer>/gi, " ") 243 | .replace(/)<[^<]*)*<\/nav>/gi, " ") 244 | .replace(/)<[^<]*)*<\/aside>/gi, " ") 245 | .replace(/)<[^<]*)*<\/form>/gi, " ") 246 | .replace(//gs, " "); 247 | 248 | // Prioritized content areas to check 249 | const contentSelectors = [ 250 | /)<[^<]*)*<\/main>/gi, 251 | /)<[^<]*)*<\/article>/gi, 252 | /]*\s+)?class\s*=\s*["'](?:[^"']*\s+)?(?:content|post-content|entry-content|article-content|page-content|main-content)[^"']*["'][^>]*>.*?<\/div>/gi, 253 | /]*\s+)?id\s*=\s*["'](?:content|post-content|entry-content|article-content|page-content|main-content)["'][^>]*>.*?<\/div>/gi, 254 | /)<[^<]*)*<\/body>/gi, 255 | ]; 256 | 257 | let mainContent = ''; 258 | 259 | // Try each selector in order of priority 260 | for (const selector of contentSelectors) { 261 | const matches = cleanedContent.match(selector); 262 | if (matches && matches.length > 0) { 263 | // If we find multiple matches (e.g., multiple articles), concatenate them 264 | mainContent = matches.join(" "); 265 | break; 266 | } 267 | } 268 | 269 | // If no content found, use the whole HTML as fallback 270 | if (!mainContent) { 271 | mainContent = cleanedContent; 272 | } 273 | 274 | // Remove remaining HTML tags 275 | let textContent = mainContent.replace(/<[^>]+>/g, " "); 276 | 277 | // Clean up whitespace 278 | textContent = textContent.replace(/\s+/g, " ").trim(); 279 | 280 | // Decode HTML entities 281 | textContent = cleanHTML(textContent); 282 | 283 | return textContent; 284 | } catch (error) { 285 | console.error("Error extracting main content:", error); 286 | return "Failed to extract content"; 287 | } 288 | } 289 | 290 | /** 291 | * Fetch and extract content from a URL with improved error handling and timeout handling 292 | */ 293 | async function fetchPageContent( 294 | url: string, 295 | ): Promise<{ url: string; content: string | null; error?: string }> { 296 | try { 297 | // Set a shorter timeout for content requests 298 | const html = await makeRequest(url, { 299 | timeout: 15000, 300 | retries: 1, 301 | headers: { 302 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 303 | } 304 | }); 305 | 306 | let content = ''; 307 | try { 308 | content = extractMainContent(html); 309 | } catch (contentError) { 310 | return { 311 | url, 312 | content: null, 313 | error: `Error extracting content: ${contentError instanceof Error ? contentError.message : String(contentError)}` 314 | }; 315 | } 316 | 317 | // Extract title for reference 318 | const titleMatch = html.match(/]*>(.*?)<\/title>/i); 319 | const title = titleMatch ? cleanHTML(titleMatch[1]) : ""; 320 | 321 | return { 322 | url, 323 | content: content || `[No content extracted. Page title: ${title}]`, 324 | }; 325 | } catch (error) { 326 | const errorMessage = error instanceof Error ? error.message : String(error); 327 | console.error(`Error fetching content from ${url}:`, errorMessage); 328 | return { 329 | url, 330 | error: errorMessage, 331 | content: null, 332 | }; 333 | } 334 | } 335 | 336 | /** 337 | * Complete web search function that fetches search results and their content 338 | * with better parallel processing and error handling 339 | */ 340 | export async function webSearch(query: string): Promise { 341 | try { 342 | // Step 1: Get search results from DuckDuckGo 343 | const searchResults = await searchDuckDuckGo(query); 344 | 345 | if (searchResults.error || searchResults.results.length === 0) { 346 | return { 347 | query, 348 | error: searchResults.error || "No search results found", 349 | results: [], 350 | }; 351 | } 352 | 353 | // Step 2: Fetch content for each result (limit to 5 results to improve performance) 354 | const resultsToProcess = searchResults.results.slice(0, 5); 355 | 356 | // Use Promise.allSettled to ensure all requests complete, even if some fail 357 | const settledPromises = await Promise.allSettled( 358 | resultsToProcess.map(result => fetchPageContent(result.url)) 359 | ); 360 | 361 | // Process results 362 | const fullResults = resultsToProcess.map((result, index) => { 363 | const promise = settledPromises[index]; 364 | 365 | if (promise.status === "fulfilled") { 366 | return { 367 | ...result, 368 | content: promise.value.content, 369 | error: promise.value.error 370 | }; 371 | } else { 372 | // For rejected promises, return the result with an error 373 | return { 374 | ...result, 375 | content: null, 376 | error: `Failed to fetch content: ${promise.reason}` 377 | }; 378 | } 379 | }); 380 | 381 | return { 382 | query, 383 | results: fullResults, 384 | }; 385 | } catch (error) { 386 | const errorMessage = error instanceof Error ? error.message : String(error); 387 | console.error("Web search failed:", errorMessage); 388 | return { 389 | query, 390 | error: errorMessage, 391 | results: [], 392 | }; 393 | } 394 | } 395 | 396 | export default { 397 | webSearch, 398 | searchDuckDuckGo, 399 | fetchPageContent, 400 | }; --------------------------------------------------------------------------------