mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
b4ce8523b0
commit
d40a61119e
7 changed files with 579 additions and 329 deletions
|
|
@ -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<string, 'auto'> = {
|
||||
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<string, 'propose'> = 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<string, PolicyDecision> = 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',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string> = new Set(AI_PROPOSABLE_TOOL_NAMES);
|
||||
|
|
|
|||
2
packages/shared-ai/src/tools/index.ts
Normal file
2
packages/shared-ai/src/tools/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export type { ToolSchema } from './schemas';
|
||||
export { AI_TOOL_CATALOG, AI_TOOL_CATALOG_BY_NAME } from './schemas';
|
||||
45
packages/shared-ai/src/tools/schemas.test.ts
Normal file
45
packages/shared-ai/src/tools/schemas.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
492
packages/shared-ai/src/tools/schemas.ts
Normal file
492
packages/shared-ai/src/tools/schemas.ts
Normal file
|
|
@ -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<string, ToolSchema> = new Map(
|
||||
AI_TOOL_CATALOG.map((t) => [t.name, t])
|
||||
);
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue