mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
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:
parent
fdc1c0023a
commit
4d9b16a683
4 changed files with 252 additions and 0 deletions
|
|
@ -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<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[] = [
|
||||
{
|
||||
|
|
@ -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`,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<Note[]> = getContext('notes');
|
||||
|
||||
|
|
@ -51,6 +52,7 @@
|
|||
</svelte:head>
|
||||
|
||||
<div class="notes-page">
|
||||
<AiProposalInbox module="notes" />
|
||||
<header class="notes-header">
|
||||
<div>
|
||||
<h1 class="notes-title">Notes</h1>
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue