diff --git a/apps/mana/CLAUDE.md b/apps/mana/CLAUDE.md index 42f0ee599..38c458285 100644 --- a/apps/mana/CLAUDE.md +++ b/apps/mana/CLAUDE.md @@ -210,7 +210,7 @@ The companion is a **second actor** that works alongside the human in every modu - **Scene lens** — workbench scenes can bind to an agent via `scene.viewingAsAgentId` (context menu → "An Agent binden…"). Pure UI lens, not a data-scope change. `SceneAppBar` shows the agent avatar on bound scene tabs. - **Workbench timeline** — `/ai-workbench` renders every AI-attributed event grouped by mission iteration with per-**agent** filter, per-module, per-mission. Each bucket header shows agent avatar + name + mission title. Per-bucket **Revert button** undoes the iteration's writes via `data/ai/revert/` (TaskCreated → delete, TaskCompleted → uncomplete, etc., newest-first). Separate **"Datenzugriff"** tab exposes the server-side decrypt audit (for missions with Key-Grants). -### Tool Coverage (59 tools, 19 modules) +### Tool Coverage (67 tools, 21 modules) Agents interact with the app through tools — each one either auto (executes silently during reasoning) or propose (creates a Proposal card the user must approve). Source of truth: `AI_TOOL_CATALOG` in `@mana/shared-ai/src/tools/schemas.ts` — both webapp policy (`src/lib/data/ai/policy.ts`) and server-side planner (`services/mana-ai/src/planner/tools.ts`) derive from it automatically, so drift is structurally impossible. @@ -235,6 +235,8 @@ Agents interact with the app through tools — each one either auto (executes si | finance | `add_transaction` | `get_month_summary`, `list_transactions` | | times | `start_timer`, `stop_timer` | `get_time_stats`, `get_timer_status`, `list_projects` | | wetter | — | `get_weather`, `get_rain_forecast` | +| invoices | `create_invoice`, `mark_invoice_paid` | `list_invoices`, `get_invoice_stats` | +| library | `create_library_entry`, `update_library_entry_status`, `rate_library_entry` | `list_library_entries` | **Server-side web-research**: mana-ai calls mana-api's `/api/v1/news-research/discover` + `/search` directly before the planner prompt is built (pre-planning injection). Missions with research-keyword objectives get real article URLs + excerpts injected as a synthetic ResolvedInput. See `services/mana-ai/src/planner/news-research-client.ts`. diff --git a/apps/mana/apps/web/src/lib/data/tools/init.ts b/apps/mana/apps/web/src/lib/data/tools/init.ts index 4b4468abd..d54c70412 100644 --- a/apps/mana/apps/web/src/lib/data/tools/init.ts +++ b/apps/mana/apps/web/src/lib/data/tools/init.ts @@ -42,6 +42,7 @@ import { wishesTools } from '$lib/modules/wishes/tools'; import { wetterTools } from '$lib/modules/wetter/tools'; import { quizTools } from '$lib/modules/quiz/tools'; import { invoicesTools } from '$lib/modules/invoices/tools'; +import { libraryTools } from '$lib/modules/library/tools'; let initialized = false; @@ -85,5 +86,6 @@ export function initTools(): void { registerTools(wetterTools); registerTools(quizTools); registerTools(invoicesTools); + registerTools(libraryTools); initialized = true; } diff --git a/apps/mana/apps/web/src/lib/modules/library/tools.ts b/apps/mana/apps/web/src/lib/modules/library/tools.ts new file mode 100644 index 000000000..5ac752507 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/library/tools.ts @@ -0,0 +1,193 @@ +/** + * Library tools — AI-accessible CRUD over the encrypted library table. + * + * Propose: + * - create_library_entry — new book/movie/series/comic + * - update_library_entry_status — planned → active → completed … + * - rate_library_entry — 1-5 stars + * + * Auto: + * - list_library_entries — filtered by kind + status + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import { libraryEntriesStore } from './stores/entries.svelte'; +import { db } from '$lib/data/database'; +import { decryptRecords, VaultLockedError } from '$lib/data/crypto'; +import { toLibraryEntry } from './queries'; +import type { LocalLibraryEntry, LibraryKind, LibraryStatus } from './types'; + +const KINDS = ['book', 'movie', 'series', 'comic'] as const; +const STATUSES = ['planned', 'active', 'completed', 'paused', 'dropped'] as const; + +function splitList(raw: unknown): string[] | undefined { + if (typeof raw !== 'string') return undefined; + const parts = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return parts.length > 0 ? parts : undefined; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +export const libraryTools: ModuleTool[] = [ + { + name: 'create_library_entry', + module: 'library', + description: 'Erstellt einen neuen Bibliotheks-Eintrag', + parameters: [ + { name: 'kind', type: 'string', description: 'Art', required: true }, + { name: 'title', type: 'string', description: 'Titel', required: true }, + { name: 'creators', type: 'string', description: 'Autor/Regie, CSV', required: false }, + { name: 'year', type: 'number', description: 'Jahr', required: false }, + { name: 'status', type: 'string', description: 'Status', required: false }, + { name: 'rating', type: 'number', description: 'Bewertung 1-5', required: false }, + { name: 'tags', type: 'string', description: 'Tags CSV', required: false }, + { name: 'genres', type: 'string', description: 'Genres CSV', required: false }, + ], + async execute(params) { + const kind = params.kind as LibraryKind; + if (!KINDS.includes(kind)) { + return { success: false, message: `Unbekannte Art: ${kind}` }; + } + const status = (params.status as LibraryStatus | undefined) ?? 'planned'; + if (!STATUSES.includes(status)) { + return { success: false, message: `Unbekannter Status: ${status}` }; + } + + const title = String(params.title ?? '').trim(); + if (!title) return { success: false, message: 'title darf nicht leer sein' }; + + const ratingNum = typeof params.rating === 'number' ? params.rating : null; + if (ratingNum !== null && (ratingNum < 1 || ratingNum > 5)) { + return { success: false, message: 'rating muss zwischen 1 und 5 liegen' }; + } + + const entry = await libraryEntriesStore.createEntry({ + kind, + title, + creators: splitList(params.creators), + year: typeof params.year === 'number' ? params.year : null, + status, + rating: ratingNum, + tags: splitList(params.tags), + genres: splitList(params.genres), + }); + + return { + success: true, + data: { entryId: entry.id, kind: entry.kind, title: entry.title }, + message: `${entry.kind} "${entry.title}" angelegt`, + }; + }, + }, + { + name: 'update_library_entry_status', + module: 'library', + description: + 'Aendert den Status eines Eintrags. Setzt bei active / completed automatisch die passenden Zeitstempel.', + parameters: [ + { name: 'entryId', type: 'string', description: 'ID des Eintrags', required: true }, + { name: 'status', type: 'string', description: 'Neuer Status', required: true }, + ], + async execute(params) { + const entryId = String(params.entryId ?? ''); + const status = params.status as LibraryStatus; + if (!STATUSES.includes(status)) { + return { success: false, message: `Unbekannter Status: ${status}` }; + } + + const existing = await db.table('libraryEntries').get(entryId); + if (!existing || existing.deletedAt) { + return { success: false, message: `Eintrag ${entryId} nicht gefunden` }; + } + + const patch: Partial = { status }; + if (status === 'active' && !existing.startedAt) patch.startedAt = nowIso(); + if (status === 'completed' && !existing.completedAt) patch.completedAt = nowIso(); + + await libraryEntriesStore.updateEntry(entryId, patch); + return { + success: true, + data: { entryId, status }, + message: `Status auf "${status}" gesetzt`, + }; + }, + }, + { + name: 'rate_library_entry', + module: 'library', + description: 'Setzt die Bewertung (1-5) eines Eintrags', + parameters: [ + { name: 'entryId', type: 'string', description: 'ID des Eintrags', required: true }, + { name: 'rating', type: 'number', description: 'Bewertung 1-5', required: true }, + ], + async execute(params) { + const entryId = String(params.entryId ?? ''); + const rating = params.rating as number; + if (typeof rating !== 'number' || rating < 1 || rating > 5) { + return { success: false, message: 'rating muss zwischen 1 und 5 liegen' }; + } + + await libraryEntriesStore.rate(entryId, rating); + return { + success: true, + data: { entryId, rating }, + message: `Bewertung ${rating}/5 gesetzt`, + }; + }, + }, + { + name: 'list_library_entries', + module: 'library', + description: + 'Listet Bibliotheks-Eintraege (id, kind, title, status, rating). Filterbar nach kind und status.', + parameters: [ + { name: 'kind', type: 'string', description: 'Nur eine Art', required: false }, + { name: 'status', type: 'string', description: 'Nur einen Status', required: false }, + { name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false }, + ], + async execute(params) { + const kindFilter = params.kind as LibraryKind | undefined; + const statusFilter = params.status as LibraryStatus | undefined; + const limit = Math.min(Math.max(Number(params.limit) || 30, 1), 100); + + try { + const all = await db.table('libraryEntries').toArray(); + const visible = all.filter((e) => !e.deletedAt); + const decrypted = await decryptRecords('libraryEntries', visible); + const rows = decrypted + .map(toLibraryEntry) + .filter((e) => (kindFilter ? e.kind === kindFilter : true)) + .filter((e) => (statusFilter ? e.status === statusFilter : true)) + .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) + .slice(0, limit) + .map((e) => ({ + id: e.id, + kind: e.kind, + title: e.title, + status: e.status, + rating: e.rating, + year: e.year, + })); + + return { + success: true, + data: { entries: rows, total: rows.length }, + message: `${rows.length} Eintrag(e) gelistet`, + }; + } catch (err) { + if (err instanceof VaultLockedError) { + return { + success: false, + message: 'Vault ist gesperrt — Bibliothek kann nicht entschlüsselt werden', + }; + } + throw err; + } + }, + }, +]; diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts index 530dd4888..69d2925d6 100644 --- a/packages/shared-ai/src/tools/schemas.ts +++ b/packages/shared-ai/src/tools/schemas.ts @@ -1216,6 +1216,113 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ defaultPolicy: 'auto', parameters: [], }, + + // ── Library ─────────────────────────────────────────────── + { + name: 'create_library_entry', + module: 'library', + description: + 'Erstellt einen neuen Eintrag in der Bibliothek (Buch, Film, Serie oder Comic). Default-Status ist "planned" falls nicht anders angegeben.', + defaultPolicy: 'propose', + parameters: [ + { + name: 'kind', + type: 'string', + description: 'Art des Eintrags', + required: true, + enum: ['book', 'movie', 'series', 'comic'], + }, + { name: 'title', type: 'string', description: 'Titel', required: true }, + { + name: 'creators', + type: 'string', + description: 'Autor/Regisseur/Creator, mehrere durch Komma trennen', + required: false, + }, + { name: 'year', type: 'number', description: 'Erscheinungsjahr', required: false }, + { + name: 'status', + type: 'string', + description: 'Anfangsstatus', + required: false, + enum: ['planned', 'active', 'completed', 'paused', 'dropped'], + }, + { + name: 'rating', + type: 'number', + description: 'Bewertung 1-5 (nur bei completed sinnvoll)', + required: false, + }, + { + name: 'tags', + type: 'string', + description: 'Tags durch Komma getrennt', + required: false, + }, + { + name: 'genres', + type: 'string', + description: 'Genres durch Komma getrennt', + required: false, + }, + ], + }, + { + name: 'update_library_entry_status', + module: 'library', + description: + 'Aendert den Status eines Bibliotheks-Eintrags (planned/active/completed/paused/dropped). Setzt beim Wechsel auf "active" automatisch startedAt, bei "completed" completedAt.', + defaultPolicy: 'propose', + parameters: [ + { name: 'entryId', type: 'string', description: 'ID des Eintrags', required: true }, + { + name: 'status', + type: 'string', + description: 'Neuer Status', + required: true, + enum: ['planned', 'active', 'completed', 'paused', 'dropped'], + }, + ], + }, + { + name: 'rate_library_entry', + module: 'library', + description: 'Setzt die Bewertung (1-5) eines Bibliotheks-Eintrags.', + defaultPolicy: 'propose', + parameters: [ + { name: 'entryId', type: 'string', description: 'ID des Eintrags', required: true }, + { name: 'rating', type: 'number', description: 'Bewertung 1 bis 5', required: true }, + ], + }, + { + name: 'list_library_entries', + module: 'library', + description: + 'Listet Bibliotheks-Eintraege (id, kind, title, status, rating). Optional nach Art und Status filterbar.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'kind', + type: 'string', + description: 'Nur eine Art zeigen', + required: false, + enum: ['book', 'movie', 'series', 'comic'], + }, + { + name: 'status', + type: 'string', + description: 'Nur einen Status zeigen', + required: false, + enum: ['planned', 'active', 'completed', 'paused', 'dropped'], + }, + { + name: 'limit', + type: 'number', + description: 'Maximale Anzahl (Standard 30)', + required: false, + }, + ], + }, ]; // ═══════════════════════════════════════════════════════════════