Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ OpenWhispr is an Electron-based desktop dictation application that uses whisper.
- **googleCalendarManager.js**: Google Calendar sync (REST, OAuth via `googleCalendarOAuth.js`)
- 10s socket timeout on API requests
- Incremental sync via `syncToken`; full re-sync on 410 prunes stale events (note-linked rows retained)
- Sync tokens pin the `timeMin`/`timeMax` window of the full sync that created them (incremental syncs never roll it forward), so tokens are discarded after 1 day to keep the 9-day lookahead covering the availability tool's 7-day horizon
- **microsoftCalendarManager.js**: Microsoft Calendar sync via Graph API (OAuth via `microsoftCalendarOAuth.js`)
- `calendarView/delta` incremental sync over a 14-day window; delta token discarded after 7 days (Graph delta links never roll their window forward)
- Delta can return recurring-series occurrences as bare stubs (no subject/attendees/meeting link); they're backfilled from their series master, one `GET /me/events/{id}` per series
Expand Down
1 change: 1 addition & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
gcalSyncEvents: () => ipcRenderer.invoke("gcal-sync-events"),
gcalGetUpcomingEvents: (windowMinutes) =>
ipcRenderer.invoke("gcal-get-upcoming-events", windowMinutes),
calendarGetAvailability: (request) => ipcRenderer.invoke("calendar-get-availability", request),
gcalGetEvent: (eventId) => ipcRenderer.invoke("gcal-get-event", eventId),

// Microsoft Calendar
Expand Down
26 changes: 22 additions & 4 deletions resources/macos-calendar-listener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Long-running process that reads the local EventKit store and emits
* line-delimited JSON on stdout: a permission message on launch, then a
* calendars+events snapshot whenever the store changes, "sync" arrives on
* stdin, or a 5-minute timer rolls the 7-day window forward.
* stdin, or a 5-minute timer rolls the availability cache window forward.
*
* Pass --request to prompt for calendar access when it is not determined.
* macOS reads the usage strings from the TCC "responsible process": the
Expand All @@ -23,8 +23,12 @@ import Foundation

let eventStore = EKEventStore()
let requestAccess = CommandLine.arguments.contains("--request")
let LOOKAHEAD_DAYS = 7.0
// One extra day keeps the tool's 7-day horizon covered between snapshots.
let CACHE_LOOKAHEAD_DAYS = 8.0
let REFRESH_INTERVAL_SECONDS = 300.0
// Keep events that may still block availability after the maximum two-hour
// buffer, plus one snapshot interval of safety.
let AVAILABILITY_LOOKBACK_SECONDS = (120.0 * 60) + REFRESH_INTERVAL_SECONDS

let isoFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
Expand Down Expand Up @@ -89,6 +93,7 @@ struct EventOut: Encodable {
let end: String
let is_all_day: Bool
let status: String
let availability: String
let organizer_email: String?
let url: String?
let location: String?
Expand Down Expand Up @@ -169,6 +174,17 @@ func eventStatus(_ status: EKEventStatus) -> String {
}
}

func eventAvailability(_ availability: EKEventAvailability) -> String {
switch availability {
case .free: return "free"
case .tentative: return "tentative"
case .busy: return "busy"
case .unavailable: return "unavailable"
case .notSupported: return "unknown"
@unknown default: return "unknown"
}
}

let linkDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)

