feat(writing): M8 — AI tools exposed through the shared catalog

Writing is now programmatically accessible from the foreground mission
runner, personas, and Claude Desktop / MCP. Eight tools land:

Auto (read-only):
- list_drafts         — filtered by kind/status + word-count summary
- get_draft           — briefing + current version body, ready for reading
- list_writing_styles — 9 presets + user customs, ids usable in create_draft

Propose (human approval per agent policy):
- create_draft            — briefing only, no generation yet
- generate_draft_content  — wraps generationsStore.startDraftGeneration;
                            writes a new LocalDraftVersion + pointer flip
- refine_draft_selection  — wraps refineSelection + applyRefinement in
                            one call; operations: shorten/expand/tone/
                            rewrite/translate with op-specific params
- set_draft_status        — draft/refining/complete/published
- save_draft_as_article   — hand-off to articlesStore.saveFromExtracted
                            with internal://writing/<id> as originalUrl,
                            records publishedTo + emits WritingDraftPublished

Schemas live in @mana/shared-ai/src/tools/schemas.ts (the SSOT that the
web-app policy layer + mana-ai planner derive from). Executors live in
modules/writing/tools.ts and delegate to the existing stores so the
encryption + event pipeline runs once regardless of who called the tool.
Registration added to data/tools/init.ts.

107 shared-ai tests still pass. CLAUDE.md tool-coverage table bumped:
67→75 tools, 21→22 modules.

Not in M8 (deferred): agent.defaultWritingStyleId linkage (needs a
Persona schema change + runner wiring), mana-tool-registry Zod specs
(add when a non-web MCP client needs richer validation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 16:19:30 +02:00
parent cc51f0b4b9
commit d49ad239d9
4 changed files with 704 additions and 1 deletions

View file

@ -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`.

View file

@ -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;
}

View file

@ -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<string, LocalDraft>();
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<string, LocalDraftVersion>();
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<typeof toDraftVersion> | 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:<name>) + 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) };
}
},
},
];