feat(brain): add Tool Layer with LLM-accessible module operations

Phase 4 of the Companion Brain. Introduces a standardized tool
interface that gives LLMs read/write access to module operations
via function calling.

Core (data/tools/):
- ModuleTool interface with typed parameters and execute function
- Registry with dynamic registration and LLM schema generator
- Executor with parameter validation and error handling
- Init function wired into app layout startup

Module tools (13 tools across 5 modules):
- Todo: create_task, complete_task, get_task_stats
- Calendar: create_event, get_todays_events
- Drink: log_drink, get_drink_progress, undo_last_drink
- Nutriphi: log_meal, get_nutrition_summary
- Places: create_place, record_visit, get_places, get_current_location

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-13 20:47:32 +02:00
parent 777810d0d2
commit 66dd684bba
11 changed files with 626 additions and 0 deletions

View file

@ -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<string, unknown>
): Promise<ToolResult> {
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}` };
}
}

View file

@ -0,0 +1,3 @@
export { registerTools, getTools, getTool, getToolsForModule, getToolsForLlm } from './registry';
export { executeTool } from './executor';
export type { ModuleTool, ToolParameter, ToolResult, LlmFunctionSchema } from './types';

View file

@ -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;
}

View file

@ -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),
},
}));
}

View file

@ -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<string, unknown>) => Promise<ToolResult>;
}
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<string, LlmPropertySchema>;
required: string[];
};
}
export interface LlmPropertySchema {
type: string;
description: string;
enum?: string[];
}

View file

@ -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<string, unknown>) => !c.deletedAt && c.isDefault) ??
calendars.find((c: Record<string, unknown>) => !c.deletedAt);
if (!defaultCal) {
return { success: false, message: 'Kein Kalender vorhanden' };
}
const result = await eventsStore.createEvent({
calendarId: (defaultCal as Record<string, unknown>).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<LocalTimeBlock>('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<LocalTimeBlock>('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`,
};
},
},
];

View file

@ -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<LocalDrinkEntry>('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' };
},
},
];

View file

@ -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<LocalMeal>('meals').toArray();
const active = allMeals.filter((m) => !m.deletedAt);
const decrypted = await decryptRecords<LocalMeal>('meals', active);
const meals = decrypted.map(toMealWithNutrition);
const goals = await db.table<LocalGoal>('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`,
};
},
},
];

View file

@ -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<LocalPlace>('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)}`,
};
},
},
];

View file

@ -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<LocalTask>('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)`,
};
},
},
];

View file

@ -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