CI / checks (push) Has been cancelled
Local models were burning the 8-round budget on simple event adds. Raise the limit, resolve calendars by name/default, and inject the household clock so relative dates work.
541 lines
21 KiB
TypeScript
541 lines
21 KiB
TypeScript
type ApiCallResult = {
|
|
status: number;
|
|
body: unknown;
|
|
};
|
|
|
|
export type ToolExecutor = (name: string, argsJson: string) => Promise<string>;
|
|
|
|
export function createApiToolExecutor(request: Request): ToolExecutor {
|
|
const origin = new URL(request.url).origin;
|
|
|
|
return async (name: string, argsJson: string) => {
|
|
const args = parseArgs(argsJson);
|
|
const result = await dispatchTool(name, args, request, origin);
|
|
return JSON.stringify(result);
|
|
};
|
|
}
|
|
|
|
function parseArgs(argsJson: string): Record<string, unknown> {
|
|
if (!argsJson.trim()) return {};
|
|
const parsed: unknown = JSON.parse(argsJson);
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
throw new Error("Tool arguments must be a JSON object");
|
|
}
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
|
|
async function dispatchTool(
|
|
name: string,
|
|
args: Record<string, unknown>,
|
|
request: Request,
|
|
origin: string,
|
|
): Promise<ApiCallResult> {
|
|
switch (name) {
|
|
case "list_lists": {
|
|
const type = typeof args.type === "string" ? args.type : undefined;
|
|
const qs = type ? `?type=${encodeURIComponent(type)}` : "";
|
|
return callApi(request, origin, "GET", `/api/v1/lists${qs}`);
|
|
}
|
|
case "list_list_items": {
|
|
const listId = requireString(args, "listId");
|
|
return callApi(request, origin, "GET", `/api/v1/lists/${listId}/items`);
|
|
}
|
|
case "add_list_item": {
|
|
const text = requireString(args, "text");
|
|
let listId = typeof args.listId === "string" ? args.listId : undefined;
|
|
|
|
if (!listId) {
|
|
const listType = typeof args.listType === "string" ? args.listType : undefined;
|
|
const listsResult = await callApi(
|
|
request,
|
|
origin,
|
|
"GET",
|
|
listType ? `/api/v1/lists?type=${encodeURIComponent(listType)}` : "/api/v1/lists",
|
|
);
|
|
if (listsResult.status !== 200 || !Array.isArray(listsResult.body)) {
|
|
return listsResult;
|
|
}
|
|
const first = listsResult.body[0] as { id?: string } | undefined;
|
|
listId = first?.id;
|
|
if (!listId) {
|
|
return { status: 404, body: { error: "No matching list found" } };
|
|
}
|
|
}
|
|
|
|
const body: Record<string, unknown> = { text };
|
|
if (typeof args.qty === "string") body.qty = args.qty;
|
|
return callApi(request, origin, "POST", `/api/v1/lists/${listId}/items`, body);
|
|
}
|
|
case "update_list_item": {
|
|
const listId = requireString(args, "listId");
|
|
const itemId = requireString(args, "itemId");
|
|
const body: Record<string, unknown> = {};
|
|
if (typeof args.done === "boolean") body.done = args.done;
|
|
if (typeof args.text === "string") body.text = args.text;
|
|
if (typeof args.qty === "string") body.qty = args.qty;
|
|
return callApi(request, origin, "PATCH", `/api/v1/lists/${listId}/items/${itemId}`, body);
|
|
}
|
|
case "delete_list_item": {
|
|
const listId = requireString(args, "listId");
|
|
const itemId = requireString(args, "itemId");
|
|
return callApi(request, origin, "DELETE", `/api/v1/lists/${listId}/items/${itemId}`);
|
|
}
|
|
case "create_list": {
|
|
const body = {
|
|
type: requireString(args, "type"),
|
|
name: requireString(args, "name"),
|
|
};
|
|
return callApi(request, origin, "POST", "/api/v1/lists", body);
|
|
}
|
|
case "update_list": {
|
|
const listId = requireString(args, "listId");
|
|
const body: Record<string, unknown> = {};
|
|
if (typeof args.name === "string") body.name = args.name;
|
|
if (typeof args.archived === "boolean") body.archived = args.archived;
|
|
return callApi(request, origin, "PATCH", `/api/v1/lists/${listId}`, body);
|
|
}
|
|
case "delete_list": {
|
|
const listId = requireString(args, "listId");
|
|
return callApi(request, origin, "DELETE", `/api/v1/lists/${listId}`);
|
|
}
|
|
case "list_calendars":
|
|
return callApi(request, origin, "GET", "/api/v1/calendars");
|
|
case "list_events": {
|
|
const from = requireString(args, "from");
|
|
const to = requireString(args, "to");
|
|
const calendarIds =
|
|
typeof args.calendarIds === "string" && args.calendarIds.length > 0
|
|
? args.calendarIds
|
|
: "all";
|
|
const qs = new URLSearchParams({ from, to, calendarIds });
|
|
return callApi(request, origin, "GET", `/api/v1/events?${qs.toString()}`);
|
|
}
|
|
case "create_event": {
|
|
const resolved = await resolveCalendarId(args, request, origin);
|
|
if ("error" in resolved) return resolved.error;
|
|
|
|
const body: Record<string, unknown> = {
|
|
calendarId: resolved.calendarId,
|
|
title: requireString(args, "title"),
|
|
startAt: requireString(args, "startAt"),
|
|
endAt: requireString(args, "endAt"),
|
|
allDay: typeof args.allDay === "boolean" ? args.allDay : false,
|
|
};
|
|
if (typeof args.location === "string") body.location = args.location;
|
|
if (typeof args.notes === "string") body.notes = args.notes;
|
|
if (typeof args.remindMinutesBefore === "number") {
|
|
body.remindMinutesBefore = args.remindMinutesBefore;
|
|
}
|
|
return callApi(request, origin, "POST", "/api/v1/events", body);
|
|
}
|
|
case "update_event": {
|
|
const eventId = requireString(args, "eventId");
|
|
const body: Record<string, unknown> = {};
|
|
if (typeof args.title === "string") body.title = args.title;
|
|
if (typeof args.startAt === "string") body.startAt = args.startAt;
|
|
if (typeof args.endAt === "string") body.endAt = args.endAt;
|
|
if (typeof args.allDay === "boolean") body.allDay = args.allDay;
|
|
if (typeof args.location === "string") body.location = args.location;
|
|
if (typeof args.notes === "string") body.notes = args.notes;
|
|
if (typeof args.remindMinutesBefore === "number") {
|
|
body.remindMinutesBefore = args.remindMinutesBefore;
|
|
}
|
|
return callApi(request, origin, "PATCH", `/api/v1/events/${eventId}`, body);
|
|
}
|
|
case "delete_event": {
|
|
const eventId = requireString(args, "eventId");
|
|
return callApi(request, origin, "DELETE", `/api/v1/events/${eventId}`);
|
|
}
|
|
case "create_calendar": {
|
|
const body: Record<string, unknown> = {
|
|
name: requireString(args, "name"),
|
|
};
|
|
if (typeof args.color === "string") body.color = args.color;
|
|
if (args.visibility === "private" || args.visibility === "household") {
|
|
body.visibility = args.visibility;
|
|
}
|
|
return callApi(request, origin, "POST", "/api/v1/calendars", body);
|
|
}
|
|
case "list_notes":
|
|
return callApi(request, origin, "GET", "/api/v1/notes");
|
|
case "create_note": {
|
|
const body: Record<string, unknown> = {
|
|
title: requireString(args, "title"),
|
|
body: typeof args.body === "string" ? args.body : "",
|
|
pinned: typeof args.pinned === "boolean" ? args.pinned : false,
|
|
};
|
|
if (typeof args.remindAt === "string") body.remindAt = args.remindAt;
|
|
return callApi(request, origin, "POST", "/api/v1/notes", body);
|
|
}
|
|
case "update_note": {
|
|
const noteId = requireString(args, "noteId");
|
|
const body: Record<string, unknown> = {};
|
|
if (typeof args.title === "string") body.title = args.title;
|
|
if (typeof args.body === "string") body.body = args.body;
|
|
if (typeof args.pinned === "boolean") body.pinned = args.pinned;
|
|
if (typeof args.remindAt === "string") body.remindAt = args.remindAt;
|
|
if (args.remindAt === null) body.remindAt = null;
|
|
return callApi(request, origin, "PATCH", `/api/v1/notes/${noteId}`, body);
|
|
}
|
|
case "delete_note": {
|
|
const noteId = requireString(args, "noteId");
|
|
return callApi(request, origin, "DELETE", `/api/v1/notes/${noteId}`);
|
|
}
|
|
case "list_journal_entries": {
|
|
const limit = typeof args.limit === "number" ? String(args.limit) : undefined;
|
|
const qs = limit ? `?limit=${encodeURIComponent(limit)}` : "";
|
|
return callApi(request, origin, "GET", `/api/v1/journal/entries${qs}`);
|
|
}
|
|
case "create_journal_entry": {
|
|
const body: Record<string, unknown> = {
|
|
recordedAt: requireString(args, "recordedAt"),
|
|
};
|
|
if (typeof args.title === "string") body.title = args.title;
|
|
if (typeof args.body === "string") body.body = args.body;
|
|
if (Array.isArray(args.moods)) body.moods = args.moods;
|
|
if (typeof args.stress === "number") body.stress = args.stress;
|
|
if (typeof args.pillsTaken === "boolean") body.pillsTaken = args.pillsTaken;
|
|
return callApi(request, origin, "POST", "/api/v1/journal/entries", body);
|
|
}
|
|
case "update_journal_entry": {
|
|
const entryId = requireString(args, "entryId");
|
|
const body: Record<string, unknown> = {};
|
|
if (typeof args.recordedAt === "string") body.recordedAt = args.recordedAt;
|
|
if (typeof args.title === "string") body.title = args.title;
|
|
if (typeof args.body === "string") body.body = args.body;
|
|
if (Array.isArray(args.moods)) body.moods = args.moods;
|
|
if (typeof args.stress === "number") body.stress = args.stress;
|
|
if (typeof args.pillsTaken === "boolean") body.pillsTaken = args.pillsTaken;
|
|
return callApi(request, origin, "PATCH", `/api/v1/journal/entries/${entryId}`, body);
|
|
}
|
|
case "delete_journal_entry": {
|
|
const entryId = requireString(args, "entryId");
|
|
return callApi(request, origin, "DELETE", `/api/v1/journal/entries/${entryId}`);
|
|
}
|
|
case "list_bangs": {
|
|
const limit = typeof args.limit === "number" ? String(args.limit) : undefined;
|
|
const qs = limit ? `?limit=${encodeURIComponent(limit)}` : "";
|
|
return callApi(request, origin, "GET", `/api/v1/bangs${qs}`);
|
|
}
|
|
case "add_bang": {
|
|
const body: Record<string, unknown> = {};
|
|
if (typeof args.occurredOn === "string") body.occurredOn = args.occurredOn;
|
|
return callApi(request, origin, "POST", "/api/v1/bangs", body);
|
|
}
|
|
case "update_bang": {
|
|
const bangId = requireString(args, "bangId");
|
|
const body = { occurredOn: requireString(args, "occurredOn") };
|
|
return callApi(request, origin, "PATCH", `/api/v1/bangs/${bangId}`, body);
|
|
}
|
|
case "delete_bang": {
|
|
const bangId = requireString(args, "bangId");
|
|
return callApi(request, origin, "DELETE", `/api/v1/bangs/${bangId}`);
|
|
}
|
|
case "list_garden_containers":
|
|
return callApi(request, origin, "GET", "/api/v1/garden/containers");
|
|
case "list_garden_plants": {
|
|
const containerId = typeof args.containerId === "string" ? args.containerId : undefined;
|
|
const qs = containerId ? `?containerId=${encodeURIComponent(containerId)}` : "";
|
|
return callApi(request, origin, "GET", `/api/v1/garden/plants${qs}`);
|
|
}
|
|
case "get_garden_plant": {
|
|
const plantId = requireString(args, "plantId");
|
|
return callApi(request, origin, "GET", `/api/v1/garden/plants/${plantId}`);
|
|
}
|
|
case "create_garden_container": {
|
|
const body: Record<string, unknown> = { name: requireString(args, "name") };
|
|
if (typeof args.type === "string") body.type = args.type;
|
|
if (typeof args.locationNotes === "string") body.locationNotes = args.locationNotes;
|
|
return callApi(request, origin, "POST", "/api/v1/garden/containers", body);
|
|
}
|
|
case "update_garden_container": {
|
|
const containerId = requireString(args, "containerId");
|
|
const body: Record<string, unknown> = {};
|
|
if (typeof args.name === "string") body.name = args.name;
|
|
if (typeof args.type === "string") body.type = args.type;
|
|
if (typeof args.locationNotes === "string") body.locationNotes = args.locationNotes;
|
|
return callApi(request, origin, "PATCH", `/api/v1/garden/containers/${containerId}`, body);
|
|
}
|
|
case "delete_garden_container": {
|
|
const containerId = requireString(args, "containerId");
|
|
return callApi(request, origin, "DELETE", `/api/v1/garden/containers/${containerId}`);
|
|
}
|
|
case "create_garden_plant": {
|
|
const body: Record<string, unknown> = { name: requireString(args, "name") };
|
|
if (typeof args.category === "string") body.category = args.category;
|
|
if (typeof args.containerId === "string") body.containerId = args.containerId;
|
|
if (typeof args.healthStatus === "string") body.healthStatus = args.healthStatus;
|
|
if (typeof args.notes === "string") body.notes = args.notes;
|
|
return callApi(request, origin, "POST", "/api/v1/garden/plants", body);
|
|
}
|
|
case "update_garden_plant": {
|
|
const plantId = requireString(args, "plantId");
|
|
const body: Record<string, unknown> = {};
|
|
if (typeof args.name === "string") body.name = args.name;
|
|
if (typeof args.category === "string") body.category = args.category;
|
|
if (typeof args.containerId === "string") body.containerId = args.containerId;
|
|
if (typeof args.healthStatus === "string") body.healthStatus = args.healthStatus;
|
|
if (typeof args.notes === "string") body.notes = args.notes;
|
|
return callApi(request, origin, "PATCH", `/api/v1/garden/plants/${plantId}`, body);
|
|
}
|
|
case "delete_garden_plant": {
|
|
const plantId = requireString(args, "plantId");
|
|
return callApi(request, origin, "DELETE", `/api/v1/garden/plants/${plantId}`);
|
|
}
|
|
case "log_garden_care": {
|
|
const plantId = requireString(args, "plantId");
|
|
const body: Record<string, unknown> = { careType: requireString(args, "careType") };
|
|
if (typeof args.notes === "string") body.notes = args.notes;
|
|
if (typeof args.performedAt === "string") body.performedAt = args.performedAt;
|
|
return callApi(request, origin, "POST", `/api/v1/garden/plants/${plantId}/care-logs`, body);
|
|
}
|
|
case "list_garden_care_logs": {
|
|
const plantId = requireString(args, "plantId");
|
|
const limit = typeof args.limit === "number" ? `?limit=${args.limit}` : "";
|
|
return callApi(request, origin, "GET", `/api/v1/garden/plants/${plantId}/care-logs${limit}`);
|
|
}
|
|
case "list_garden_care_schedules": {
|
|
const plantId = requireString(args, "plantId");
|
|
return callApi(request, origin, "GET", `/api/v1/garden/plants/${plantId}/care-schedules`);
|
|
}
|
|
case "upsert_garden_care_schedule": {
|
|
const plantId = requireString(args, "plantId");
|
|
const body: Record<string, unknown> = {
|
|
careType: requireString(args, "careType"),
|
|
intervalDays: requireNumber(args, "intervalDays"),
|
|
};
|
|
if (typeof args.enabled === "boolean") body.enabled = args.enabled;
|
|
return callApi(
|
|
request,
|
|
origin,
|
|
"POST",
|
|
`/api/v1/garden/plants/${plantId}/care-schedules`,
|
|
body,
|
|
);
|
|
}
|
|
case "delete_garden_care_schedule": {
|
|
const scheduleId = requireString(args, "scheduleId");
|
|
return callApi(request, origin, "DELETE", `/api/v1/garden/care-schedules/${scheduleId}`);
|
|
}
|
|
case "toggle_garden_care_schedule": {
|
|
const scheduleId = requireString(args, "scheduleId");
|
|
const body = { enabled: requireBoolean(args, "enabled") };
|
|
return callApi(request, origin, "PATCH", `/api/v1/garden/care-schedules/${scheduleId}`, body);
|
|
}
|
|
case "push_overdue_garden_care":
|
|
return callApi(request, origin, "POST", "/api/v1/garden/overdue-care/push");
|
|
case "schedule_garden_care_on_calendar": {
|
|
const scheduleId = requireString(args, "scheduleId");
|
|
const body: Record<string, unknown> = {
|
|
calendarId: requireString(args, "calendarId"),
|
|
};
|
|
if (typeof args.reminderMinutesBefore === "number") {
|
|
body.reminderMinutesBefore = args.reminderMinutesBefore;
|
|
}
|
|
return callApi(
|
|
request,
|
|
origin,
|
|
"POST",
|
|
`/api/v1/garden/care-schedules/${scheduleId}/calendar`,
|
|
body,
|
|
);
|
|
}
|
|
case "list_shareable_entity_types":
|
|
return callApi(request, origin, "GET", "/api/v1/share-links?entityTypes=true");
|
|
case "list_share_links": {
|
|
const qs = new URLSearchParams();
|
|
if (typeof args.entityType === "string") qs.set("entityType", args.entityType);
|
|
if (typeof args.entityId === "string") qs.set("entityId", args.entityId);
|
|
const query = qs.toString();
|
|
return callApi(
|
|
request,
|
|
origin,
|
|
"GET",
|
|
query ? `/api/v1/share-links?${query}` : "/api/v1/share-links",
|
|
);
|
|
}
|
|
case "create_share_link": {
|
|
const body: Record<string, unknown> = {
|
|
entityType: requireString(args, "entityType"),
|
|
entityId: requireString(args, "entityId"),
|
|
};
|
|
if (typeof args.expiresAt === "string") body.expiresAt = args.expiresAt;
|
|
if (typeof args.write === "boolean") {
|
|
body.capabilities = { write: args.write };
|
|
}
|
|
return callApi(request, origin, "POST", "/api/v1/share-links", body);
|
|
}
|
|
case "revoke_share_link": {
|
|
const linkId = requireString(args, "linkId");
|
|
return callApi(request, origin, "DELETE", `/api/v1/share-links/${linkId}`);
|
|
}
|
|
case "get_api_docs":
|
|
return getApiDocs(args);
|
|
case "call_api":
|
|
return callApiFallback(args, request, origin);
|
|
default:
|
|
return { status: 400, body: { error: `Unknown tool: ${name}` } };
|
|
}
|
|
}
|
|
|
|
async function resolveCalendarId(
|
|
args: Record<string, unknown>,
|
|
request: Request,
|
|
origin: string,
|
|
): Promise<{ calendarId: string } | { error: ApiCallResult }> {
|
|
const calendarId = typeof args.calendarId === "string" ? args.calendarId.trim() : "";
|
|
if (calendarId) return { calendarId };
|
|
|
|
const calendarsResult = await callApi(request, origin, "GET", "/api/v1/calendars");
|
|
if (calendarsResult.status !== 200 || !Array.isArray(calendarsResult.body)) {
|
|
return { error: calendarsResult };
|
|
}
|
|
|
|
const calendars = calendarsResult.body as Array<{ id?: string; name?: string }>;
|
|
const calendarName =
|
|
typeof args.calendarName === "string" ? args.calendarName.trim().toLowerCase() : "";
|
|
|
|
if (calendarName) {
|
|
const match = calendars.find(
|
|
(calendar) =>
|
|
typeof calendar.name === "string" && calendar.name.toLowerCase() === calendarName,
|
|
);
|
|
if (!match?.id) {
|
|
return {
|
|
error: {
|
|
status: 404,
|
|
body: {
|
|
error: `No calendar named "${args.calendarName}"`,
|
|
calendars: calendars.map((calendar) => ({ id: calendar.id, name: calendar.name })),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
return { calendarId: match.id };
|
|
}
|
|
|
|
const first = calendars[0];
|
|
if (!first?.id) {
|
|
return { error: { status: 404, body: { error: "No calendars found" } } };
|
|
}
|
|
return { calendarId: first.id };
|
|
}
|
|
|
|
function requireString(args: Record<string, unknown>, key: string): string {
|
|
const value = args[key];
|
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
throw new Error(`Missing required argument: ${key}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requireNumber(args: Record<string, unknown>, key: string): number {
|
|
const value = args[key];
|
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
throw new Error(`Missing required argument: ${key}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requireBoolean(args: Record<string, unknown>, key: string): boolean {
|
|
const value = args[key];
|
|
if (typeof value !== "boolean") {
|
|
throw new Error(`Missing required argument: ${key}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function getApiDocs(args: Record<string, unknown>): Promise<ApiCallResult> {
|
|
const { readFile } = await import("fs/promises");
|
|
const path = await import("path");
|
|
const specPath = path.join(process.cwd(), "docs", "api", "openapi.yaml");
|
|
const spec = await readFile(specPath, "utf8");
|
|
const search = typeof args.search === "string" ? args.search.trim().toLowerCase() : "";
|
|
|
|
if (!search) {
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
spec,
|
|
hint: "Pass search to filter paths, or use call_api with a /api/v1/* path.",
|
|
},
|
|
};
|
|
}
|
|
|
|
const lines = spec.split("\n");
|
|
const matches = lines.filter((line) => line.toLowerCase().includes(search));
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
search,
|
|
matchCount: matches.length,
|
|
matches: matches.slice(0, 100),
|
|
hint: "Use call_api with method and path from the matches above.",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function callApiFallback(
|
|
args: Record<string, unknown>,
|
|
request: Request,
|
|
origin: string,
|
|
): Promise<ApiCallResult> {
|
|
const method = requireString(args, "method").toUpperCase();
|
|
if (!["GET", "POST", "PATCH", "DELETE"].includes(method)) {
|
|
return { status: 400, body: { error: "method must be GET, POST, PATCH, or DELETE" } };
|
|
}
|
|
|
|
let apiPath = requireString(args, "path");
|
|
if (!apiPath.startsWith("/api/v1/")) {
|
|
return { status: 400, body: { error: "path must start with /api/v1/" } };
|
|
}
|
|
if (apiPath.includes("..")) {
|
|
return { status: 400, body: { error: "invalid path" } };
|
|
}
|
|
|
|
if (typeof args.query === "object" && args.query !== null && !Array.isArray(args.query)) {
|
|
const qs = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(args.query as Record<string, unknown>)) {
|
|
if (value !== undefined && value !== null) qs.set(key, String(value));
|
|
}
|
|
const query = qs.toString();
|
|
if (query) apiPath += `?${query}`;
|
|
}
|
|
|
|
const body =
|
|
typeof args.body === "object" && args.body !== null && !Array.isArray(args.body)
|
|
? (args.body as Record<string, unknown>)
|
|
: undefined;
|
|
|
|
return callApi(request, origin, method, apiPath, body);
|
|
}
|
|
|
|
async function callApi(
|
|
request: Request,
|
|
origin: string,
|
|
method: string,
|
|
path: string,
|
|
body?: Record<string, unknown>,
|
|
): Promise<ApiCallResult> {
|
|
const response = await fetch(`${origin}${path}`, {
|
|
method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
cookie: request.headers.get("cookie") ?? "",
|
|
},
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
|
|
const text = await response.text();
|
|
let parsed: unknown = null;
|
|
if (text) {
|
|
try {
|
|
parsed = JSON.parse(text);
|
|
} catch {
|
|
parsed = text;
|
|
}
|
|
}
|
|
|
|
return { status: response.status, body: parsed };
|
|
}
|