src / toolsProvider.ts
/**
* Calendar Plugin — toolsProvider
*
* Tools:
* calendar(action) — today | upcoming | search | free_slots
*/
import { text, tool, type ToolCallContext, type ToolsProvider } from "@lmstudio/sdk";
import { z } from "zod";
import { pluginConfigSchematics } from "./config";
import { parseIcsFile, filterByRange, formatEvent, type CalEvent } from "./ical";
import { fetchMacOSEvents } from "./macos";
function json(obj: unknown): string {
return JSON.stringify(obj, null, 2);
}
function safe_impl<T extends Record<string, unknown>>(
name: string,
fn: (params: T, ctx: ToolCallContext) => Promise<string>
): (params: T, ctx: ToolCallContext) => Promise<string> {
return async (params: T, ctx: ToolCallContext) => {
if (ctx.signal.aborted) return JSON.stringify({ tool_error: true, tool: name, error: "cancelled" });
try {
return await fn(params, ctx);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return JSON.stringify({ tool_error: true, tool: name, error: msg }, null, 2);
}
};
}
async function getEvents(source: string, icsPath: string, from: Date, to: Date): Promise<CalEvent[]> {
if (source === "ics") {
if (!icsPath) throw new Error("ICS file path not configured. Set it in plugin settings.");
const all = await parseIcsFile(icsPath);
return filterByRange(all, from, to);
}
return fetchMacOSEvents(from, to);
}
export const toolsProvider: ToolsProvider = async (ctl) => {
const cfg = ctl.getPluginConfig(pluginConfigSchematics);
return [
tool({
name: "calendar",
description: text`
Query your local calendar (macOS Calendar.app or an ICS file).
action: "today" — All events for today
action: "upcoming" — Events in the next N days (days_ahead, default from settings)
action: "search" — Find events matching a keyword in title/location
action: "free_slots" — Find gaps ≥ N minutes between events on a given date
date: ISO date string "YYYY-MM-DD" (default: today)
keyword: search term (for search action)
days_ahead: number of days to look forward (for upcoming)
min_gap_minutes: minimum free block size in minutes (for free_slots, default 30)
`,
parameters: {
action: z.enum(["today", "upcoming", "search", "free_slots"]),
date: z.string().default("").describe("YYYY-MM-DD, default today"),
days_ahead: z.coerce.number().int().min(1).max(90).optional(),
keyword: z.string().default(""),
min_gap_minutes: z.coerce.number().int().min(5).max(480).default(30),
},
implementation: safe_impl("calendar", async ({ action, date, days_ahead, keyword, min_gap_minutes }, ctx) => {
const source = cfg.get("calendarSource");
const icsPath = cfg.get("icsFilePath");
const defaultDaysAhead = cfg.get("daysAhead");
const today = new Date();
today.setHours(0, 0, 0, 0);
const targetDate = date ? new Date(date + "T00:00:00") : new Date(today);
if (action === "today") {
ctx.status("Fetching today's events");
const endOfDay = new Date(targetDate);
endOfDay.setHours(23, 59, 59, 999);
const events = await getEvents(source, icsPath, targetDate, endOfDay);
return json({
date: targetDate.toDateString(),
count: events.length,
events: events.map(formatEvent),
});
}
if (action === "upcoming") {
const ahead = days_ahead ?? defaultDaysAhead;
ctx.status(`Fetching events for next ${ahead} days`);
const to = new Date(today);
to.setDate(to.getDate() + ahead);
to.setHours(23, 59, 59, 999);
const events = await getEvents(source, icsPath, today, to);
const grouped: Record<string, string[]> = {};
for (const e of events) {
const key = e.start.toDateString();
grouped[key] = grouped[key] ?? [];
grouped[key].push(formatEvent(e));
}
return json({ days_ahead: ahead, total_events: events.length, schedule: grouped });
}
if (action === "search") {
if (!keyword) throw new Error("keyword required for search action");
ctx.status(`Searching for "${keyword}"`);
const to = new Date(today);
to.setDate(to.getDate() + 90);
const all = await getEvents(source, icsPath, today, to);
const kw = keyword.toLowerCase();
const matches = all.filter(e =>
e.summary.toLowerCase().includes(kw) ||
(e.location ?? "").toLowerCase().includes(kw)
);
return json({ keyword, matches_found: matches.length, events: matches.map(formatEvent) });
}
// free_slots
ctx.status(`Finding free slots ≥ ${min_gap_minutes} min on ${targetDate.toDateString()}`);
const endOfDay = new Date(targetDate);
endOfDay.setHours(23, 59, 59, 999);
const events = await getEvents(source, icsPath, targetDate, endOfDay);
const workStart = new Date(targetDate); workStart.setHours(9, 0, 0, 0);
const workEnd = new Date(targetDate); workEnd.setHours(18, 0, 0, 0);
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const freeSlots: Array<{ from: string; to: string; minutes: number }> = [];
let cursor = workStart;
for (const e of sorted) {
if (e.start > cursor) {
const gapMin = Math.round((e.start.getTime() - cursor.getTime()) / 60000);
if (gapMin >= min_gap_minutes) {
freeSlots.push({
from: cursor.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
to: e.start.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
minutes: gapMin,
});
}
}
if (e.end > cursor) cursor = e.end;
}
if (cursor < workEnd) {
const gapMin = Math.round((workEnd.getTime() - cursor.getTime()) / 60000);
if (gapMin >= min_gap_minutes) {
freeSlots.push({
from: cursor.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
to: workEnd.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
minutes: gapMin,
});
}
}
return json({ date: targetDate.toDateString(), min_gap_minutes, free_slots: freeSlots });
}),
}),
];
};