feat: journal dashboard widgets, agent polish, and edit-mode live previews
CI / checks (push) Failing after 2m7s
CI / build (push) Successful in 4m36s

Journal dashboard widgets and quick-add; rich-text quick-add dialogs.

Dashboard draft sync for live edit previews; assistant bubble + API tools.

Journal UX: stress slider, mood grid, query cap fix.
This commit is contained in:
ginnoir
2026-07-04 22:03:45 -05:00
parent 4a924a4107
commit a09747c314
76 changed files with 4594 additions and 611 deletions
@@ -0,0 +1,54 @@
export type AssistantChatMessage = {
role: "user" | "assistant";
content: string;
};
const STORAGE_VERSION = "v1";
const MAX_MESSAGES = 40;
function storageKey(userId: string) {
return `assistant-chat:${STORAGE_VERSION}:${userId}`;
}
function isValidMessage(value: unknown): value is AssistantChatMessage {
if (!value || typeof value !== "object") return false;
const row = value as Record<string, unknown>;
return (
(row.role === "user" || row.role === "assistant") &&
typeof row.content === "string" &&
row.content.trim().length > 0
);
}
export function loadAssistantChat(userId: string): AssistantChatMessage[] {
try {
const raw = localStorage.getItem(storageKey(userId));
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isValidMessage).slice(-MAX_MESSAGES);
} catch {
return [];
}
}
export function saveAssistantChat(userId: string, messages: AssistantChatMessage[]): void {
try {
const trimmed = messages.slice(-MAX_MESSAGES);
if (trimmed.length === 0) {
localStorage.removeItem(storageKey(userId));
return;
}
localStorage.setItem(storageKey(userId), JSON.stringify(trimmed));
} catch {
// Private browsing, quota exceeded, or disabled storage.
}
}
export function clearAssistantChat(userId: string): void {
try {
localStorage.removeItem(storageKey(userId));
} catch {
// Ignore storage errors.
}
}
@@ -0,0 +1,66 @@
import type { AgentProgressEvent } from "./server/progress";
type DoneEvent = Extract<AgentProgressEvent, { type: "done" }>;
export async function consumeAgentChatStream(
response: Response,
onEvent: (event: AgentProgressEvent) => void,
): Promise<DoneEvent> {
if (!response.ok) {
let message = "Assistant request failed";
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) message = payload.error;
} catch {
// ignore parse errors
}
throw new Error(message);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Assistant returned an empty stream");
const decoder = new TextDecoder();
let buffer = "";
let doneEvent: DoneEvent | null = null;
const handleEvent = (event: AgentProgressEvent) => {
onEvent(event);
if (event.type === "error") throw new Error(event.message);
if (event.type === "done") doneEvent = event;
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let boundary = buffer.indexOf("\n\n");
while (boundary !== -1) {
const chunk = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
parseSseChunk(chunk, handleEvent);
boundary = buffer.indexOf("\n\n");
}
}
if (buffer.trim()) {
parseSseChunk(buffer, handleEvent);
}
if (!doneEvent) {
throw new Error("Assistant stream ended without a final response");
}
return doneEvent;
}
function parseSseChunk(chunk: string, onEvent: (event: AgentProgressEvent) => void) {
for (const line of chunk.split("\n")) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice("data: ".length);
if (!payload) continue;
onEvent(JSON.parse(payload) as AgentProgressEvent);
}
}
@@ -0,0 +1,53 @@
"use client";
import { useState } from "react";
import { MessageCircle, X } from "lucide-react";
import { AssistantPanel } from "./assistant-panel";
type Props = {
configured: boolean;
userId: string;
};
export function AssistantBubble({ configured, userId }: Props) {
const [open, setOpen] = useState(false);
return (
<div className="assistant-bubble" data-open={open ? "true" : "false"}>
{open ? (
<div
className="assistant-bubble-panel"
role="dialog"
aria-label="Assistant"
aria-modal="false"
>
<div className="assistant-bubble-header">
<div>
<div className="serif text-[15px] font-medium tracking-tight">Assistant</div>
<div className="muted text-[11px]">Household helper</div>
</div>
<button
type="button"
className="assistant-bubble-close"
aria-label="Close assistant"
onClick={() => setOpen(false)}
>
<X className="size-4" />
</button>
</div>
<AssistantPanel key={userId} configured={configured} userId={userId} />
</div>
) : null}
<button
type="button"
className="assistant-bubble-trigger"
aria-label={open ? "Close assistant" : "Open assistant"}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
{open ? <X className="size-5" /> : <MessageCircle className="size-5" />}
</button>
</div>
);
}
@@ -1,134 +0,0 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { Loader2, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type ChatMessage = {
role: "user" | "assistant";
content: string;
};
type Props = {
configured: boolean;
};
export function AssistantChat({ configured }: Props) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const listRef = useRef<HTMLDivElement>(null);
function scrollToBottom() {
requestAnimationFrame(() => {
const node = listRef.current;
if (node) node.scrollTop = node.scrollHeight;
});
}
function sendMessage() {
const text = input.trim();
if (!text || isPending) return;
const nextMessages: ChatMessage[] = [...messages, { role: "user", content: text }];
setInput("");
setError(null);
setMessages(nextMessages);
scrollToBottom();
startTransition(async () => {
try {
const response = await fetch("/api/agent/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: nextMessages }),
});
const payload = (await response.json()) as {
error?: string;
message?: ChatMessage;
};
if (!response.ok) {
throw new Error(payload.error ?? "Assistant request failed");
}
if (!payload.message?.content) {
throw new Error("Assistant returned an empty response");
}
setMessages((current) => [...current, payload.message!]);
scrollToBottom();
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
}
});
}
return (
<div className="mx-auto flex w-full max-w-2xl min-h-[70vh] flex-col gap-4 min-w-0">
<div>
<h2 className="serif text-[22px] tracking-tight">Assistant</h2>
<p className="muted text-[13px] mt-1">
{configured
? "Connected to your homelab LLM. I can update lists, calendar, notes, and journal via the API."
: "LLM endpoint not configured — using the built-in mock provider for testing."}
</p>
</div>
<div
ref={listRef}
className="flex-1 overflow-y-auto rounded-[var(--r-md)] border-[0.5px] bg-[var(--card)] p-4 min-h-[320px] max-h-[60vh]"
style={{ borderColor: "var(--hair)" }}
>
{messages.length === 0 ? (
<p className="muted text-[13px]">
Try &quot;add milk to the shopping list&quot; or &quot;what&apos;s on the calendar this
week?&quot;
</p>
) : (
<div className="grid gap-3">
{messages.map((message, index) => (
<div
key={`${message.role}-${index}`}
className={`rounded-lg px-3 py-2 text-[13px] leading-relaxed whitespace-pre-wrap ${
message.role === "user"
? "ml-8 bg-[var(--shade)]"
: "mr-8 border-[0.5px] bg-[var(--card)]"
}`}
style={message.role === "assistant" ? { borderColor: "var(--hair)" } : undefined}
>
<div className="eyebrow mb-1">{message.role === "user" ? "You" : "Assistant"}</div>
{message.content}
</div>
))}
</div>
)}
</div>
{error ? <p className="text-[13px] text-destructive">{error}</p> : null}
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
sendMessage();
}}
>
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Ask the assistant…"
disabled={isPending}
aria-label="Message"
/>
<Button type="submit" disabled={isPending || !input.trim()}>
{isPending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
Send
</Button>
</form>
</div>
);
}
@@ -0,0 +1,194 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Loader2, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { consumeAgentChatStream } from "../assistant-chat-stream";
import {
clearAssistantChat,
loadAssistantChat,
saveAssistantChat,
type AssistantChatMessage,
} from "../assistant-chat-storage";
type Props = {
configured: boolean;
userId: string;
};
export function AssistantPanel({ configured, userId }: Props) {
const [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId));
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const [activityLabel, setActivityLabel] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
saveAssistantChat(userId, messages);
}, [messages, userId]);
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
function scrollToBottom() {
requestAnimationFrame(() => {
const node = listRef.current;
if (node) node.scrollTop = node.scrollHeight;
});
}
function clearChat() {
abortRef.current?.abort();
setMessages([]);
setError(null);
setActivityLabel(null);
setIsPending(false);
clearAssistantChat(userId);
}
async function sendMessage() {
const text = input.trim();
if (!text || isPending) return;
const nextMessages: AssistantChatMessage[] = [...messages, { role: "user", content: text }];
setInput("");
setError(null);
setMessages(nextMessages);
setIsPending(true);
setActivityLabel("Understanding your request…");
scrollToBottom();
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const response = await fetch("/api/agent/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: nextMessages, stream: true }),
signal: controller.signal,
});
const result = await consumeAgentChatStream(response, (event) => {
if (event.type === "thinking" || event.type === "tool" || event.type === "responding") {
setActivityLabel(event.label);
scrollToBottom();
}
});
if (!result.message.content) {
throw new Error("Assistant returned an empty response");
}
setMessages((current) => [...current, result.message]);
scrollToBottom();
} catch (err) {
if (err instanceof Error && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setIsPending(false);
setActivityLabel(null);
abortRef.current = null;
}
}
const showEmptyState = messages.length === 0 && !isPending;
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<p className="muted min-w-0 text-[12px] leading-relaxed">
{configured
? "Ask me to update lists, calendar, notes, or journal."
: "Mock provider active — set LLM_BASE_URL for your homelab model."}
</p>
{messages.length > 0 ? (
<button
type="button"
onClick={clearChat}
disabled={isPending}
className="shrink-0 text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
Clear
</button>
) : null}
</div>
<div
ref={listRef}
className="min-h-0 flex-1 overflow-y-auto rounded-[var(--r-md)] border-[0.5px] bg-[var(--shade)] p-3"
style={{ borderColor: "var(--hair)" }}
>
{showEmptyState ? (
<p className="muted text-[12px]">
Try &quot;add milk to the shopping list&quot; or &quot;what&apos;s on the calendar this
week?&quot;
</p>
) : (
<div className="grid gap-2">
{messages.map((message, index) => (
<div
key={`${message.role}-${index}`}
className={`rounded-lg px-2.5 py-2 text-[12.5px] leading-relaxed whitespace-pre-wrap ${
message.role === "user"
? "ml-6 bg-[var(--card)]"
: "mr-6 border-[0.5px] bg-[var(--card)]"
}`}
style={message.role === "assistant" ? { borderColor: "var(--hair)" } : undefined}
>
<div className="eyebrow mb-0.5 text-[10px]">
{message.role === "user" ? "You" : "Assistant"}
</div>
{message.content}
</div>
))}
{isPending ? (
<div
className="mr-6 rounded-lg border-[0.5px] bg-[var(--card)] px-2.5 py-2"
style={{ borderColor: "var(--hair)" }}
aria-live="polite"
aria-busy="true"
>
<div className="eyebrow mb-1 text-[10px]">Assistant</div>
<div className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
<Loader2 className="size-3.5 shrink-0 animate-spin" />
<span>{activityLabel ?? "Working…"}</span>
</div>
</div>
) : null}
</div>
)}
</div>
{error ? <p className="text-[12px] text-destructive">{error}</p> : null}
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
sendMessage();
}}
>
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Ask the assistant…"
disabled={isPending}
aria-label="Assistant message"
className="h-9"
/>
<Button type="submit" size="sm" disabled={isPending || !input.trim()} aria-label="Send">
{isPending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
</Button>
</form>
</div>
);
}
-1
View File
@@ -3,7 +3,6 @@ import type { ModuleManifest } from "../_core/module";
const manifest: ModuleManifest = {
id: "agent",
name: "Assistant",
nav: { href: "/assistant", label: "Assistant", icon: "message-circle" },
entities: [],
};
+19
View File
@@ -0,0 +1,19 @@
export type AgentProgressEvent =
| { type: "thinking"; label: string; round: number }
| { type: "tool"; name: string; label: string }
| { type: "responding"; label: string }
| {
type: "done";
message: { role: "assistant"; content: string };
toolCalls: { name: string; status: number }[];
}
| { type: "error"; message: string };
export function thinkingLabel(round: number): string {
if (round === 0) return "Understanding your request…";
return "Reviewing what I found…";
}
export function encodeSseEvent(event: AgentProgressEvent): string {
return `data: ${JSON.stringify(event)}\n\n`;
}
+13
View File
@@ -1,6 +1,8 @@
import { createLlmClient, type ChatMessage, type LlmClient } from "@/lib/llm";
import { AGENT_SYSTEM_PROMPT, AGENT_TOOLS } from "../tools";
import { describeToolActivity } from "../tool-labels";
import { createApiToolExecutor, type ToolExecutor } from "../tool-executor";
import { thinkingLabel, type AgentProgressEvent } from "./progress";
const MAX_TOOL_ROUNDS = 8;
@@ -19,14 +21,18 @@ export type AgentChatResult = {
toolCalls: AgentToolCallSummary[];
};
export type AgentProgressHandler = (event: AgentProgressEvent) => void;
export async function runAgentChat(options: {
messages: ClientChatMessage[];
request: Request;
llm?: LlmClient;
executeTool?: ToolExecutor;
onProgress?: AgentProgressHandler;
}): Promise<AgentChatResult> {
const llm = options.llm ?? createLlmClient();
const executeTool = options.executeTool ?? createApiToolExecutor(options.request);
const onProgress = options.onProgress;
const transcript: ChatMessage[] = [
{ role: "system", content: AGENT_SYSTEM_PROMPT },
@@ -41,6 +47,8 @@ export async function runAgentChat(options: {
const toolCalls: AgentToolCallSummary[] = [];
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
onProgress?.({ type: "thinking", label: thinkingLabel(round), round });
const completion = await llm.chatCompletion({
messages: transcript,
tools: AGENT_TOOLS,
@@ -50,6 +58,7 @@ export async function runAgentChat(options: {
transcript.push(assistantMessage);
if (!assistantMessage.tool_calls?.length) {
onProgress?.({ type: "responding", label: "Writing a reply…" });
return {
message: {
role: "assistant",
@@ -60,6 +69,9 @@ export async function runAgentChat(options: {
}
for (const toolCall of assistantMessage.tool_calls) {
const label = describeToolActivity(toolCall.function.name, toolCall.function.arguments);
onProgress?.({ type: "tool", name: toolCall.function.name, label });
let result: string;
let status: number;
@@ -86,6 +98,7 @@ export async function runAgentChat(options: {
}
}
onProgress?.({ type: "responding", label: "Wrapping up…" });
return {
message: {
role: "assistant",
+355 -6
View File
@@ -66,6 +66,38 @@ async function dispatchTool(
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": {
@@ -79,29 +111,265 @@ async function dispatchTool(
return callApi(request, origin, "GET", `/api/v1/events?${qs.toString()}`);
}
case "create_event": {
const body = {
const body: Record<string, unknown> = {
calendarId: requireString(args, "calendarId"),
title: requireString(args, "title"),
startAt: requireString(args, "startAt"),
endAt: requireString(args, "endAt"),
allDay: typeof args.allDay === "boolean" ? args.allDay : false,
location: typeof args.location === "string" ? args.location : undefined,
notes: typeof args.notes === "string" ? args.notes : undefined,
};
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 = {
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 "list_journal_entries":
return callApi(request, origin, "GET", "/api/v1/journal/entries");
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}` } };
}
@@ -115,6 +383,87 @@ function requireString(args: Record<string, unknown>, key: string): string {
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,
+134
View File
@@ -0,0 +1,134 @@
function parseArgs(argsJson: string): Record<string, unknown> {
if (!argsJson.trim()) return {};
try {
const parsed: unknown = JSON.parse(argsJson);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// ignore malformed tool args in UI copy
}
return {};
}
function str(args: Record<string, unknown>, key: string): string | undefined {
const value = args[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
export function describeToolActivity(name: string, argsJson = ""): string {
const args = parseArgs(argsJson);
switch (name) {
case "list_lists":
return str(args, "type") ? `Looking up your ${args.type} lists…` : "Looking up your lists…";
case "list_list_items":
return "Reading list items…";
case "add_list_item": {
const text = str(args, "text");
return text ? `Adding “${text}” to a list…` : "Adding an item to a list…";
}
case "update_list_item":
return typeof args.done === "boolean" && args.done
? "Marking a list item complete…"
: "Updating a list item…";
case "delete_list_item":
return "Removing a list item…";
case "create_list":
return str(args, "name") ? `Creating list “${args.name}”…` : "Creating a new list…";
case "update_list":
return "Updating a list…";
case "delete_list":
return "Deleting a list…";
case "list_calendars":
return "Looking up calendars…";
case "list_events":
return "Checking calendar events…";
case "create_event":
return str(args, "title") ? `Creating event “${args.title}”…` : "Creating a calendar event…";
case "update_event":
return "Updating a calendar event…";
case "delete_event":
return "Deleting a calendar event…";
case "create_calendar":
return str(args, "name") ? `Creating calendar “${args.name}”…` : "Creating a calendar…";
case "list_notes":
return "Looking up notes…";
case "create_note":
return str(args, "title") ? `Creating note “${args.title}”…` : "Creating a note…";
case "update_note":
return "Updating a note…";
case "delete_note":
return "Deleting a note…";
case "list_journal_entries":
return "Reading journal entries…";
case "create_journal_entry":
return "Saving a journal entry…";
case "update_journal_entry":
return "Updating a journal entry…";
case "delete_journal_entry":
return "Deleting a journal entry…";
case "list_bangs":
return "Checking bang counter…";
case "add_bang":
return "Recording a bang…";
case "update_bang":
return "Updating a bang entry…";
case "delete_bang":
return "Deleting a bang entry…";
case "list_garden_containers":
return "Looking up garden containers…";
case "list_garden_plants":
return "Looking up plants…";
case "get_garden_plant":
return "Loading plant details…";
case "create_garden_container":
return str(args, "name") ? `Adding container “${args.name}”…` : "Adding a garden container…";
case "update_garden_container":
return "Updating a garden container…";
case "delete_garden_container":
return "Removing a garden container…";
case "create_garden_plant":
return str(args, "name") ? `Adding plant “${args.name}”…` : "Adding a plant…";
case "update_garden_plant":
return "Updating a plant…";
case "delete_garden_plant":
return "Removing a plant…";
case "log_garden_care": {
const care = str(args, "careType");
return care ? `Logging ${care} care…` : "Logging plant care…";
}
case "list_garden_care_logs":
return "Reading care history…";
case "list_garden_care_schedules":
return "Checking care schedules…";
case "upsert_garden_care_schedule":
return "Updating a care schedule…";
case "delete_garden_care_schedule":
return "Removing a care schedule…";
case "toggle_garden_care_schedule":
return "Toggling a care schedule…";
case "push_overdue_garden_care":
return "Pushing overdue garden tasks…";
case "schedule_garden_care_on_calendar":
return "Adding garden care to calendar…";
case "list_shareable_entity_types":
return "Checking what can be shared…";
case "list_share_links":
return "Looking up share links…";
case "create_share_link":
return "Creating a share link…";
case "revoke_share_link":
return "Revoking a share link…";
case "get_api_docs":
return str(args, "search")
? `Reading API docs for “${args.search}”…`
: "Reading API documentation…";
case "call_api": {
const path = str(args, "path");
return path ? `Calling ${path}` : "Calling the API…";
}
default:
return `Running ${name.replaceAll("_", " ")}`;
}
}
+640 -2
View File
@@ -1,4 +1,7 @@
import type { AgentToolDefinition } from "@/lib/llm";
import { MOOD_CATALOG } from "@/modules/journal/mood-catalog";
const JOURNAL_MOOD_IDS = MOOD_CATALOG.map((mood) => mood.id).join(", ");
export const AGENT_TOOLS: AgentToolDefinition[] = [
{
@@ -46,6 +49,85 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
},
},
},
{
type: "function",
function: {
name: "update_list_item",
description:
"Update a list item — mark done/undone, rename, or change quantity. Use list_list_items first to resolve itemId.",
parameters: {
type: "object",
properties: {
listId: { type: "string", description: "UUID of the list" },
itemId: { type: "string", description: "UUID of the item" },
done: { type: "boolean", description: "Mark completed (true) or open (false)" },
text: { type: "string", description: "New item text" },
qty: { type: "string", description: "New quantity" },
},
required: ["listId", "itemId"],
},
},
},
{
type: "function",
function: {
name: "delete_list_item",
description: "Remove an item from a list permanently.",
parameters: {
type: "object",
properties: {
listId: { type: "string", description: "UUID of the list" },
itemId: { type: "string", description: "UUID of the item" },
},
required: ["listId", "itemId"],
},
},
},
{
type: "function",
function: {
name: "create_list",
description: "Create a new list (e.g. type shopping or tasks).",
parameters: {
type: "object",
properties: {
type: { type: "string", description: "List type, e.g. shopping or tasks" },
name: { type: "string", description: "Display name" },
},
required: ["type", "name"],
},
},
},
{
type: "function",
function: {
name: "update_list",
description: "Rename a list or archive/unarchive it.",
parameters: {
type: "object",
properties: {
listId: { type: "string" },
name: { type: "string" },
archived: { type: "boolean" },
},
required: ["listId"],
},
},
},
{
type: "function",
function: {
name: "delete_list",
description: "Permanently delete a list and all its items.",
parameters: {
type: "object",
properties: {
listId: { type: "string" },
},
required: ["listId"],
},
},
},
{
type: "function",
function: {
@@ -88,11 +170,66 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
allDay: { type: "boolean" },
location: { type: "string" },
notes: { type: "string" },
remindMinutesBefore: {
type: "number",
description: "Optional reminder N minutes before start",
},
},
required: ["calendarId", "title", "startAt", "endAt"],
},
},
},
{
type: "function",
function: {
name: "update_event",
description: "Update or reschedule a calendar event.",
parameters: {
type: "object",
properties: {
eventId: { type: "string" },
title: { type: "string" },
startAt: { type: "string" },
endAt: { type: "string" },
allDay: { type: "boolean" },
location: { type: "string" },
notes: { type: "string" },
remindMinutesBefore: { type: "number" },
},
required: ["eventId"],
},
},
},
{
type: "function",
function: {
name: "delete_event",
description: "Delete a calendar event.",
parameters: {
type: "object",
properties: {
eventId: { type: "string" },
},
required: ["eventId"],
},
},
},
{
type: "function",
function: {
name: "create_calendar",
description: "Create a new calendar.",
parameters: {
type: "object",
properties: {
name: { type: "string" },
color: { type: "string" },
visibility: { type: "string", description: "private or household" },
},
required: ["name"],
},
},
},
{
type: "function",
function: {
@@ -112,11 +249,44 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
title: { type: "string" },
body: { type: "string" },
pinned: { type: "boolean" },
remindAt: { type: "string", description: "ISO 8601 reminder datetime, or null to clear" },
},
required: ["title"],
},
},
},
{
type: "function",
function: {
name: "update_note",
description: "Update a note's title, body, pin state, or reminder.",
parameters: {
type: "object",
properties: {
noteId: { type: "string" },
title: { type: "string" },
body: { type: "string" },
pinned: { type: "boolean" },
remindAt: { type: "string", description: "ISO 8601 datetime or null" },
},
required: ["noteId"],
},
},
},
{
type: "function",
function: {
name: "delete_note",
description: "Delete a note.",
parameters: {
type: "object",
properties: {
noteId: { type: "string" },
},
required: ["noteId"],
},
},
},
{
type: "function",
function: {
@@ -130,10 +300,478 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
},
},
},
{
type: "function",
function: {
name: "create_journal_entry",
description:
"Create a journal entry for the current user. Supports moods, stress (1-10), and pillsTaken.",
parameters: {
type: "object",
properties: {
recordedAt: { type: "string", description: "ISO 8601 date/time for the entry" },
title: { type: "string" },
body: { type: "string" },
moods: {
type: "array",
items: { type: "string" },
description: `Mood ids: ${JOURNAL_MOOD_IDS}`,
},
stress: { type: "number", description: "Stress level 1-10" },
pillsTaken: { type: "boolean", description: "Whether pills were taken" },
},
required: ["recordedAt"],
},
},
},
{
type: "function",
function: {
name: "update_journal_entry",
description: "Update a journal entry including moods, stress, and pillsTaken.",
parameters: {
type: "object",
properties: {
entryId: { type: "string" },
recordedAt: { type: "string" },
title: { type: "string" },
body: { type: "string" },
moods: {
type: "array",
items: { type: "string" },
description: `Mood ids: ${JOURNAL_MOOD_IDS}`,
},
stress: { type: "number" },
pillsTaken: { type: "boolean" },
},
required: ["entryId"],
},
},
},
{
type: "function",
function: {
name: "delete_journal_entry",
description: "Delete a journal entry.",
parameters: {
type: "object",
properties: {
entryId: { type: "string" },
},
required: ["entryId"],
},
},
},
{
type: "function",
function: {
name: "list_bangs",
description: "Get bang counter stats and recent entries for the household.",
parameters: {
type: "object",
properties: {
limit: { type: "number", description: "Recent entries to include (default 10)" },
},
},
},
},
{
type: "function",
function: {
name: "add_bang",
description: "Record a new bang. Date defaults to today if omitted.",
parameters: {
type: "object",
properties: {
occurredOn: { type: "string", description: "YYYY-MM-DD" },
},
},
},
},
{
type: "function",
function: {
name: "update_bang",
description: "Change the date of an existing bang entry.",
parameters: {
type: "object",
properties: {
bangId: { type: "string" },
occurredOn: { type: "string", description: "YYYY-MM-DD" },
},
required: ["bangId", "occurredOn"],
},
},
},
{
type: "function",
function: {
name: "delete_bang",
description: "Delete a bang entry.",
parameters: {
type: "object",
properties: {
bangId: { type: "string" },
},
required: ["bangId"],
},
},
},
{
type: "function",
function: {
name: "list_garden_containers",
description: "List garden containers (pots, beds, etc.).",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "list_garden_plants",
description: "List plants. Optionally filter by containerId.",
parameters: {
type: "object",
properties: {
containerId: { type: "string", description: "UUID of container" },
},
},
},
},
{
type: "function",
function: {
name: "get_garden_plant",
description: "Get full detail for a plant by id.",
parameters: {
type: "object",
properties: { plantId: { type: "string" } },
required: ["plantId"],
},
},
},
{
type: "function",
function: {
name: "create_garden_container",
description: "Create a garden container.",
parameters: {
type: "object",
properties: {
name: { type: "string" },
type: { type: "string", description: "e.g. pot, bed, greenhouse" },
locationNotes: { type: "string" },
},
required: ["name"],
},
},
},
{
type: "function",
function: {
name: "update_garden_container",
description: "Update a garden container.",
parameters: {
type: "object",
properties: {
containerId: { type: "string" },
name: { type: "string" },
type: { type: "string" },
locationNotes: { type: "string" },
},
required: ["containerId"],
},
},
},
{
type: "function",
function: {
name: "delete_garden_container",
description: "Delete a garden container.",
parameters: {
type: "object",
properties: { containerId: { type: "string" } },
required: ["containerId"],
},
},
},
{
type: "function",
function: {
name: "create_garden_plant",
description: "Add a new plant to the garden.",
parameters: {
type: "object",
properties: {
name: { type: "string" },
category: { type: "string" },
containerId: { type: "string" },
healthStatus: { type: "string" },
notes: { type: "string" },
},
required: ["name"],
},
},
},
{
type: "function",
function: {
name: "update_garden_plant",
description: "Update plant details (name, health, container, notes, etc.).",
parameters: {
type: "object",
properties: {
plantId: { type: "string" },
name: { type: "string" },
category: { type: "string" },
containerId: { type: "string" },
healthStatus: { type: "string" },
notes: { type: "string" },
},
required: ["plantId"],
},
},
},
{
type: "function",
function: {
name: "delete_garden_plant",
description: "Delete a plant.",
parameters: {
type: "object",
properties: { plantId: { type: "string" } },
required: ["plantId"],
},
},
},
{
type: "function",
function: {
name: "log_garden_care",
description: "Record care performed on a plant (water, fertilize, prune, etc.).",
parameters: {
type: "object",
properties: {
plantId: { type: "string" },
careType: { type: "string", description: "e.g. water, fertilize, prune" },
notes: { type: "string" },
performedAt: { type: "string", description: "ISO 8601; defaults to now" },
},
required: ["plantId", "careType"],
},
},
},
{
type: "function",
function: {
name: "list_garden_care_logs",
description: "List recent care logs for a plant.",
parameters: {
type: "object",
properties: {
plantId: { type: "string" },
limit: { type: "number" },
},
required: ["plantId"],
},
},
},
{
type: "function",
function: {
name: "list_garden_care_schedules",
description: "List care schedules (recurring reminders) for a plant.",
parameters: {
type: "object",
properties: { plantId: { type: "string" } },
required: ["plantId"],
},
},
},
{
type: "function",
function: {
name: "upsert_garden_care_schedule",
description: "Create or update a recurring care schedule for a plant.",
parameters: {
type: "object",
properties: {
plantId: { type: "string" },
careType: { type: "string" },
intervalDays: { type: "number", description: "Days between care" },
enabled: { type: "boolean" },
},
required: ["plantId", "careType", "intervalDays"],
},
},
},
{
type: "function",
function: {
name: "delete_garden_care_schedule",
description: "Delete a care schedule by schedule id.",
parameters: {
type: "object",
properties: { scheduleId: { type: "string" } },
required: ["scheduleId"],
},
},
},
{
type: "function",
function: {
name: "toggle_garden_care_schedule",
description: "Enable or disable a care schedule.",
parameters: {
type: "object",
properties: {
scheduleId: { type: "string" },
enabled: { type: "boolean" },
},
required: ["scheduleId", "enabled"],
},
},
},
{
type: "function",
function: {
name: "push_overdue_garden_care",
description: "Push overdue garden care items to the household task list.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "schedule_garden_care_on_calendar",
description: "Add a care schedule's next due date as a calendar event.",
parameters: {
type: "object",
properties: {
scheduleId: { type: "string" },
calendarId: { type: "string" },
reminderMinutesBefore: { type: "number" },
},
required: ["scheduleId", "calendarId"],
},
},
},
{
type: "function",
function: {
name: "list_shareable_entity_types",
description: "List entity types that can be shared via public links.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "list_share_links",
description: "List active share links. Optionally filter by entityType and entityId.",
parameters: {
type: "object",
properties: {
entityType: { type: "string" },
entityId: { type: "string" },
},
},
},
},
{
type: "function",
function: {
name: "create_share_link",
description:
"Create a temporary public share link for an entity. Entity types: calendar, calendar.event, list, note, garden.plant, garden.container.",
parameters: {
type: "object",
properties: {
entityType: { type: "string" },
entityId: { type: "string" },
expiresAt: { type: "string", description: "ISO 8601 expiry, or omit for no expiry" },
write: { type: "boolean", description: "Allow write via link (default false)" },
},
required: ["entityType", "entityId"],
},
},
},
{
type: "function",
function: {
name: "revoke_share_link",
description: "Revoke an active share link by its id.",
parameters: {
type: "object",
properties: { linkId: { type: "string" } },
required: ["linkId"],
},
},
},
{
type: "function",
function: {
name: "get_api_docs",
description:
"Read famapp REST API documentation (OpenAPI). Use when unsure which endpoint to call or no dedicated tool exists. Pass search to filter relevant paths.",
parameters: {
type: "object",
properties: {
search: {
type: "string",
description: "Optional keyword to filter paths (e.g. garden, share, journal)",
},
},
},
},
},
{
type: "function",
function: {
name: "call_api",
description:
"Fallback: call any /api/v1/* endpoint directly. Use get_api_docs first when unsure. Only household-scoped v1 routes.",
parameters: {
type: "object",
properties: {
method: {
type: "string",
enum: ["GET", "POST", "PATCH", "DELETE"],
description: "HTTP method",
},
path: {
type: "string",
description: "API path starting with /api/v1/ (e.g. /api/v1/notes)",
},
query: {
type: "object",
description: "Optional query string parameters as key-value pairs",
},
body: {
type: "object",
description: "Optional JSON body for POST/PATCH",
},
},
required: ["method", "path"],
},
},
},
];
export const AGENT_SYSTEM_PROMPT = `You are the famapp household assistant. Help Matt and his wife manage calendar events, shopping and task lists, notes, and personal journal entries.
export const AGENT_SYSTEM_PROMPT = `You are the famapp household assistant. Help Matt and his wife manage calendar events, shopping and task lists, notes, journal entries, garden plants, share links, and the bang counter.
Use the provided tools to read and update data. Prefer calling tools instead of guessing. Be concise and friendly.
When adding shopping items, resolve the shopping list id via list_lists if needed. Use ISO 8601 datetimes for calendar tools.`;
When no dedicated tool fits, or you are unsure how to do something:
1. Call get_api_docs with a relevant search term to find the right /api/v1/* endpoint.
2. Call call_api with the documented method, path, query, and body.
Lists: resolve list ids via list_lists. To complete items, list_list_items then update_list_item with done: true.
Journal: per-user private entries. Valid mood ids: ${JOURNAL_MOOD_IDS}. stress is 1-10. pillsTaken is boolean.
Garden: care types are free text (water, fertilize, prune, etc.). Use list_garden_plants to find plant ids.
Sharing: journal entries are not shareable. Shareable types: calendar, calendar.event, list, note, garden.plant, garden.container.
Calendar: use ISO 8601 datetimes. Bang dates use YYYY-MM-DD.`;