From 4d9b16a68300a5cafe6eb761fb9610fa22fe6766 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 16 Apr 2026 00:24:48 +0200 Subject: [PATCH] feat(notes): list + update + append + add_tag tools for the AI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the "read all notes and tag them #Natur/#Technologie/…" use case fully functional. Four new ModuleTool entries in notes/tools.ts: - list_notes(limit?, query?, includeArchived?) — auto, read-only. Returns id + title + excerpt so the planner can reference concrete notes without dumping full bodies. - update_note(noteId, title?, content?) — proposable. Destructive full overwrite. Docstring nudges toward append_to_note when applicable. - append_to_note(noteId, content) — proposable, non-destructive. Handles the trailing-newline separator so markdown stays clean. - add_tag_to_note(noteId, tag) — proposable, idempotent, case-insensitive. Strips leading #, replaces spaces with _, skips if already present. Exactly the categorization primitive the user asked for. All three writes are added to AI_PROPOSABLE_TOOL_NAMES so both the webapp policy and mana-ai's boot-time drift guard agree (now 11 tools). Mirrored in services/mana-ai/src/planner/tools.ts. AiProposalInbox mounted on /notes so approvals land inline in the notes module too (already appears in the mission-detail cross-module inbox via the earlier commit). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/modules/notes/tools.ts | 205 ++++++++++++++++++ .../web/src/routes/(app)/notes/+page.svelte | 2 + .../shared-ai/src/policy/proposable-tools.ts | 3 + services/mana-ai/src/planner/tools.ts | 42 ++++ 4 files changed, 252 insertions(+) diff --git a/apps/mana/apps/web/src/lib/modules/notes/tools.ts b/apps/mana/apps/web/src/lib/modules/notes/tools.ts index d9de5ce84..1bd264796 100644 --- a/apps/mana/apps/web/src/lib/modules/notes/tools.ts +++ b/apps/mana/apps/web/src/lib/modules/notes/tools.ts @@ -1,5 +1,34 @@ +/** + * Notes tools — AI-accessible read + write over the encrypted notes table. + * + * The three write tools (`update_note`, `append_to_note`, `add_tag_to_note`) + * are proposable: every edit to an existing note goes through the proposal + * inbox first. `create_note` is also proposable via the shared-ai list. + * + * The read tool (`list_notes`) runs auto — safely lists decrypted note + * metadata so the planner can decide which note to tag or edit. + */ + import type { ModuleTool } from '$lib/data/tools/types'; import { notesStore } from './stores/notes.svelte'; +import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; +import type { LocalNote } from './types'; + +const MAX_LIST_LIMIT = 100; +const DEFAULT_LIST_LIMIT = 30; + +function excerptOf(content: string, max = 140): string { + const flat = content.replace(/\s+/g, ' ').trim(); + return flat.length <= max ? flat : flat.slice(0, max - 1) + '…'; +} + +async function readLocalNote(id: string): Promise { + const local = await db.table('notes').get(id); + if (!local || local.deletedAt) return null; + const [decrypted] = await decryptRecords('notes', [local]); + return decrypted ?? null; +} export const notesTools: ModuleTool[] = [ { @@ -22,4 +51,180 @@ export const notesTools: ModuleTool[] = [ }; }, }, + { + name: 'list_notes', + module: 'notes', + description: + 'Listet vorhandene Notizen (id, title, excerpt) damit du sie referenzieren kannst. Standardmäßig ohne archivierte.', + parameters: [ + { + name: 'limit', + type: 'number', + description: `Maximale Anzahl (Standard ${DEFAULT_LIST_LIMIT}, max ${MAX_LIST_LIMIT})`, + 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, + }, + ], + async execute(params) { + const limit = Math.min( + Math.max(Number(params.limit) || DEFAULT_LIST_LIMIT, 1), + MAX_LIST_LIMIT + ); + const query = typeof params.query === 'string' ? params.query.toLowerCase().trim() : ''; + const includeArchived = Boolean(params.includeArchived); + + const all = await db.table('notes').toArray(); + const visible = all.filter((n) => !n.deletedAt && (includeArchived || !n.isArchived)); + const decrypted = await decryptRecords('notes', visible); + + const rows = decrypted + .filter((n) => { + if (!query) return true; + return ( + (n.title ?? '').toLowerCase().includes(query) || + (n.content ?? '').toLowerCase().includes(query) + ); + }) + .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) + .slice(0, limit) + .map((n) => ({ + id: n.id, + title: n.title || '(ohne Titel)', + excerpt: excerptOf(n.content ?? ''), + isPinned: n.isPinned, + isArchived: n.isArchived, + updatedAt: n.updatedAt, + })); + + return { + success: true, + data: { notes: rows, total: rows.length }, + message: `${rows.length} Notiz(en) gelistet`, + }; + }, + }, + { + 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, + }, + ], + async execute(params) { + const noteId = params.noteId as string; + const diff: { title?: string; content?: string } = {}; + if (typeof params.title === 'string') diff.title = params.title; + if (typeof params.content === 'string') diff.content = params.content; + if (diff.title === undefined && diff.content === undefined) { + return { success: false, message: 'Kein Feld zum Aktualisieren angegeben' }; + } + + const existing = await readLocalNote(noteId); + if (!existing) return { success: false, message: `Notiz ${noteId} nicht gefunden` }; + + await notesStore.updateNote(noteId, diff); + return { + success: true, + data: { noteId }, + message: `Notiz "${existing.title || 'Unbenannt'}" aktualisiert`, + }; + }, + }, + { + 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 }, + ], + async execute(params) { + const noteId = params.noteId as string; + const addition = String(params.content ?? '').trim(); + if (!addition) return { success: false, message: 'content darf nicht leer sein' }; + + const existing = await readLocalNote(noteId); + if (!existing) return { success: false, message: `Notiz ${noteId} nicht gefunden` }; + + const separator = existing.content && !existing.content.endsWith('\n') ? '\n' : ''; + const nextContent = `${existing.content ?? ''}${separator}${addition}`; + + await notesStore.updateNote(noteId, { content: nextContent }); + return { + success: true, + data: { noteId, addedChars: addition.length }, + message: `"${existing.title || 'Notiz'}" um ${addition.length} Zeichen erweitert`, + }; + }, + }, + { + 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. Genau richtig um Notizen thematisch zu kategorisieren.', + 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, + }, + ], + async execute(params) { + const noteId = params.noteId as string; + const rawTag = String(params.tag ?? '') + .replace(/^#+/, '') + .trim(); + if (!rawTag) return { success: false, message: 'tag darf nicht leer sein' }; + + const normalized = rawTag.replace(/\s+/g, '_'); + const tagToken = `#${normalized}`; + const existing = await readLocalNote(noteId); + if (!existing) return { success: false, message: `Notiz ${noteId} nicht gefunden` }; + + const content = existing.content ?? ''; + // Case-insensitive idempotency so #Natur + #natur deduplicate. + const escaped = tagToken.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const tagRegex = new RegExp(`(^|\\s)${escaped}(\\s|$)`, 'i'); + if (tagRegex.test(content)) { + return { + success: true, + data: { noteId, already: true }, + message: `Tag ${tagToken} war schon vorhanden.`, + }; + } + + const separator = content && !content.endsWith('\n') ? '\n\n' : ''; + const nextContent = `${content}${separator}${tagToken}`; + + await notesStore.updateNote(noteId, { content: nextContent }); + return { + success: true, + data: { noteId, tag: tagToken }, + message: `${tagToken} zu "${existing.title || 'Notiz'}" hinzugefügt`, + }; + }, + }, ]; diff --git a/apps/mana/apps/web/src/routes/(app)/notes/+page.svelte b/apps/mana/apps/web/src/routes/(app)/notes/+page.svelte index a49a6da30..d7c1900d1 100644 --- a/apps/mana/apps/web/src/routes/(app)/notes/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/notes/+page.svelte @@ -6,6 +6,7 @@ import { searchNotes, getPreview, formatRelativeTime } from '$lib/modules/notes/queries'; import { notesStore } from '$lib/modules/notes/stores/notes.svelte'; import { NOTE_COLORS } from '$lib/modules/notes/types'; + import AiProposalInbox from '$lib/components/ai/AiProposalInbox.svelte'; const allNotes$: Observable = getContext('notes'); @@ -51,6 +52,7 @@
+

Notes

diff --git a/packages/shared-ai/src/policy/proposable-tools.ts b/packages/shared-ai/src/policy/proposable-tools.ts index b3f5618cc..c86e35040 100644 --- a/packages/shared-ai/src/policy/proposable-tools.ts +++ b/packages/shared-ai/src/policy/proposable-tools.ts @@ -25,6 +25,9 @@ export const AI_PROPOSABLE_TOOL_NAMES = [ 'visit_place', 'undo_drink', 'save_news_article', + 'update_note', + 'append_to_note', + 'add_tag_to_note', ] as const; export type AiProposableToolName = (typeof AI_PROPOSABLE_TOOL_NAMES)[number]; diff --git a/services/mana-ai/src/planner/tools.ts b/services/mana-ai/src/planner/tools.ts index 263d084e8..7f5a794ea 100644 --- a/services/mana-ai/src/planner/tools.ts +++ b/services/mana-ai/src/planner/tools.ts @@ -104,6 +104,48 @@ export const AI_AVAILABLE_TOOLS: readonly AvailableTool[] = [ }, ], }, + { + 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, + }, + ], + }, ]; export const AI_AVAILABLE_TOOL_NAMES = new Set(AI_AVAILABLE_TOOLS.map((t) => t.name));