diff --git a/apps/mana/apps/web/src/lib/data/tools/executor.ts b/apps/mana/apps/web/src/lib/data/tools/executor.ts new file mode 100644 index 000000000..b16dee3d2 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/tools/executor.ts @@ -0,0 +1,53 @@ +/** + * Tool Executor — Validates parameters and runs a tool by name. + */ + +import { getTool } from './registry'; +import type { ToolResult } from './types'; + +export async function executeTool( + name: string, + params: Record +): Promise { + const tool = getTool(name); + if (!tool) { + return { success: false, message: `Unknown tool: ${name}` }; + } + + // Validate required parameters + for (const p of tool.parameters) { + if (p.required && (params[p.name] === undefined || params[p.name] === null)) { + return { success: false, message: `Missing required parameter: ${p.name}` }; + } + } + + // Validate types + for (const p of tool.parameters) { + const val = params[p.name]; + if (val === undefined || val === null) continue; + + if (p.type === 'number' && typeof val !== 'number') { + const num = Number(val); + if (isNaN(num)) { + return { success: false, message: `Parameter ${p.name} must be a number` }; + } + params[p.name] = num; + } + if (p.type === 'boolean' && typeof val !== 'boolean') { + params[p.name] = val === 'true' || val === true; + } + if (p.enum && !p.enum.includes(String(val))) { + return { + success: false, + message: `Parameter ${p.name} must be one of: ${p.enum.join(', ')}`, + }; + } + } + + try { + return await tool.execute(params); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { success: false, message: `Tool execution failed: ${msg}` }; + } +} diff --git a/apps/mana/apps/web/src/lib/data/tools/index.ts b/apps/mana/apps/web/src/lib/data/tools/index.ts new file mode 100644 index 000000000..601a53618 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/tools/index.ts @@ -0,0 +1,3 @@ +export { registerTools, getTools, getTool, getToolsForModule, getToolsForLlm } from './registry'; +export { executeTool } from './executor'; +export type { ModuleTool, ToolParameter, ToolResult, LlmFunctionSchema } from './types'; diff --git a/apps/mana/apps/web/src/lib/data/tools/init.ts b/apps/mana/apps/web/src/lib/data/tools/init.ts new file mode 100644 index 000000000..0caf514c6 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/tools/init.ts @@ -0,0 +1,23 @@ +/** + * Tool initialization — Registers all module tools. + * Call once at app startup. + */ + +import { registerTools } from './registry'; +import { todoTools } from '$lib/modules/todo/tools'; +import { calendarTools } from '$lib/modules/calendar/tools'; +import { drinkTools } from '$lib/modules/drink/tools'; +import { nutriphiTools } from '$lib/modules/nutriphi/tools'; +import { placesTools } from '$lib/modules/places/tools'; + +let initialized = false; + +export function initTools(): void { + if (initialized) return; + registerTools(todoTools); + registerTools(calendarTools); + registerTools(drinkTools); + registerTools(nutriphiTools); + registerTools(placesTools); + initialized = true; +} diff --git a/apps/mana/apps/web/src/lib/data/tools/registry.ts b/apps/mana/apps/web/src/lib/data/tools/registry.ts new file mode 100644 index 000000000..df1514030 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/tools/registry.ts @@ -0,0 +1,53 @@ +/** + * Tool Registry — Collects ModuleTools and generates LLM schemas. + */ + +import type { ModuleTool, LlmFunctionSchema } from './types'; + +const tools: ModuleTool[] = []; + +/** Register tools from a module. Call once per module at init. */ +export function registerTools(moduleTools: ModuleTool[]): void { + for (const tool of moduleTools) { + if (!tools.some((t) => t.name === tool.name)) { + tools.push(tool); + } + } +} + +/** Get all registered tools. */ +export function getTools(): readonly ModuleTool[] { + return tools; +} + +/** Get a tool by name. */ +export function getTool(name: string): ModuleTool | undefined { + return tools.find((t) => t.name === name); +} + +/** Get tools for a specific module. */ +export function getToolsForModule(module: string): ModuleTool[] { + return tools.filter((t) => t.module === module); +} + +/** Generate LLM function-calling schemas for all registered tools. */ +export function getToolsForLlm(): LlmFunctionSchema[] { + return tools.map((t) => ({ + name: t.name, + description: t.description, + parameters: { + type: 'object' as const, + properties: Object.fromEntries( + t.parameters.map((p) => [ + p.name, + { + type: p.type, + description: p.description, + ...(p.enum ? { enum: p.enum } : {}), + }, + ]) + ), + required: t.parameters.filter((p) => p.required).map((p) => p.name), + }, + })); +} diff --git a/apps/mana/apps/web/src/lib/data/tools/types.ts b/apps/mana/apps/web/src/lib/data/tools/types.ts new file mode 100644 index 000000000..9fea87ac5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/tools/types.ts @@ -0,0 +1,52 @@ +/** + * Tool Layer types — Standardized LLM access to module operations. + * + * Each module exports a ModuleTool[] array. The registry collects them + * and generates LLM function-calling schemas. The executor validates + * parameters and runs the tool. + */ + +export interface ModuleTool { + /** Unique tool name, e.g. 'create_task', 'log_drink' */ + name: string; + /** Source module, e.g. 'todo', 'drink' */ + module: string; + /** Human-readable description for the LLM function schema */ + description: string; + /** Parameter definitions */ + parameters: ToolParameter[]; + /** Execute the tool. Params are pre-validated by the executor. */ + execute: (params: Record) => Promise; +} + +export interface ToolParameter { + name: string; + type: 'string' | 'number' | 'boolean'; + description: string; + required: boolean; + enum?: string[]; +} + +export interface ToolResult { + success: boolean; + data?: unknown; + /** Human-readable confirmation message */ + message: string; +} + +/** JSON Schema for LLM function calling */ +export interface LlmFunctionSchema { + name: string; + description: string; + parameters: { + type: 'object'; + properties: Record; + required: string[]; + }; +} + +export interface LlmPropertySchema { + type: string; + description: string; + enum?: string[]; +} diff --git a/apps/mana/apps/web/src/lib/modules/calendar/tools.ts b/apps/mana/apps/web/src/lib/modules/calendar/tools.ts new file mode 100644 index 000000000..d1b74ae03 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/calendar/tools.ts @@ -0,0 +1,84 @@ +/** + * Calendar Tools — LLM-accessible operations for calendar events. + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import { eventsStore } from './stores/events.svelte'; +import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; +import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; + +export const calendarTools: ModuleTool[] = [ + { + name: 'create_event', + module: 'calendar', + description: 'Erstellt einen neuen Kalender-Termin', + parameters: [ + { name: 'title', type: 'string', description: 'Titel des Termins', required: true }, + { name: 'startTime', type: 'string', description: 'Startzeit (ISO 8601)', required: true }, + { name: 'endTime', type: 'string', description: 'Endzeit (ISO 8601)', required: true }, + { name: 'isAllDay', type: 'boolean', description: 'Ganztaegig', required: false }, + { name: 'location', type: 'string', description: 'Ort', required: false }, + { name: 'description', type: 'string', description: 'Beschreibung', required: false }, + ], + async execute(params) { + // Find default calendar + const calendars = await db.table('calendars').toArray(); + const defaultCal = + calendars.find((c: Record) => !c.deletedAt && c.isDefault) ?? + calendars.find((c: Record) => !c.deletedAt); + if (!defaultCal) { + return { success: false, message: 'Kein Kalender vorhanden' }; + } + + const result = await eventsStore.createEvent({ + calendarId: (defaultCal as Record).id as string, + title: params.title as string, + startTime: params.startTime as string, + endTime: params.endTime as string, + isAllDay: (params.isAllDay as boolean) ?? false, + location: params.location as string | undefined, + description: params.description as string | undefined, + }); + return { + success: result.success, + data: result.data, + message: result.success + ? `Termin "${params.title}" erstellt` + : (result.error ?? 'Fehler beim Erstellen'), + }; + }, + }, + { + name: 'get_todays_events', + module: 'calendar', + description: 'Gibt alle Termine fuer heute zurueck', + parameters: [], + async execute() { + const today = new Date().toISOString().split('T')[0]; + const blocks = await db + .table('timeBlocks') + .where('startDate') + .between(`${today}T00:00:00`, `${today}T23:59:59\uffff`) + .toArray(); + const eventBlocks = blocks.filter( + (b) => !b.deletedAt && b.type === 'event' && b.sourceModule === 'calendar' + ); + const decrypted = await decryptRecords('timeBlocks', eventBlocks); + const events = decrypted + .sort((a, b) => (a.startDate as string).localeCompare(b.startDate as string)) + .map((b) => ({ + id: b.sourceId, + title: b.title, + startTime: b.startDate, + endTime: b.endDate, + allDay: b.allDay, + })); + return { + success: true, + data: events, + message: `${events.length} Termine heute`, + }; + }, + }, +]; diff --git a/apps/mana/apps/web/src/lib/modules/drink/tools.ts b/apps/mana/apps/web/src/lib/modules/drink/tools.ts new file mode 100644 index 000000000..8eaf44327 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/drink/tools.ts @@ -0,0 +1,95 @@ +/** + * Drink Tools — LLM-accessible operations for beverage tracking. + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import { drinkStore } from './stores/drink.svelte'; +import { drinkEntryTable } from './collections'; +import { decryptRecords } from '$lib/data/crypto'; +import { DEFAULT_DAILY_GOAL_ML, type DrinkType, type LocalDrinkEntry } from './types'; + +export const drinkTools: ModuleTool[] = [ + { + name: 'log_drink', + module: 'drink', + description: 'Loggt ein Getraenk (Wasser, Kaffee, Tee, etc.)', + parameters: [ + { + name: 'drinkType', + type: 'string', + description: 'Art des Getraenks', + required: true, + enum: ['water', 'coffee', 'tea', 'juice', 'alcohol', 'smoothie', 'soda', 'other'], + }, + { name: 'quantityMl', type: 'number', description: 'Menge in Milliliter', required: true }, + { + name: 'name', + type: 'string', + description: 'Name (z.B. "Latte Macchiato")', + required: false, + }, + ], + async execute(params) { + const entry = await drinkStore.logDrink({ + name: (params.name as string) ?? (params.drinkType as string), + drinkType: params.drinkType as DrinkType, + quantityMl: params.quantityMl as number, + }); + return { + success: true, + data: entry, + message: `${params.quantityMl}ml ${params.name ?? params.drinkType} geloggt`, + }; + }, + }, + { + name: 'get_drink_progress', + module: 'drink', + description: 'Gibt den heutigen Trink-Fortschritt zurueck (Wasser, Kaffee, gesamt)', + parameters: [], + async execute() { + const today = new Date().toISOString().split('T')[0]; + const all = await drinkEntryTable.toArray(); + const todayEntries = all.filter((e) => !e.deletedAt && e.date === today); + const decrypted = await decryptRecords('drinkEntries', todayEntries); + + let waterMl = 0; + let coffeeMl = 0; + let coffeeCount = 0; + let totalMl = 0; + for (const d of decrypted) { + const ml = d.quantityMl ?? 0; + totalMl += ml; + if (d.drinkType === 'water') waterMl += ml; + if (d.drinkType === 'coffee') { + coffeeMl += ml; + coffeeCount++; + } + } + + return { + success: true, + data: { + water: { + ml: waterMl, + goal: DEFAULT_DAILY_GOAL_ML, + percent: Math.round((waterMl / DEFAULT_DAILY_GOAL_ML) * 100), + }, + coffee: { ml: coffeeMl, count: coffeeCount }, + total: { ml: totalMl, count: decrypted.length }, + }, + message: `Wasser: ${waterMl}/${DEFAULT_DAILY_GOAL_ML}ml (${Math.round((waterMl / DEFAULT_DAILY_GOAL_ML) * 100)}%), ${decrypted.length} Getraenke gesamt`, + }; + }, + }, + { + name: 'undo_last_drink', + module: 'drink', + description: 'Macht den letzten Getraenk-Eintrag rueckgaengig', + parameters: [], + async execute() { + await drinkStore.undoLastEntry(); + return { success: true, message: 'Letzter Eintrag rueckgaengig gemacht' }; + }, + }, +]; diff --git a/apps/mana/apps/web/src/lib/modules/nutriphi/tools.ts b/apps/mana/apps/web/src/lib/modules/nutriphi/tools.ts new file mode 100644 index 000000000..1f43e935e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/nutriphi/tools.ts @@ -0,0 +1,80 @@ +/** + * Nutriphi Tools — LLM-accessible operations for nutrition tracking. + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import { mealMutations } from './mutations'; +import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; +import { getDailySummary, toMealWithNutrition } from './queries'; +import type { LocalMeal, MealType, LocalGoal } from './types'; + +export const nutriphiTools: ModuleTool[] = [ + { + name: 'log_meal', + module: 'nutriphi', + description: 'Loggt eine Mahlzeit mit optionalen Naehrwerten', + parameters: [ + { + name: 'mealType', + type: 'string', + description: 'Art der Mahlzeit', + required: true, + enum: ['breakfast', 'lunch', 'dinner', 'snack'], + }, + { + name: 'description', + type: 'string', + description: 'Beschreibung der Mahlzeit', + required: true, + }, + { name: 'calories', type: 'number', description: 'Kalorien (kcal)', required: false }, + { name: 'protein', type: 'number', description: 'Protein (g)', required: false }, + ], + async execute(params) { + const nutrition = + params.calories || params.protein + ? { + calories: (params.calories as number) ?? 0, + protein: (params.protein as number) ?? 0, + carbohydrates: 0, + fat: 0, + fiber: 0, + sugar: 0, + } + : undefined; + + const meal = await mealMutations.create({ + mealType: params.mealType as MealType, + description: params.description as string, + nutrition, + }); + return { + success: true, + data: meal, + message: `${params.mealType} geloggt: "${params.description}"${nutrition ? ` (${nutrition.calories} kcal)` : ''}`, + }; + }, + }, + { + name: 'get_nutrition_summary', + module: 'nutriphi', + description: + 'Gibt die heutige Ernaehrungs-Zusammenfassung zurueck (Mahlzeiten, Kalorien, Protein)', + parameters: [], + async execute() { + const allMeals = await db.table('meals').toArray(); + const active = allMeals.filter((m) => !m.deletedAt); + const decrypted = await decryptRecords('meals', active); + const meals = decrypted.map(toMealWithNutrition); + const goals = await db.table('goals').toArray(); + const activeGoal = goals.find((g) => !g.deletedAt) ?? null; + const summary = getDailySummary(meals, new Date(), activeGoal); + return { + success: true, + data: summary, + message: `${summary.meals.length} Mahlzeiten, ${summary.progress.calories.current}/${summary.progress.calories.target} kcal`, + }; + }, + }, +]; diff --git a/apps/mana/apps/web/src/lib/modules/places/tools.ts b/apps/mana/apps/web/src/lib/modules/places/tools.ts new file mode 100644 index 000000000..b2947392b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/places/tools.ts @@ -0,0 +1,108 @@ +/** + * Places Tools — LLM-accessible operations for location tracking. + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import { placesStore } from './stores/places.svelte'; +import { trackingStore } from './stores/tracking.svelte'; +import { placeTable } from './collections'; +import { decryptRecords } from '$lib/data/crypto'; +import { toPlace } from './queries'; +import type { LocalPlace, PlaceCategory } from './types'; + +export const placesTools: ModuleTool[] = [ + { + name: 'create_place', + module: 'places', + description: 'Erstellt einen neuen Ort', + parameters: [ + { name: 'name', type: 'string', description: 'Name des Ortes', required: true }, + { name: 'latitude', type: 'number', description: 'Breitengrad', required: true }, + { name: 'longitude', type: 'number', description: 'Laengengrad', required: true }, + { + name: 'category', + type: 'string', + description: 'Kategorie', + required: false, + enum: [ + 'home', + 'work', + 'food', + 'shopping', + 'sport', + 'culture', + 'nature', + 'transport', + 'health', + 'education', + 'nightlife', + 'other', + ], + }, + { name: 'address', type: 'string', description: 'Adresse', required: false }, + ], + async execute(params) { + const place = await placesStore.createPlace({ + name: params.name as string, + latitude: params.latitude as number, + longitude: params.longitude as number, + category: params.category as PlaceCategory | undefined, + address: params.address as string | undefined, + }); + return { success: true, data: place, message: `Ort "${params.name}" erstellt` }; + }, + }, + { + name: 'record_visit', + module: 'places', + description: 'Registriert einen Besuch an einem bekannten Ort', + parameters: [{ name: 'placeId', type: 'string', description: 'ID des Ortes', required: true }], + async execute(params) { + await placesStore.recordVisit(params.placeId as string); + return { success: true, message: 'Besuch registriert' }; + }, + }, + { + name: 'get_places', + module: 'places', + description: 'Gibt alle gespeicherten Orte zurueck', + parameters: [], + async execute() { + const all = await placeTable.toArray(); + const active = all.filter((p) => !p.deletedAt && !p.isArchived); + const decrypted = await decryptRecords('places', active); + const places = decrypted.map(toPlace); + return { + success: true, + data: places.map((p) => ({ + id: p.id, + name: p.name, + category: p.category, + visitCount: p.visitCount, + })), + message: `${places.length} Orte gespeichert`, + }; + }, + }, + { + name: 'get_current_location', + module: 'places', + description: 'Gibt die aktuelle GPS-Position zurueck (erfordert Standort-Berechtigung)', + parameters: [], + async execute() { + const pos = await trackingStore.getCurrentPosition(); + if (!pos) { + return { success: false, message: 'Standort nicht verfuegbar' }; + } + return { + success: true, + data: { + latitude: pos.coords.latitude, + longitude: pos.coords.longitude, + accuracy: pos.coords.accuracy, + }, + message: `Standort: ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`, + }; + }, + }, +]; diff --git a/apps/mana/apps/web/src/lib/modules/todo/tools.ts b/apps/mana/apps/web/src/lib/modules/todo/tools.ts new file mode 100644 index 000000000..5c8bd4757 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/todo/tools.ts @@ -0,0 +1,73 @@ +/** + * Todo Tools — LLM-accessible operations for the task module. + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import { tasksStore } from './stores/tasks.svelte'; +import { taskTable } from './collections'; +import { toTask, getTaskStats } from './queries'; +import { decryptRecords } from '$lib/data/crypto'; +import type { LocalTask } from './types'; + +export const todoTools: ModuleTool[] = [ + { + name: 'create_task', + module: 'todo', + description: 'Erstellt einen neuen Task mit optionalem Faelligkeitsdatum und Prioritaet', + parameters: [ + { name: 'title', type: 'string', description: 'Titel des Tasks', required: true }, + { + name: 'dueDate', + type: 'string', + description: 'Faelligkeitsdatum (YYYY-MM-DD)', + required: false, + }, + { + name: 'priority', + type: 'string', + description: 'Prioritaet', + required: false, + enum: ['low', 'medium', 'high'], + }, + { name: 'description', type: 'string', description: 'Beschreibung', required: false }, + ], + async execute(params) { + const task = await tasksStore.createTask({ + title: params.title as string, + dueDate: params.dueDate as string | undefined, + priority: (params.priority as 'low' | 'medium' | 'high') ?? undefined, + description: params.description as string | undefined, + }); + return { success: true, data: task, message: `Task "${task.title}" erstellt` }; + }, + }, + { + name: 'complete_task', + module: 'todo', + description: 'Markiert einen Task als erledigt', + parameters: [{ name: 'taskId', type: 'string', description: 'ID des Tasks', required: true }], + async execute(params) { + await tasksStore.completeTask(params.taskId as string); + return { success: true, message: 'Task erledigt' }; + }, + }, + { + name: 'get_task_stats', + module: 'todo', + description: + 'Gibt Statistiken ueber alle Tasks zurueck (total, erledigt, ueberfaellig, heute faellig)', + parameters: [], + async execute() { + const all = await taskTable.toArray(); + const active = all.filter((t) => !t.deletedAt); + const decrypted = await decryptRecords('tasks', active); + const tasks = decrypted.map(toTask); + const stats = getTaskStats(tasks); + return { + success: true, + data: stats, + message: `${stats.total} Tasks (${stats.completed} erledigt, ${stats.overdue} ueberfaellig)`, + }; + }, + }, +]; diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 94928af22..4343a1227 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -6,6 +6,7 @@ import { createReminderScheduler } from '@mana/shared-stores'; import { todoReminderSource } from '$lib/modules/todo/reminder-source'; import { startEventStore, stopEventStore } from '$lib/data/events/event-store'; + import { initTools } from '$lib/data/tools/init'; import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte'; import SessionWarning from '$lib/components/SessionWarning.svelte'; import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte'; @@ -419,6 +420,7 @@ ]); initSharedUload(); startEventStore(); + initTools(); await dashboardStore.initialize(); // Start the persistent LLM task queue. Idempotent — safe to call