diff --git a/apps/mana/CLAUDE.md b/apps/mana/CLAUDE.md index 38c458285..ca3b5ac48 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 (67 tools, 21 modules) +### Tool Coverage (75 tools, 22 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. @@ -237,6 +237,7 @@ Agents interact with the app through tools — each one either auto (executes si | 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` | +| writing | `create_draft`, `generate_draft_content`, `refine_draft_selection`, `set_draft_status`, `save_draft_as_article` | `list_drafts`, `get_draft`, `list_writing_styles` | **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 85ed3d105..ae6ae12e3 100644 --- a/apps/mana/apps/web/src/lib/data/tools/init.ts +++ b/apps/mana/apps/web/src/lib/data/tools/init.ts @@ -46,6 +46,7 @@ import { invoicesTools } from '$lib/modules/invoices/tools'; import { libraryTools } from '$lib/modules/library/tools'; import { broadcastTools } from '$lib/modules/broadcast/tools'; import { websiteTools } from '$lib/modules/website/tools'; +import { writingTools } from '$lib/modules/writing/tools'; let initialized = false; @@ -93,5 +94,6 @@ export function initTools(): void { registerTools(libraryTools); registerTools(broadcastTools); registerTools(websiteTools); + registerTools(writingTools); initialized = true; } diff --git a/apps/mana/apps/web/src/lib/modules/writing/tools.ts b/apps/mana/apps/web/src/lib/modules/writing/tools.ts new file mode 100644 index 000000000..83ae0ccda --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/tools.ts @@ -0,0 +1,493 @@ +/** + * Writing module tools — AI-accessible operations over drafts + styles. + * + * Auto (read-only): + * - list_drafts + * - get_draft + * - list_writing_styles + * + * Propose (human approval per the agent's policy): + * - create_draft + * - generate_draft_content + * - refine_draft_selection + * - set_draft_status + * - save_draft_as_article + * + * All writes delegate to the existing stores so the encryption + events + * pipeline runs once, no matter whether the call came from the UI, + * the foreground mission runner, or an external MCP client. + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import { draftsStore } from './stores/drafts.svelte'; +import { generationsStore } from './stores/generations.svelte'; +import { draftTable, draftVersionTable } from './collections'; +import { articlesStore } from '$lib/modules/articles/stores/articles.svelte'; +import { decryptRecords, VaultLockedError } from '$lib/data/crypto'; +import { toDraft, toDraftVersion } from './queries'; +import { STYLE_PRESETS } from './presets/styles'; +import { writingStyleTable } from './collections'; +import type { + LocalDraft, + LocalDraftVersion, + LocalWritingStyle, + DraftKind, + DraftStatus, +} from './types'; + +const KINDS: DraftKind[] = [ + 'blog', + 'essay', + 'email', + 'social', + 'story', + 'letter', + 'speech', + 'cover-letter', + 'product-description', + 'press-release', + 'bio', + 'other', +]; +const STATUSES: DraftStatus[] = ['draft', 'refining', 'complete', 'published']; +const REFINE_OPS = ['shorten', 'expand', 'tone', 'rewrite', 'translate'] as const; +type RefineOp = (typeof REFINE_OPS)[number]; +const REFINE_KIND_MAP: Record< + RefineOp, + | 'selection-shorten' + | 'selection-expand' + | 'selection-tone' + | 'selection-rewrite' + | 'selection-translate' +> = { + shorten: 'selection-shorten', + expand: 'selection-expand', + tone: 'selection-tone', + rewrite: 'selection-rewrite', + translate: 'selection-translate', +}; + +export const writingTools: ModuleTool[] = [ + { + name: 'list_drafts', + module: 'writing', + description: + 'Listet Writing-Drafts (id, kind, title, status, wordCount). Optional nach kind/status filterbar.', + parameters: [ + { name: 'kind', type: 'string', description: 'Nur eine Textart', 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 DraftKind | undefined; + const statusFilter = params.status as DraftStatus | undefined; + const limit = Math.min(Math.max(Number(params.limit) || 30, 1), 100); + + try { + const drafts = await draftTable.toArray(); + const visible = drafts.filter((d) => !d.deletedAt); + const decrypted = await decryptRecords('writingDrafts', visible); + const byId = new Map(); + for (const d of decrypted) byId.set(d.id, d); + + // Pull current versions in one batch so the listing can report + // word-counts without per-row queries. + const versionIds = decrypted + .map((d) => d.currentVersionId) + .filter((id): id is string => !!id); + const versionRows = (await draftVersionTable.bulkGet(versionIds)).filter( + (v): v is LocalDraftVersion => !!v && !v.deletedAt + ); + const versionsDecrypted = await decryptRecords('writingDraftVersions', versionRows); + const versionById = new Map(); + for (const v of versionsDecrypted) versionById.set(v.id, v); + + const rows = decrypted + .map(toDraft) + .filter((d) => (kindFilter ? d.kind === kindFilter : true)) + .filter((d) => (statusFilter ? d.status === statusFilter : true)) + .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) + .slice(0, limit) + .map((d) => { + const v = d.currentVersionId ? versionById.get(d.currentVersionId) : undefined; + return { + id: d.id, + kind: d.kind, + title: d.title, + status: d.status, + wordCount: v?.wordCount ?? 0, + updatedAt: d.updatedAt, + }; + }); + + return { + success: true, + data: { drafts: rows, total: rows.length }, + message: `${rows.length} Draft(s) gelistet`, + }; + } catch (err) { + if (err instanceof VaultLockedError) { + return { + success: false, + message: 'Vault ist gesperrt — Writing kann nicht entschlüsselt werden', + }; + } + throw err; + } + }, + }, + + { + name: 'get_draft', + module: 'writing', + description: + 'Liefert einen vollstaendigen Draft inklusive Briefing, aktueller Version, Stil-ID und Quellen.', + parameters: [{ name: 'draftId', type: 'string', description: 'ID des Drafts', required: true }], + async execute(params) { + const draftId = String(params.draftId ?? ''); + if (!draftId) return { success: false, message: 'draftId erforderlich' }; + + try { + const local = await draftTable.get(draftId); + if (!local || local.deletedAt) { + return { success: false, message: `Draft ${draftId} nicht gefunden` }; + } + const [decrypted] = await decryptRecords('writingDrafts', [local]); + if (!decrypted) return { success: false, message: 'Entschlüsselung fehlgeschlagen' }; + const draft = toDraft(decrypted); + + let version = null as ReturnType | null; + if (draft.currentVersionId) { + const vLocal = await draftVersionTable.get(draft.currentVersionId); + if (vLocal && !vLocal.deletedAt) { + const [vDec] = await decryptRecords('writingDraftVersions', [vLocal]); + if (vDec) version = toDraftVersion(vDec); + } + } + + return { + success: true, + data: { + draft: { + id: draft.id, + kind: draft.kind, + status: draft.status, + title: draft.title, + briefing: draft.briefing, + styleId: draft.styleId, + references: draft.references, + visibility: draft.visibility, + publishedTo: draft.publishedTo, + createdAt: draft.createdAt, + updatedAt: draft.updatedAt, + }, + version: version + ? { + id: version.id, + versionNumber: version.versionNumber, + content: version.content, + wordCount: version.wordCount, + isAiGenerated: version.isAiGenerated, + } + : null, + }, + message: `Draft "${draft.title}" (${draft.kind})`, + }; + } catch (err) { + if (err instanceof VaultLockedError) { + return { success: false, message: 'Vault ist gesperrt' }; + } + throw err; + } + }, + }, + + { + name: 'list_writing_styles', + module: 'writing', + description: + 'Listet verfuegbare Schreibstile: 9 Presets (id=preset:) + alle Custom-Styles (uuid).', + parameters: [], + async execute() { + try { + const presets = STYLE_PRESETS.map((p) => ({ + id: `preset:${p.id}`, + name: p.name.de, + description: p.description.de, + source: 'preset' as const, + })); + + const rows = await writingStyleTable.toArray(); + const visible = rows.filter((s) => !s.deletedAt); + const decrypted = await decryptRecords('writingStyles', visible); + const customs = (decrypted as LocalWritingStyle[]).map((s) => ({ + id: s.id, + name: s.name, + description: s.description, + source: s.source, + })); + + return { + success: true, + data: { presets, customs, total: presets.length + customs.length }, + message: `${presets.length} Vorlagen + ${customs.length} eigene Stile`, + }; + } catch (err) { + if (err instanceof VaultLockedError) { + return { success: false, message: 'Vault ist gesperrt' }; + } + throw err; + } + }, + }, + + { + name: 'create_draft', + module: 'writing', + description: 'Legt einen neuen Writing-Draft mit Briefing an (ohne Generation).', + parameters: [ + { name: 'kind', type: 'string', description: 'Textart', required: true }, + { name: 'title', type: 'string', description: 'Titel', required: true }, + { name: 'topic', type: 'string', description: 'Kern-Briefing', required: true }, + { name: 'audience', type: 'string', description: 'Zielgruppe', required: false }, + { name: 'tone', type: 'string', description: 'Ton', required: false }, + { name: 'language', type: 'string', description: 'Sprachcode', required: false }, + { name: 'targetWords', type: 'number', description: 'Ziel-Laenge', required: false }, + { name: 'styleId', type: 'string', description: 'Stil-ID', required: false }, + { name: 'extraInstructions', type: 'string', description: 'Extra-Hinweise', required: false }, + ], + async execute(params) { + const kind = params.kind as DraftKind; + if (!KINDS.includes(kind)) return { success: false, message: `Unbekannte Art: ${kind}` }; + const title = String(params.title ?? '').trim(); + const topic = String(params.topic ?? '').trim(); + if (!title) return { success: false, message: 'title erforderlich' }; + if (!topic) return { success: false, message: 'topic erforderlich' }; + + const targetWordsRaw = + typeof params.targetWords === 'number' ? Math.round(params.targetWords) : null; + const { draft } = await draftsStore.createDraft({ + kind, + title, + styleId: + typeof params.styleId === 'string' && params.styleId.length > 0 ? params.styleId : null, + briefing: { + topic, + audience: typeof params.audience === 'string' ? params.audience : null, + tone: typeof params.tone === 'string' ? params.tone : null, + language: typeof params.language === 'string' ? params.language : 'de', + targetLength: targetWordsRaw + ? { type: 'words' as const, value: targetWordsRaw } + : undefined, + extraInstructions: + typeof params.extraInstructions === 'string' ? params.extraInstructions : null, + }, + }); + + return { + success: true, + data: { draftId: draft.id, kind: draft.kind, title: draft.title }, + message: `Draft "${draft.title}" angelegt`, + }; + }, + }, + + { + name: 'generate_draft_content', + module: 'writing', + description: + 'Erzeugt Text fuer einen existierenden Draft und schreibt eine neue Version. Flippt currentVersionId.', + parameters: [{ name: 'draftId', type: 'string', description: 'ID des Drafts', required: true }], + async execute(params) { + const draftId = String(params.draftId ?? ''); + if (!draftId) return { success: false, message: 'draftId erforderlich' }; + try { + const generationId = await generationsStore.startDraftGeneration(draftId); + return { + success: true, + data: { draftId, generationId }, + message: 'Text generiert und als neue Version gespeichert', + }; + } catch (err) { + return { + success: false, + message: err instanceof Error ? err.message : String(err), + }; + } + }, + }, + + { + name: 'refine_draft_selection', + module: 'writing', + description: + 'Verfeinert einen markierten Ausschnitt in der aktuellen Version — shorten/expand/tone/rewrite/translate. In-place auf current version.', + parameters: [ + { name: 'draftId', type: 'string', description: 'ID des Drafts', required: true }, + { + name: 'operation', + type: 'string', + description: 'shorten|expand|tone|rewrite|translate', + required: true, + }, + { name: 'selectionStart', type: 'number', description: 'Start (0-basiert)', required: true }, + { name: 'selectionEnd', type: 'number', description: 'Ende (exklusiv)', required: true }, + { name: 'targetTone', type: 'string', description: 'fuer operation=tone', required: false }, + { + name: 'instruction', + type: 'string', + description: 'fuer operation=rewrite', + required: false, + }, + { + name: 'targetLanguage', + type: 'string', + description: 'fuer operation=translate', + required: false, + }, + ], + async execute(params) { + const draftId = String(params.draftId ?? ''); + const op = params.operation as RefineOp; + if (!REFINE_OPS.includes(op)) { + return { success: false, message: `Unbekannte operation: ${op}` }; + } + const start = Number(params.selectionStart); + const end = Number(params.selectionEnd); + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) { + return { success: false, message: 'Ungueltige Auswahl-Range' }; + } + + try { + const draft = await draftTable.get(draftId); + if (!draft || draft.deletedAt || !draft.currentVersionId) { + return { success: false, message: `Draft ${draftId} oder aktuelle Version fehlt` }; + } + const versionLocal = await draftVersionTable.get(draft.currentVersionId); + if (!versionLocal || versionLocal.deletedAt) { + return { success: false, message: 'Aktuelle Version fehlt' }; + } + const [versionDec] = await decryptRecords('writingDraftVersions', [versionLocal]); + if (!versionDec) return { success: false, message: 'Entschlüsselung fehlgeschlagen' }; + const content = versionDec.content ?? ''; + const clampedEnd = Math.min(end, content.length); + if (start < 0 || start >= content.length) { + return { success: false, message: 'selectionStart ausserhalb des Textes' }; + } + const text = content.slice(start, clampedEnd); + if (!text.trim()) return { success: false, message: 'Auswahl ist leer' }; + + const paramsForStore: + | { targetTone: string } + | { instruction: string } + | { targetLanguage: string } + | undefined = + op === 'tone' + ? { targetTone: String(params.targetTone ?? '').trim() || 'neutral' } + : op === 'rewrite' + ? { instruction: String(params.instruction ?? '').trim() } + : op === 'translate' + ? { targetLanguage: String(params.targetLanguage ?? '').trim() || 'en' } + : undefined; + + if (op === 'rewrite' && !(paramsForStore as { instruction: string }).instruction) { + return { success: false, message: 'instruction erforderlich fuer rewrite' }; + } + + const { generationId, refined } = await generationsStore.refineSelection( + draftId, + draft.currentVersionId, + { start, end: clampedEnd, text }, + REFINE_KIND_MAP[op], + paramsForStore as never + ); + await generationsStore.applyRefinement( + draft.currentVersionId, + { start, end: clampedEnd }, + refined, + generationId + ); + return { + success: true, + data: { draftId, generationId, refined }, + message: `Auswahl via ${op} verfeinert`, + }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : String(err) }; + } + }, + }, + + { + name: 'set_draft_status', + module: 'writing', + description: 'Setzt den Status eines Drafts (draft/refining/complete/published).', + parameters: [ + { name: 'draftId', type: 'string', description: 'ID', required: true }, + { name: 'status', type: 'string', description: 'Neuer Status', required: true }, + ], + async execute(params) { + const draftId = String(params.draftId ?? ''); + const status = params.status as DraftStatus; + if (!STATUSES.includes(status)) { + return { success: false, message: `Unbekannter Status: ${status}` }; + } + await draftsStore.setStatus(draftId, status); + return { + success: true, + data: { draftId, status }, + message: `Status auf "${status}" gesetzt`, + }; + }, + }, + + { + name: 'save_draft_as_article', + module: 'writing', + description: + 'Veroeffentlicht die aktuelle Version als Read-Later-Artikel im articles-Modul und traegt das Ziel in publishedTo ein.', + parameters: [{ name: 'draftId', type: 'string', description: 'ID des Drafts', required: true }], + async execute(params) { + const draftId = String(params.draftId ?? ''); + try { + const draftLocal = await draftTable.get(draftId); + if (!draftLocal || draftLocal.deletedAt) { + return { success: false, message: `Draft ${draftId} nicht gefunden` }; + } + const [draftDec] = await decryptRecords('writingDrafts', [draftLocal]); + if (!draftDec) return { success: false, message: 'Entschlüsselung fehlgeschlagen' }; + const draft = toDraft(draftDec); + + let content = ''; + if (draft.currentVersionId) { + const vLocal = await draftVersionTable.get(draft.currentVersionId); + if (vLocal && !vLocal.deletedAt) { + const [vDec] = await decryptRecords('writingDraftVersions', [vLocal]); + if (vDec) content = vDec.content ?? ''; + } + } + + const wordCount = content.trim().split(/\s+/).filter(Boolean).length; + const article = await articlesStore.saveFromExtracted({ + originalUrl: `internal://writing/${draft.id}`, + title: draft.title || draft.briefing.topic || 'Unbenannt', + excerpt: content.slice(0, 240).trim() || null, + content, + htmlContent: content, + author: null, + siteName: 'Writing', + wordCount, + readingTimeMinutes: Math.max(1, Math.round(wordCount / 200)), + }); + await draftsStore.recordPublish(draft.id, 'articles', article.id); + return { + success: true, + data: { draftId: draft.id, articleId: article.id }, + message: `Als Artikel gespeichert (id=${article.id})`, + }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : String(err) }; + } + }, + }, +]; diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts index cacc0b9e6..e5e26f3e1 100644 --- a/packages/shared-ai/src/tools/schemas.ts +++ b/packages/shared-ai/src/tools/schemas.ts @@ -1671,6 +1671,213 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ defaultPolicy: 'auto', parameters: [{ name: 'pageId', type: 'string', description: 'ID der Seite', required: true }], }, + + // ── Writing (Ghostwriter) ───────────────────────────────── + { + name: 'list_drafts', + module: 'writing', + description: + 'Listet Writing-Drafts (id, kind, title, status, wordCount). Optional nach kind oder status filterbar.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'kind', + type: 'string', + description: 'Nur eine Textart zeigen', + required: false, + enum: [ + 'blog', + 'essay', + 'email', + 'social', + 'story', + 'letter', + 'speech', + 'cover-letter', + 'product-description', + 'press-release', + 'bio', + 'other', + ], + }, + { + name: 'status', + type: 'string', + description: 'Nur einen Status zeigen', + required: false, + enum: ['draft', 'refining', 'complete', 'published'], + }, + { + name: 'limit', + type: 'number', + description: 'Maximale Anzahl (Standard 30)', + required: false, + }, + ], + }, + { + name: 'get_draft', + module: 'writing', + description: + 'Liefert einen vollstaendigen Draft inklusive Briefing, aktueller Version, Stil und Quellen.', + defaultPolicy: 'auto', + parameters: [{ name: 'draftId', type: 'string', description: 'ID des Drafts', required: true }], + }, + { + name: 'list_writing_styles', + module: 'writing', + description: + 'Listet verfuegbare Schreibstile (9 eingebaute Presets + vom Nutzer angelegte). Jeder mit id (preset: oder uuid), name und Kurzbeschreibung.', + defaultPolicy: 'auto', + parameters: [], + }, + { + name: 'create_draft', + module: 'writing', + description: + 'Legt einen neuen Writing-Draft mit Briefing an — noch ohne Generation. Optional mit Stil und Quellen. Danach via generate_draft_content die erste Version erzeugen.', + defaultPolicy: 'propose', + parameters: [ + { + name: 'kind', + type: 'string', + description: 'Textart', + required: true, + enum: [ + 'blog', + 'essay', + 'email', + 'social', + 'story', + 'letter', + 'speech', + 'cover-letter', + 'product-description', + 'press-release', + 'bio', + 'other', + ], + }, + { name: 'title', type: 'string', description: 'Titel / Arbeitstitel', required: true }, + { + name: 'topic', + type: 'string', + description: 'Kern-Briefing (worum geht es?)', + required: true, + }, + { name: 'audience', type: 'string', description: 'Zielgruppe', required: false }, + { + name: 'tone', + type: 'string', + description: 'Ton (z.B. "neutral", "warm")', + required: false, + }, + { + name: 'language', + type: 'string', + description: 'ISO-Sprachcode, Standard "de"', + required: false, + }, + { + name: 'targetWords', + type: 'number', + description: 'Ziel-Laenge in Woertern', + required: false, + }, + { + name: 'styleId', + type: 'string', + description: 'Stil-ID (preset: oder uuid einer Custom-Style-Row)', + required: false, + }, + { + name: 'extraInstructions', + type: 'string', + description: 'Zusatzhinweise fuer die Generation', + required: false, + }, + ], + }, + { + name: 'generate_draft_content', + module: 'writing', + description: + 'Erzeugt Text fuer einen existierenden Draft. Schreibt eine neue LocalDraftVersion und flippt den currentVersionId-Pointer auf die neue Version. Nutzt Briefing + Stil + Quellen des Drafts.', + defaultPolicy: 'propose', + parameters: [{ name: 'draftId', type: 'string', description: 'ID des Drafts', required: true }], + }, + { + name: 'refine_draft_selection', + module: 'writing', + description: + 'Verfeinert einen markierten Ausschnitt der aktuellen Version in-place. Operationen: shorten, expand, tone (target), rewrite (instruction), translate (targetLanguage). Wird direkt auf die aktuelle Version angewandt — keine neue Version.', + defaultPolicy: 'propose', + parameters: [ + { name: 'draftId', type: 'string', description: 'ID des Drafts', required: true }, + { + name: 'operation', + type: 'string', + description: 'Art der Verfeinerung', + required: true, + enum: ['shorten', 'expand', 'tone', 'rewrite', 'translate'], + }, + { + name: 'selectionStart', + type: 'number', + description: 'Zeichen-Start der Auswahl (0-basiert)', + required: true, + }, + { + name: 'selectionEnd', + type: 'number', + description: 'Zeichen-Ende der Auswahl (exklusiv)', + required: true, + }, + { + name: 'targetTone', + type: 'string', + description: 'Nur fuer operation=tone: der Zielton', + required: false, + }, + { + name: 'instruction', + type: 'string', + description: 'Nur fuer operation=rewrite: die Anweisung', + required: false, + }, + { + name: 'targetLanguage', + type: 'string', + description: 'Nur fuer operation=translate: ISO-Code der Zielsprache', + required: false, + }, + ], + }, + { + name: 'set_draft_status', + module: 'writing', + description: + 'Setzt den Status eines Drafts (draft/refining/complete/published). Emittiert WritingDraftStatusChanged fuer die Timeline.', + defaultPolicy: 'propose', + parameters: [ + { name: 'draftId', type: 'string', description: 'ID des Drafts', required: true }, + { + name: 'status', + type: 'string', + description: 'Neuer Status', + required: true, + enum: ['draft', 'refining', 'complete', 'published'], + }, + ], + }, + { + name: 'save_draft_as_article', + module: 'writing', + description: + 'Veroeffentlicht die aktuelle Version des Drafts als Read-Later-Artikel im articles-Modul. Traegt das Ziel in draft.publishedTo ein und emittiert WritingDraftPublished.', + defaultPolicy: 'propose', + parameters: [{ name: 'draftId', type: 'string', description: 'ID des Drafts', required: true }], + }, ]; // ═══════════════════════════════════════════════════════════════