feat(notes): list + update + append + add_tag tools for the AI

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-16 00:24:48 +02:00
parent fdc1c0023a
commit 4d9b16a683
4 changed files with 252 additions and 0 deletions

View file

@ -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 type { ModuleTool } from '$lib/data/tools/types';
import { notesStore } from './stores/notes.svelte'; 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<LocalNote | null> {
const local = await db.table<LocalNote>('notes').get(id);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('notes', [local]);
return decrypted ?? null;
}
export const notesTools: ModuleTool[] = [ 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<LocalNote>('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`,
};
},
},
]; ];

View file

@ -6,6 +6,7 @@
import { searchNotes, getPreview, formatRelativeTime } from '$lib/modules/notes/queries'; import { searchNotes, getPreview, formatRelativeTime } from '$lib/modules/notes/queries';
import { notesStore } from '$lib/modules/notes/stores/notes.svelte'; import { notesStore } from '$lib/modules/notes/stores/notes.svelte';
import { NOTE_COLORS } from '$lib/modules/notes/types'; import { NOTE_COLORS } from '$lib/modules/notes/types';
import AiProposalInbox from '$lib/components/ai/AiProposalInbox.svelte';
const allNotes$: Observable<Note[]> = getContext('notes'); const allNotes$: Observable<Note[]> = getContext('notes');
@ -51,6 +52,7 @@
</svelte:head> </svelte:head>
<div class="notes-page"> <div class="notes-page">
<AiProposalInbox module="notes" />
<header class="notes-header"> <header class="notes-header">
<div> <div>
<h1 class="notes-title">Notes</h1> <h1 class="notes-title">Notes</h1>

View file

@ -25,6 +25,9 @@ export const AI_PROPOSABLE_TOOL_NAMES = [
'visit_place', 'visit_place',
'undo_drink', 'undo_drink',
'save_news_article', 'save_news_article',
'update_note',
'append_to_note',
'add_tag_to_note',
] as const; ] as const;
export type AiProposableToolName = (typeof AI_PROPOSABLE_TOOL_NAMES)[number]; export type AiProposableToolName = (typeof AI_PROPOSABLE_TOOL_NAMES)[number];

View file

@ -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<string>(AI_AVAILABLE_TOOLS.map((t) => t.name)); export const AI_AVAILABLE_TOOL_NAMES = new Set<string>(AI_AVAILABLE_TOOLS.map((t) => t.name));