diff --git a/notes_access.py b/notes_access.py index 1d4c06fb..426c4a43 100644 --- a/notes_access.py +++ b/notes_access.py @@ -33,19 +33,42 @@ async def _run_notes_script(script: str, timeout: float = 10) -> str: async def get_recent_notes(count: int = 10) -> list[dict]: - """Get most recent notes (title + creation date).""" + """Get most recent notes (title + creation date). + + Individual notes are read defensively, because an unguarded property + access aborts the entire script and one unreadable note would otherwise + cost every note. + + "folder" is best-effort and is currently empty in practice: on recent + macOS, `name of container of note` fails with -1728 for every note (the + container resolves, but as a bare `item` whose name is not exposed). Real + folder names are only reachable by walking `every note of folder`, which + means scanning the whole library on every call — not worth it while no + caller reads the field. Guarding the lookup rather than dropping it keeps + this self-healing if Apple restores the property. + """ + # Bound the scan so a run of unreadable notes can't walk a whole library. + scan_limit = count + 50 script = f''' tell application "Notes" set output to "" set allNotes to every note - set limit to count of allNotes - if limit > {count} then set limit to {count} - repeat with i from 1 to limit - set n to item i of allNotes - set nName to name of n - set nDate to creation date of n as string - set nFolder to name of container of n - set output to output & nName & "|||" & nDate & "|||" & nFolder & linefeed + set scanCount to count of allNotes + if scanCount > {scan_limit} then set scanCount to {scan_limit} + set collected to 0 + repeat with i from 1 to scanCount + if collected >= {count} then exit repeat + try + set n to item i of allNotes + set nName to name of n + set nDate to creation date of n as string + set nFolder to "" + try + set nFolder to name of container of n + end try + set output to output & nName & "|||" & nDate & "|||" & nFolder & linefeed + set collected to collected + 1 + end try end repeat return output end tell diff --git a/server.py b/server.py index f08e7370..358e4b78 100644 --- a/server.py +++ b/server.py @@ -42,14 +42,14 @@ from actions import execute_action, monitor_build, open_terminal, open_browser, open_claude_in_project, _generate_project_name, prompt_existing_terminal, applescript_escape from work_mode import WorkSession, is_casual_question from screen import get_active_windows, take_screenshot, describe_screen, format_windows_for_context -from calendar_access import get_todays_events, get_upcoming_events, get_next_event, format_events_for_context, format_schedule_summary, refresh_cache as refresh_calendar_cache +from calendar_access import get_todays_events, get_upcoming_events, get_next_event, get_calendar_names, format_events_for_context, format_schedule_summary, refresh_cache as refresh_calendar_cache from mail_access import get_unread_count, get_unread_messages, get_recent_messages, search_mail, read_message, format_unread_summary, format_messages_for_context, format_messages_for_voice from memory import ( remember, recall, get_open_tasks, create_task, complete_task, search_tasks, create_note, search_notes, get_tasks_for_date, build_memory_context, format_tasks_for_voice, extract_memories, get_important_memories, ) -from notes_access import get_recent_notes, read_note, search_notes_apple, create_apple_note +from notes_access import get_recent_notes, get_note_folders, read_note, search_notes_apple, create_apple_note from dispatch_registry import DispatchRegistry from planner import TaskPlanner, detect_planning_mode, BYPASS_PHRASES @@ -67,6 +67,9 @@ USER_NAME = os.getenv("USER_NAME", "sir") PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) _SKIP_PERMISSIONS = os.getenv("JARVIS_SKIP_PERMISSIONS", "true").lower() not in ("0", "false", "no") +# Per-integration ceiling for the /api/settings/status probes, so the settings +# panel stays responsive when Calendar/Mail/Notes are slow or unauthorized. +_SETTINGS_PROBE_TIMEOUT = 8.0 DESKTOP_PATH = Path.home() / "Desktop" @@ -2530,18 +2533,40 @@ async def api_test_fish(body: KeyTest): except Exception as e: return {"valid": False, "error": str(e)[:200]} +async def _probe_integration(coro, ok=lambda _: True, timeout: float = _SETTINGS_PROBE_TIMEOUT) -> bool: + """Return whether a macOS integration answered within the time budget. + + Individual osascript calls are already bounded, but the totals are not, and + before the user grants TCC permission they block on a system dialog. The + settings panel only needs a reachable/unreachable flag, so cap the wait and + report a slow integration the same as a broken one. + """ + try: + return bool(ok(await asyncio.wait_for(coro, timeout=timeout))) + except Exception: + return False + + @app.get("/api/settings/status") async def api_settings_status(): import shutil as _shutil _, env_dict = _read_env() claude_installed = _shutil.which("claude") is not None - calendar_ok = mail_ok = notes_ok = False - try: await get_todays_events(); calendar_ok = True - except Exception: pass - try: await get_unread_count(); mail_ok = True - except Exception: pass - try: await get_recent_notes(count=1); notes_ok = True - except Exception: pass + # Probed concurrently — they touch three separate apps and don't depend on + # each other, so the endpoint costs one budget rather than three. + # + # Calendar and Notes are probed by listing containers, and their empty + # result is read as a failure. Both modules answer an unreachable app with + # the same empty value they use for "nothing found", so a probe that only + # checked for a raised exception would report an app as healthy while it + # was in fact failing. Listing also avoids the expensive paths: fetching + # today's events fans a cold cache out over every calendar in batches of + # two, which alone outruns the budget on a well-populated account. + calendar_ok, mail_ok, notes_ok = await asyncio.gather( + _probe_integration(get_calendar_names(), ok=bool), + _probe_integration(get_unread_count()), + _probe_integration(get_note_folders(), ok=bool), + ) memory_count = task_count = 0 try: memory_count = len(get_important_memories(limit=9999)) except Exception: pass