func extractURLs(from text: String?) -> [String] {
Expand Down Expand Up @@ -204,6 +220,7 @@ func mapEvent(_ event: EKEvent) -> EventOut? {
end: isoFormatter.string(from: endDate),
is_all_day: event.isAllDay,
status: eventStatus(event.status),
availability: eventAvailability(event.availability),
organizer_email: mailtoEmail(event.organizer?.url),
url: event.url?.absoluteString,
location: event.location,
Expand All @@ -226,8 +243,9 @@ func emitSnapshot() {
)
}

let start = Date()
let end = start.addingTimeInterval(LOOKAHEAD_DAYS * 24 * 60 * 60)
let now = Date()
let start = now.addingTimeInterval(-AVAILABILITY_LOOKBACK_SECONDS)
let end = now.addingTimeInterval(CACHE_LOOKAHEAD_DAYS * 24 * 60 * 60)
let predicate = eventStore.predicateForEvents(withStart: start, end: end, calendars: calendars)
let eventsOut = eventStore.events(matching: predicate).compactMap(mapEvent)

Expand Down
12 changes: 11 additions & 1 deletion src/components/chat/toolIcons.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { Search, Globe, ClipboardCheck, Calendar, FileText, FilePlus, FilePen } from "lucide-react";
import {
Search,
Globe,
ClipboardCheck,
Calendar,
CalendarCheck,
FileText,
FilePlus,
FilePen,
} from "lucide-react";

export const toolIcons: Record<string, typeof Search> = {
search_notes: Search,
web_search: Globe,
copy_to_clipboard: ClipboardCheck,
get_calendar_events: Calendar,
get_calendar_availability: CalendarCheck,
get_note: FileText,
create_note: FilePlus,
update_note: FilePen,
Expand Down
24 changes: 24 additions & 0 deletions src/config/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,29 @@ const TOOL_INSTRUCTIONS: Record<string, string> = {
"Use copy_to_clipboard when the user asks you to copy something to their clipboard.",
get_calendar_events:
"Use get_calendar_events to check the user's schedule, upcoming meetings, or calendar events.",
get_calendar_availability:
"Use get_calendar_availability when the user asks when they are free or requests open time slots. Pass timezone-aware RFC3339 start and end timestamps, deriving the correct offset for each future date from the IANA time zone rather than assuming the current offset across a daylight-saving transition. Treat the returned slotCount and each slot's localized date, weekday, times, and duration as authoritative: use them exactly and never recalculate, add, omit, merge, or invent slots. For a broad multi-day request without daily-hour bounds, ask which hours of each day to consider, then make a separate call for each day. Results reflect the local calendar cache across the user's selected connected calendars, so describe free results as no scheduled conflicts found rather than guaranteed real-time availability, and never infer event details from availability facts.",
};

const twoDigits = (value: number): string => String(value).padStart(2, "0");

function formatLocalRfc3339(date: Date): string {
const offsetMinutes = -date.getTimezoneOffset();
const offsetSign = offsetMinutes >= 0 ? "+" : "-";
const absoluteOffset = Math.abs(offsetMinutes);
const offset = `${offsetSign}${twoDigits(Math.floor(absoluteOffset / 60))}:${twoDigits(absoluteOffset % 60)}`;
return (
`${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}` +
`T${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}:${twoDigits(date.getSeconds())}${offset}`
);
}

function getLocalCalendarContext(): string {
const now = new Date();
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
return `Current local date and time: ${formatLocalRfc3339(now)}. IANA time zone: ${timeZone}.`;
}

export function getAgentSystemPrompt(availableTools?: string[], noteContext?: string): string {
let prompt = resolvePrompt("chatAgent", { agentName: null });

Expand All @@ -51,6 +72,9 @@ export function getAgentSystemPrompt(availableTools?: string[], noteContext?: st
if (toolLines.length > 0) {
prompt += "\n\nYou have access to tools. " + toolLines.join(" ");
}
if (availableTools.includes("get_calendar_availability")) {
prompt += "\n\n" + getLocalCalendarContext();
}
}

if (noteContext) {
Expand Down
9 changes: 9 additions & 0 deletions src/helpers/appleCalendarManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const { FOCUS_SYNC_THROTTLE_MS } = require("./calendarSyncInterval");
const BINARY_NAME = "macos-calendar-listener";
const HELPER_RESTART_BASE_MS = 1000;
const HELPER_RESTART_MAX_MS = 30 * 1000;
const AVAILABILITY_STATUSES = new Set(["free", "tentative", "busy", "unavailable", "unknown"]);
const RESPONSE_STATUSES = new Set(["accepted", "declined", "tentative", "needsAction"]);

// Reads the local EventKit store (all accounts Calendar.app aggregates) via a
// bundled Swift helper that pushes calendars+events snapshots as line-delimited
Expand Down Expand Up @@ -301,6 +303,7 @@ class AppleCalendarManager {

_mapEvent(event) {
const attendees = event.attendees || [];
const selfResponseStatus = attendees.find((attendee) => attendee.self === true)?.status;
return {
id: event.id,
calendar_id: event.calendar_id,
Expand All @@ -310,6 +313,12 @@ class AppleCalendarManager {
end_time: event.end,
is_all_day: event.is_all_day,
status: event.status,
availability_status: AVAILABILITY_STATUSES.has(event.availability)
? event.availability
: "unknown",
self_response_status: RESPONSE_STATUSES.has(selfResponseStatus)
? selfResponseStatus
: "unknown",
hangout_link:
extractMeetingUrl([event.url, event.location, ...(event.notes_urls || [])]) ??
// Generic fallback only for the event's own URL field
Expand Down
Loading
Loading