diff --git a/apps/mana/apps/web/src/lib/data/ai/policy.ts b/apps/mana/apps/web/src/lib/data/ai/policy.ts index 2e3748fb5..ce362aaa9 100644 --- a/apps/mana/apps/web/src/lib/data/ai/policy.ts +++ b/apps/mana/apps/web/src/lib/data/ai/policy.ts @@ -21,7 +21,7 @@ import { getTool } from '../tools/registry'; import type { Actor } from '../events/actor'; -import { AI_PROPOSABLE_TOOL_NAMES } from '@mana/shared-ai'; +import { AI_TOOL_CATALOG } from '@mana/shared-ai'; export type PolicyDecision = 'auto' | 'propose' | 'deny'; @@ -34,37 +34,15 @@ export interface AiPolicy { readonly defaultForAi: PolicyDecision; } -// ── Auto-executed tools (read-only / append-only self-state) ────────── -// Kept here as the canonical local-only list — policies that don't mutate -// user-visible records are webapp-specific and don't need to travel -// through @mana/shared-ai. -const AUTO_TOOLS: Record = { - get_task_stats: 'auto', - list_tasks: 'auto', - list_notes: 'auto', - get_todays_events: 'auto', - get_drink_progress: 'auto', - nutrition_summary: 'auto', - get_places: 'auto', - location_log: 'auto', - get_habits: 'auto', - get_contacts: 'auto', - // Append-only self-state logs: AI proposing "did you drink water?" + - // user confirming + AI logging it should not require a second approval. - log_drink: 'auto', - log_meal: 'auto', -}; - -// ── Proposable tools derived from the shared canonical list ─────────── -// Keeps the webapp policy and mana-ai's `AI_AVAILABLE_TOOLS` from drifting. -// Adding a new proposable tool → append to AI_PROPOSABLE_TOOL_NAMES in -// @mana/shared-ai and both sides pick it up automatically. -const PROPOSE_TOOLS: Record = Object.fromEntries( - AI_PROPOSABLE_TOOL_NAMES.map((name) => [name, 'propose'] as const) +// ── Per-tool policy derived from the AI Tool Catalog ────────────────── +// Each tool in the catalog declares its defaultPolicy ('auto' or 'propose'). +// Adding a new tool to the catalog automatically updates this policy map. +const CATALOG_TOOLS: Record = Object.fromEntries( + AI_TOOL_CATALOG.map((t) => [t.name, t.defaultPolicy]) ); export const DEFAULT_AI_POLICY: AiPolicy = { - tools: { ...AUTO_TOOLS, ...PROPOSE_TOOLS }, + tools: CATALOG_TOOLS, defaultForAi: 'propose', }; diff --git a/packages/shared-ai/src/index.ts b/packages/shared-ai/src/index.ts index 7244c019e..4a6daeb1a 100644 --- a/packages/shared-ai/src/index.ts +++ b/packages/shared-ai/src/index.ts @@ -75,6 +75,9 @@ export { type PolicyDecision, } from './policy'; +export type { ToolSchema } from './tools'; +export { AI_TOOL_CATALOG, AI_TOOL_CATALOG_BY_NAME } from './tools'; + export type { Agent, AgentState, diff --git a/packages/shared-ai/src/policy/proposable-tools.ts b/packages/shared-ai/src/policy/proposable-tools.ts index 7b0052201..56b9d40c2 100644 --- a/packages/shared-ai/src/policy/proposable-tools.ts +++ b/packages/shared-ai/src/policy/proposable-tools.ts @@ -1,51 +1,23 @@ /** - * Canonical list of tool names the AI is allowed to *propose*. + * Proposable tool names — derived from the AI Tool Catalog. + * + * Tools with `defaultPolicy: 'propose'` in the catalog are the ones the + * server-side planner actively proposes. This file re-exports a derived + * array and set for backward compatibility; the source of truth is + * `../tools/schemas.ts`. * - * Both the webapp's `DEFAULT_AI_POLICY` and the server-side - * `AI_AVAILABLE_TOOLS` list in `services/mana-ai/` derive from here. * Adding a new proposable tool: - * - * 1. Append its name to {@link AI_PROPOSABLE_TOOL_NAMES} - * 2. Add the tool with its params to `AI_AVAILABLE_TOOLS` in mana-ai - * (the contract test below ensures step 2 isn't forgotten) - * 3. The webapp's `DEFAULT_AI_POLICY` picks it up automatically - * - * Tools NOT in this list default to `'propose'` only if the per-tool - * policy map lacks an explicit entry. Most `auto` / `deny` decisions - * stay hardcoded in the webapp policy — this shared list only covers - * the tools the *server-side* planner actively proposes. + * 1. Add its schema to `AI_TOOL_CATALOG` with `defaultPolicy: 'propose'` + * 2. Add the `execute` function in the webapp module's `tools.ts` + * 3. Done — this list updates automatically */ -export const AI_PROPOSABLE_TOOL_NAMES = [ - // ── Todo ────────────────────────────────── - 'create_task', - 'complete_task', - 'complete_tasks_by_title', - // ── Calendar ────────────────────────────── - 'create_event', - // ── Places ──────────────────────────────── - 'create_place', - 'visit_place', - // ── Drink ───────────────────────────────── - 'undo_drink', - // ── News ────────────────────────────────── - 'save_news_article', - // ── Notes ───────────────────────────────── - 'create_note', - 'update_note', - 'append_to_note', - 'add_tag_to_note', - // ── Journal ─────────────────────────────── - 'create_journal_entry', - // ── Habits ──────────────────────────────── - 'create_habit', - 'log_habit', - // ── News-Research ───────────────────────── - 'research_news', - // ── Contacts ────────────────────────────── - 'create_contact', -] as const; +import { AI_TOOL_CATALOG } from '../tools/schemas'; -export type AiProposableToolName = (typeof AI_PROPOSABLE_TOOL_NAMES)[number]; +export const AI_PROPOSABLE_TOOL_NAMES: readonly string[] = AI_TOOL_CATALOG.filter( + (t) => t.defaultPolicy === 'propose' +).map((t) => t.name); + +export type AiProposableToolName = string; export const AI_PROPOSABLE_TOOL_SET: ReadonlySet = new Set(AI_PROPOSABLE_TOOL_NAMES); diff --git a/packages/shared-ai/src/tools/index.ts b/packages/shared-ai/src/tools/index.ts new file mode 100644 index 000000000..b5201adf4 --- /dev/null +++ b/packages/shared-ai/src/tools/index.ts @@ -0,0 +1,2 @@ +export type { ToolSchema } from './schemas'; +export { AI_TOOL_CATALOG, AI_TOOL_CATALOG_BY_NAME } from './schemas'; diff --git a/packages/shared-ai/src/tools/schemas.test.ts b/packages/shared-ai/src/tools/schemas.test.ts new file mode 100644 index 000000000..c9e9d86ab --- /dev/null +++ b/packages/shared-ai/src/tools/schemas.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { AI_TOOL_CATALOG, AI_TOOL_CATALOG_BY_NAME } from './schemas'; + +describe('AI_TOOL_CATALOG', () => { + it('has no duplicate tool names', () => { + const names = AI_TOOL_CATALOG.map((t) => t.name); + expect(names.length).toBe(new Set(names).size); + }); + + it('every tool has a non-empty name, module, and description', () => { + for (const tool of AI_TOOL_CATALOG) { + expect(tool.name.length, `tool name empty`).toBeGreaterThan(0); + expect(tool.module.length, `${tool.name}: module empty`).toBeGreaterThan(0); + expect(tool.description.length, `${tool.name}: description empty`).toBeGreaterThan(0); + } + }); + + it('every parameter has a description', () => { + for (const tool of AI_TOOL_CATALOG) { + for (const param of tool.parameters) { + expect( + param.description.length, + `${tool.name}.${param.name}: description empty` + ).toBeGreaterThan(0); + } + } + }); + + it('defaultPolicy is either auto or propose', () => { + for (const tool of AI_TOOL_CATALOG) { + expect(['auto', 'propose']).toContain(tool.defaultPolicy); + } + }); + + it('has the expected propose and auto tool counts', () => { + const propose = AI_TOOL_CATALOG.filter((t) => t.defaultPolicy === 'propose'); + const auto = AI_TOOL_CATALOG.filter((t) => t.defaultPolicy === 'auto'); + expect(propose.length).toBe(17); + expect(auto.length).toBe(12); + }); + + it('by-name map has same size as catalog', () => { + expect(AI_TOOL_CATALOG_BY_NAME.size).toBe(AI_TOOL_CATALOG.length); + }); +}); diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts new file mode 100644 index 000000000..a145df40f --- /dev/null +++ b/packages/shared-ai/src/tools/schemas.ts @@ -0,0 +1,492 @@ +/** + * AI Tool Catalog — single source of truth for all tool schemas. + * + * Both the webapp (policy, planner prompt, executor) and the server-side + * mana-ai service (planner prompt, drift guard) derive their tool lists + * from this catalog. Adding a new tool: + * + * 1. Add its schema here with `defaultPolicy` + * 2. Add the `execute` function in the webapp module's `tools.ts` + * 3. Done — policy, server tools, and proposable list derive automatically + * + * The `defaultPolicy` field determines how the tool behaves by default: + * - `'propose'` — AI writes go through the Proposal review workflow + * - `'auto'` — executes immediately during the reasoning loop (read-only / append-only) + */ + +import type { PolicyDecision } from '../policy/types'; + +export interface ToolSchema { + readonly name: string; + readonly module: string; + readonly description: string; + readonly defaultPolicy: PolicyDecision; + readonly parameters: ReadonlyArray<{ + readonly name: string; + readonly type: string; + readonly required: boolean; + readonly description: string; + readonly enum?: readonly string[]; + }>; +} + +// ═══════════════════════════════════════════════════════════════ +// TOOL CATALOG +// ═══════════════════════════════════════════════════════════════ + +export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ + // ── Todo ────────────────────────────────────────────────── + { + name: 'create_task', + module: 'todo', + description: 'Erstellt einen neuen Task mit optionalem Faelligkeitsdatum und Prioritaet', + defaultPolicy: 'propose', + 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 }, + ], + }, + { + name: 'complete_task', + module: 'todo', + description: 'Markiert einen Task als erledigt', + defaultPolicy: 'propose', + parameters: [{ name: 'taskId', type: 'string', description: 'ID des Tasks', required: true }], + }, + { + name: 'complete_tasks_by_title', + module: 'todo', + description: 'Markiert alle Tasks deren Titel den Substring enthält (case-insensitive)', + defaultPolicy: 'propose', + parameters: [ + { + name: 'titleSubstring', + type: 'string', + description: 'Teil des Task-Titels', + required: true, + }, + ], + }, + { + name: 'get_task_stats', + module: 'todo', + description: + 'Gibt Statistiken ueber alle Tasks zurueck (total, erledigt, ueberfaellig, heute faellig)', + defaultPolicy: 'auto', + parameters: [], + }, + { + name: 'list_tasks', + module: 'todo', + description: + 'Listet Tasks mit Titel, Faelligkeit und Prioritaet auf. Nutze diese, wenn der Nutzer fragt welche Tasks er hat oder eine Liste sehen will.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'filter', + type: 'string', + description: 'Welche Tasks zeigen', + required: false, + enum: ['open', 'completed', 'overdue', 'today', 'all'], + }, + { + name: 'limit', + type: 'number', + description: 'Maximale Anzahl (default: 20)', + required: false, + }, + ], + }, + + // ── Calendar ────────────────────────────────────────────── + { + name: 'create_event', + module: 'calendar', + description: 'Erstellt einen Kalender-Event', + defaultPolicy: 'propose', + parameters: [ + { name: 'title', type: 'string', description: 'Event-Titel', required: true }, + { name: 'startIso', type: 'string', description: 'Start (ISO)', required: true }, + { name: 'endIso', type: 'string', description: 'Ende (ISO)', required: false }, + ], + }, + { + name: 'get_todays_events', + module: 'calendar', + description: 'Gibt alle Termine fuer heute zurueck', + defaultPolicy: 'auto', + parameters: [], + }, + + // ── Notes ───────────────────────────────────────────────── + { + name: 'create_note', + module: 'notes', + description: 'Erstellt eine neue Notiz. Gibt die ID der angelegten Notiz zurueck.', + defaultPolicy: 'propose', + parameters: [ + { name: 'title', type: 'string', description: 'Titel der Notiz', required: true }, + { + name: 'content', + type: 'string', + description: 'Inhalt der Notiz (Markdown)', + required: false, + }, + ], + }, + { + name: 'update_note', + module: 'notes', + description: + 'Überschreibt Titel und/oder Inhalt einer bestehenden Notiz. Destruktiv — bevorzuge append_to_note oder add_tag_to_note wenn du nur ergänzen willst.', + defaultPolicy: 'propose', + parameters: [ + { name: 'noteId', type: 'string', description: 'ID der Notiz', required: true }, + { name: 'title', type: 'string', description: 'Neuer Titel', required: false }, + { + name: 'content', + type: 'string', + description: 'Neuer Inhalt (überschreibt vollständig)', + required: false, + }, + ], + }, + { + name: 'append_to_note', + module: 'notes', + description: + 'Hängt Text ans Ende des Inhalts einer bestehenden Notiz an (neue Zeile getrennt). Nicht-destruktiv.', + defaultPolicy: 'propose', + parameters: [ + { name: 'noteId', type: 'string', description: 'ID der Notiz', required: true }, + { name: 'content', type: 'string', description: 'Text zum Anhängen', required: true }, + ], + }, + { + name: 'add_tag_to_note', + module: 'notes', + description: + 'Fügt einen Hashtag (z.B. "#Natur") an eine bestehende Notiz an. Idempotent — wenn der Tag schon vorhanden ist, passiert nichts.', + defaultPolicy: 'propose', + parameters: [ + { name: 'noteId', type: 'string', description: 'ID der Notiz', required: true }, + { + name: 'tag', + type: 'string', + description: + 'Tag-Name (ohne #; z.B. "Natur", "Arbeit"). Leerzeichen werden durch _ ersetzt.', + required: true, + }, + ], + }, + { + name: 'list_notes', + module: 'notes', + description: + 'Listet vorhandene Notizen (id, title, excerpt) damit du sie referenzieren kannst. Standardmäßig ohne archivierte.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'limit', + type: 'number', + description: 'Maximale Anzahl (Standard 30, max 100)', + required: false, + }, + { + name: 'query', + type: 'string', + description: 'Case-insensitive Substring-Filter auf Titel oder Inhalt', + required: false, + }, + { + name: 'includeArchived', + type: 'boolean', + description: 'Auch archivierte Notizen einbeziehen (default false)', + required: false, + }, + ], + }, + + // ── Places ──────────────────────────────────────────────── + { + name: 'create_place', + module: 'places', + description: 'Fügt einen neuen Ort hinzu', + defaultPolicy: 'propose', + parameters: [ + { name: 'name', type: 'string', description: 'Name des Ortes', required: true }, + { name: 'category', type: 'string', description: 'Kategorie', required: false }, + ], + }, + { + name: 'visit_place', + module: 'places', + description: 'Vermerkt einen Besuch an einem bereits erfassten Ort', + defaultPolicy: 'propose', + parameters: [{ name: 'placeId', type: 'string', description: 'ID des Ortes', required: true }], + }, + { + name: 'get_places', + module: 'places', + description: 'Gibt alle gespeicherten Orte zurueck', + defaultPolicy: 'auto', + parameters: [], + }, + { + name: 'location_log', + module: 'places', + description: 'Gibt die aktuelle GPS-Position zurueck (erfordert Standort-Berechtigung)', + defaultPolicy: 'auto', + parameters: [], + }, + + // ── Drink ───────────────────────────────────────────────── + { + name: 'undo_drink', + module: 'drink', + description: 'Macht den letzten Drink-Eintrag rückgängig', + defaultPolicy: 'propose', + parameters: [], + }, + { + name: 'get_drink_progress', + module: 'drink', + description: 'Gibt den heutigen Trink-Fortschritt zurueck (Wasser, Kaffee, gesamt)', + defaultPolicy: 'auto', + parameters: [], + }, + { + name: 'log_drink', + module: 'drink', + description: 'Loggt ein Getraenk (Wasser, Kaffee, Tee, etc.)', + defaultPolicy: 'auto', + 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, + }, + ], + }, + + // ── Food ────────────────────────────────────────────────── + { + name: 'nutrition_summary', + module: 'food', + description: + 'Gibt die heutige Ernaehrungs-Zusammenfassung zurueck (Mahlzeiten, Kalorien, Protein)', + defaultPolicy: 'auto', + parameters: [], + }, + { + name: 'log_meal', + module: 'food', + description: 'Loggt eine Mahlzeit mit optionalen Naehrwerten', + defaultPolicy: 'auto', + 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 }, + ], + }, + + // ── News ────────────────────────────────────────────────── + { + name: 'save_news_article', + module: 'news', + description: + 'Speichert einen Artikel von einer URL in die Leseliste. URL wird serverseitig per Readability extrahiert.', + defaultPolicy: 'propose', + parameters: [ + { name: 'url', type: 'string', description: 'Die Artikel-URL', required: true }, + { + name: 'title', + type: 'string', + description: 'Anzeigetitel für den Approval-Dialog (informativ)', + required: false, + }, + { + name: 'summary', + type: 'string', + description: 'Kurze Begründung warum dieser Artikel relevant ist', + required: false, + }, + ], + }, + + // ── News-Research ───────────────────────────────────────── + { + name: 'research_news', + module: 'news-research', + description: + 'Durchsucht RSS-Feeds und Web-Quellen nach relevanten Artikeln zu einem Thema. Gibt gefundene Artikel-URLs + Titel + Zusammenfassung zurueck. Nuetzlich als Vorstufe zu save_news_article.', + defaultPolicy: 'propose', + parameters: [ + { + name: 'query', + type: 'string', + description: 'Suchbegriff / Thema (z.B. "TypeScript 5.8 release")', + required: true, + }, + { + name: 'language', + type: 'string', + description: 'Sprache (z.B. "de" oder "en")', + required: false, + }, + { + name: 'limit', + type: 'number', + description: 'Max. Anzahl Ergebnisse (Standard: 10)', + required: false, + }, + ], + }, + + // ── Journal ─────────────────────────────────────────────── + { + name: 'create_journal_entry', + module: 'journal', + description: + 'Erstellt einen neuen Tagebuch-Eintrag fuer den heutigen Tag. Gibt die ID zurueck.', + defaultPolicy: 'propose', + parameters: [ + { + name: 'content', + type: 'string', + description: 'Inhalt des Eintrags (Markdown)', + required: true, + }, + { name: 'title', type: 'string', description: 'Optionaler Titel', required: false }, + { + name: 'mood', + type: 'string', + description: 'Stimmung', + required: false, + enum: ['great', 'good', 'neutral', 'bad', 'terrible'], + }, + ], + }, + + // ── Habits ──────────────────────────────────────────────── + { + name: 'create_habit', + module: 'habits', + description: 'Erstellt einen neuen Habit-Tracker. Gibt die ID des neuen Habits zurueck.', + defaultPolicy: 'propose', + parameters: [ + { name: 'title', type: 'string', description: 'Titel des Habits', required: true }, + { name: 'icon', type: 'string', description: 'Emoji-Icon', required: true }, + { + name: 'color', + type: 'string', + description: 'Hex-Farbe (z.B. #EF4444)', + required: true, + }, + ], + }, + { + name: 'log_habit', + module: 'habits', + description: + 'Loggt eine Ausfuehrung eines existierenden Habits fuer heute. Optional mit Notiz.', + defaultPolicy: 'propose', + parameters: [ + { name: 'habitId', type: 'string', description: 'ID des Habits', required: true }, + { + name: 'note', + type: 'string', + description: 'Optionale Notiz zum Log', + required: false, + }, + ], + }, + { + name: 'get_habits', + module: 'habits', + description: 'Gibt alle aktiven Habits zurueck', + defaultPolicy: 'auto', + parameters: [], + }, + + // ── Contacts ────────────────────────────────────────────── + { + name: 'create_contact', + module: 'contacts', + description: 'Erstellt einen neuen Kontakt. Felder die nicht bekannt sind einfach weglassen.', + defaultPolicy: 'propose', + parameters: [ + { name: 'firstName', type: 'string', description: 'Vorname', required: true }, + { name: 'lastName', type: 'string', description: 'Nachname', required: false }, + { name: 'email', type: 'string', description: 'E-Mail-Adresse', required: false }, + { name: 'phone', type: 'string', description: 'Telefonnummer', required: false }, + { + name: 'company', + type: 'string', + description: 'Firma / Organisation', + required: false, + }, + { + name: 'notes', + type: 'string', + description: 'Freitext-Notizen zum Kontakt', + required: false, + }, + ], + }, + { + name: 'get_contacts', + module: 'contacts', + description: 'Gibt alle Kontakte zurueck', + defaultPolicy: 'auto', + parameters: [], + }, +]; + +// ═══════════════════════════════════════════════════════════════ +// DERIVED LOOKUPS +// ═══════════════════════════════════════════════════════════════ + +/** O(1) lookup by tool name. */ +export const AI_TOOL_CATALOG_BY_NAME: ReadonlyMap = new Map( + AI_TOOL_CATALOG.map((t) => [t.name, t]) +); diff --git a/services/mana-ai/src/planner/tools.ts b/services/mana-ai/src/planner/tools.ts index 30698b266..b261883df 100644 --- a/services/mana-ai/src/planner/tools.ts +++ b/services/mana-ai/src/planner/tools.ts @@ -1,269 +1,27 @@ /** - * Hardcoded allow-list of tools the server-side Planner may propose. + * Server-side tool list — derived from the AI Tool Catalog. * - * Parameter shapes live here (the webapp owns the full Dexie-bound - * registry); the set of NAMES is shared via `@mana/shared-ai`'s - * `AI_PROPOSABLE_TOOL_NAMES`. The module-load assertion at the bottom - * guards against drift in either direction — if this file or the shared - * list falls out of sync, the service refuses to start. + * The full schema definitions now live in `@mana/shared-ai/src/tools/schemas.ts`. + * This file filters the catalog to the proposable subset (tools the server-side + * planner may suggest) and provides the name sets used by the parser and drift guard. + * + * Adding a new tool: add it to `AI_TOOL_CATALOG` in `@mana/shared-ai` — this + * file picks it up automatically. */ -import { AI_PROPOSABLE_TOOL_SET, type AvailableTool } from '@mana/shared-ai'; +import { AI_TOOL_CATALOG, AI_PROPOSABLE_TOOL_SET, type AvailableTool } from '@mana/shared-ai'; -export const AI_AVAILABLE_TOOLS: readonly AvailableTool[] = [ - { - 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 }, - ], - }, - { - name: 'complete_task', - module: 'todo', - description: 'Markiert einen Task als erledigt', - parameters: [{ name: 'taskId', type: 'string', description: 'ID des Tasks', required: true }], - }, - { - name: 'complete_tasks_by_title', - module: 'todo', - description: 'Markiert alle Tasks deren Titel den Substring enthält (case-insensitive)', - parameters: [ - { - name: 'titleSubstring', - type: 'string', - description: 'Teil des Task-Titels', - required: true, - }, - ], - }, - { - name: 'create_event', - module: 'calendar', - description: 'Erstellt einen Kalender-Event', - parameters: [ - { name: 'title', type: 'string', description: 'Event-Titel', required: true }, - { name: 'startIso', type: 'string', description: 'Start (ISO)', required: true }, - { name: 'endIso', type: 'string', description: 'Ende (ISO)', required: false }, - ], - }, - { - name: 'create_place', - module: 'places', - description: 'Fügt einen neuen Ort hinzu', - parameters: [ - { name: 'name', type: 'string', description: 'Name des Ortes', required: true }, - { name: 'category', type: 'string', description: 'Kategorie', required: false }, - ], - }, - { - name: 'visit_place', - module: 'places', - description: 'Vermerkt einen Besuch an einem bereits erfassten Ort', - parameters: [{ name: 'placeId', type: 'string', description: 'ID des Ortes', required: true }], - }, - { - name: 'undo_drink', - module: 'drink', - description: 'Macht den letzten Drink-Eintrag rückgängig', - parameters: [], - }, - { - name: 'save_news_article', - module: 'news', - description: - 'Speichert einen Artikel von einer URL in die Leseliste. URL wird serverseitig per Readability extrahiert.', - parameters: [ - { name: 'url', type: 'string', description: 'Die Artikel-URL', required: true }, - { - name: 'title', - type: 'string', - description: 'Anzeigetitel für den Approval-Dialog (informativ)', - required: false, - }, - { - name: 'summary', - type: 'string', - description: 'Kurze Begründung warum dieser Artikel relevant ist', - required: false, - }, - ], - }, - { - name: 'update_note', - module: 'notes', - description: - 'Überschreibt Titel und/oder Inhalt einer bestehenden Notiz. Destruktiv — bevorzuge append_to_note oder add_tag_to_note wenn du nur ergänzen willst.', - parameters: [ - { name: 'noteId', type: 'string', description: 'ID der Notiz', required: true }, - { name: 'title', type: 'string', description: 'Neuer Titel', required: false }, - { - name: 'content', - type: 'string', - description: 'Neuer Inhalt (überschreibt vollständig)', - required: false, - }, - ], - }, - { - name: 'append_to_note', - module: 'notes', - description: - 'Hängt Text ans Ende des Inhalts einer bestehenden Notiz an (neue Zeile getrennt). Nicht-destruktiv.', - parameters: [ - { name: 'noteId', type: 'string', description: 'ID der Notiz', required: true }, - { name: 'content', type: 'string', description: 'Text zum Anhängen', required: true }, - ], - }, - { - name: 'add_tag_to_note', - module: 'notes', - description: - 'Fügt einen Hashtag (z.B. "#Natur") an eine bestehende Notiz an. Idempotent — wenn der Tag schon vorhanden ist, passiert nichts.', - parameters: [ - { name: 'noteId', type: 'string', description: 'ID der Notiz', required: true }, - { - name: 'tag', - type: 'string', - description: - 'Tag-Name (ohne #; z.B. "Natur", "Arbeit"). Leerzeichen werden durch _ ersetzt.', - required: true, - }, - ], - }, - - // ── Notes: create ──────────────────────────────────────── - { - name: 'create_note', - module: 'notes', - description: 'Erstellt eine neue Notiz. Gibt die ID der angelegten Notiz zurueck.', - parameters: [ - { name: 'title', type: 'string', description: 'Titel der Notiz', required: true }, - { - name: 'content', - type: 'string', - description: 'Inhalt der Notiz (Markdown)', - required: false, - }, - ], - }, - - // ── Journal ────────────────────────────────────────────── - { - name: 'create_journal_entry', - module: 'journal', - description: - 'Erstellt einen neuen Tagebuch-Eintrag fuer den heutigen Tag. Gibt die ID zurueck.', - parameters: [ - { - name: 'content', - type: 'string', - description: 'Inhalt des Eintrags (Markdown)', - required: true, - }, - { name: 'title', type: 'string', description: 'Optionaler Titel', required: false }, - { - name: 'mood', - type: 'string', - description: 'Stimmung', - required: false, - enum: ['great', 'good', 'neutral', 'bad', 'terrible'], - }, - ], - }, - - // ── Habits ─────────────────────────────────────────────── - { - name: 'create_habit', - module: 'habits', - description: 'Erstellt einen neuen Habit-Tracker. Gibt die ID des neuen Habits zurueck.', - parameters: [ - { name: 'title', type: 'string', description: 'Titel des Habits', required: true }, - { name: 'icon', type: 'string', description: 'Emoji-Icon', required: true }, - { name: 'color', type: 'string', description: 'Hex-Farbe (z.B. #EF4444)', required: true }, - ], - }, - { - name: 'log_habit', - module: 'habits', - description: - 'Loggt eine Ausfuehrung eines existierenden Habits fuer heute. Optional mit Notiz.', - parameters: [ - { name: 'habitId', type: 'string', description: 'ID des Habits', required: true }, - { name: 'note', type: 'string', description: 'Optionale Notiz zum Log', required: false }, - ], - }, - - // ── News-Research ──────────────────────────────────────── - { - name: 'research_news', - module: 'news-research', - description: - 'Durchsucht RSS-Feeds und Web-Quellen nach relevanten Artikeln zu einem Thema. Gibt gefundene Artikel-URLs + Titel + Zusammenfassung zurueck. Nuetzlich als Vorstufe zu save_news_article.', - parameters: [ - { - name: 'query', - type: 'string', - description: 'Suchbegriff / Thema (z.B. "TypeScript 5.8 release")', - required: true, - }, - { - name: 'language', - type: 'string', - description: 'Sprache (z.B. "de" oder "en")', - required: false, - }, - { - name: 'limit', - type: 'number', - description: 'Max. Anzahl Ergebnisse (Standard: 10)', - required: false, - }, - ], - }, - - // ── Contacts ───────────────────────────────────────────── - { - name: 'create_contact', - module: 'contacts', - description: 'Erstellt einen neuen Kontakt. Felder die nicht bekannt sind einfach weglassen.', - parameters: [ - { name: 'firstName', type: 'string', description: 'Vorname', required: true }, - { name: 'lastName', type: 'string', description: 'Nachname', required: false }, - { name: 'email', type: 'string', description: 'E-Mail-Adresse', required: false }, - { name: 'phone', type: 'string', description: 'Telefonnummer', required: false }, - { name: 'company', type: 'string', description: 'Firma / Organisation', required: false }, - { - name: 'notes', - type: 'string', - description: 'Freitext-Notizen zum Kontakt', - required: false, - }, - ], - }, -]; +/** Tools the server-side planner may propose (defaultPolicy === 'propose'). */ +export const AI_AVAILABLE_TOOLS: readonly AvailableTool[] = AI_TOOL_CATALOG.filter( + (t) => t.defaultPolicy === 'propose' +); export const AI_AVAILABLE_TOOL_NAMES = new Set(AI_AVAILABLE_TOOLS.map((t) => t.name)); // ── Contract check — runs on module load ─────────────────── -// Catches drift between this file and @mana/shared-ai's canonical -// proposable list. A mismatch means the webapp's policy + mana-ai are -// about to disagree; better fail fast than ship a silently-degraded AI. +// Both sides now derive from the same catalog, so drift is structurally +// impossible. This lightweight guard catches regressions if the derivation +// logic is ever accidentally changed. { const extra = [...AI_AVAILABLE_TOOL_NAMES].filter((n) => !AI_PROPOSABLE_TOOL_SET.has(n)); const missing = [...AI_PROPOSABLE_TOOL_SET].filter((n) => !AI_AVAILABLE_TOOL_NAMES.has(n));