refactor(ai): dynamic tool registry — single-source catalog in shared-ai

Introduce AI_TOOL_CATALOG in @mana/shared-ai as the single source of truth
for all 29 tool schemas (17 propose + 12 auto). Both the webapp policy and
the server-side mana-ai planner now derive their tool lists from the catalog
instead of maintaining independent hardcoded copies.

- New: packages/shared-ai/src/tools/schemas.ts — catalog with ToolSchema type
- Rewrite: proposable-tools.ts — derived from catalog instead of hardcoded array
- Rewrite: services/mana-ai/src/planner/tools.ts — 277→30 lines (imports from catalog)
- Simplify: webapp policy.ts — derives AUTO/PROPOSE from catalog defaultPolicy

Adding a new tool now requires 2 files instead of 3-5:
1. Add schema to AI_TOOL_CATALOG (shared-ai)
2. Add execute function in the module's tools.ts (webapp)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-16 13:06:07 +02:00
parent b4ce8523b0
commit d40a61119e
7 changed files with 579 additions and 329 deletions

View file

@ -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<string>(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));