feat(articles): M6 AI tools — list / save / archive / tag / highlight

Five new entries in AI_TOOL_CATALOG (shared-ai/src/tools/schemas.ts):

  list_articles            auto     Read-only listing with status +
                                    query filter. Default hides
                                    archived; 'all' includes them.
  save_article             propose  URL → Readability → encrypted save.
                                    Delegates to articlesStore.saveFromUrl
                                    which already handles scope-aware
                                    dedupe. Duplicates surface as
                                    success:true with duplicate:true.
  archive_article          propose  setStatus('archived') after
                                    scoped existence check.
  tag_article              propose  Case-insensitive dedupe over
                                    globalTags; tagMutations.createTag
                                    fills in when missing. Junction
                                    write via articleTagOps.addTag.
  add_article_highlight    propose  Snaps to the first verbatim
                                    occurrence of `text` in the
                                    decrypted article.content. Fails
                                    cleanly when the snippet isn't
                                    found — no orphan highlights.

Policy, client executor, and server planner derive automatically from
the catalog (see root CLAUDE.md §"AI Tool Catalog") so no manual
registration in policy.ts / services/mana-ai is needed.

Skipped from the M6 plan: <AiProposalInbox module="articles" />. The
component doesn't exist in the current codebase — after the
pendingProposals-table drop in Dexie v29 the inbox surface moved to
the mission-detail cross-module view, and articles proposals show up
there automatically. Documented in docs/plans/articles-module.md.

Also updated: plan doc now marks M1–M6 as DONE with commit refs and
the next-step pointer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-21 18:46:13 +02:00
parent 12be75e6a6
commit 5924f4fac3
4 changed files with 447 additions and 1 deletions

View file

@ -31,6 +31,7 @@ import { inventoryTools } from '$lib/modules/inventory/tools';
import { plantsTools } from '$lib/modules/plants/tools';
import { newsTools } from '$lib/modules/news/tools';
import { newsResearchTools } from '$lib/modules/news-research/tools';
import { articlesTools } from '$lib/modules/articles/tools';
import { recipesTools } from '$lib/modules/recipes/tools';
import { questionsTools } from '$lib/modules/questions/tools';
import { meditateTools } from '$lib/modules/meditate/tools';
@ -76,6 +77,7 @@ export function initTools(): void {
registerTools(plantsTools);
registerTools(newsTools);
registerTools(newsResearchTools);
registerTools(articlesTools);
registerTools(recipesTools);
registerTools(questionsTools);
registerTools(meditateTools);

View file

@ -0,0 +1,308 @@
/**
* Articles AI Tools LLM-accessible operations for the articles module.
*
* Catalog entries live in `@mana/shared-ai/src/tools/schemas.ts` and drive
* the policy layer + server planner automatically; this file contributes
* the execute-side glue.
*
* list_articles auto Read-only listing for agent context.
* save_article propose URL Readability encrypted save.
* Legacy `save_news_article` kept as
* alias in `modules/news/tools.ts`.
* archive_article propose Flips status 'archived'.
* tag_article propose Creates (or reuses) a global tag by
* name and links it to the article.
* add_article_highlight propose Persists a highlight anchored to the
* first verbatim occurrence of `text`
* in the article's plain content. Fails
* gracefully if the snippet isn't found.
*/
import { decryptRecords } from '$lib/data/crypto';
import { scopedForModule, scopedGet } from '$lib/data/scope';
import { tagMutations, useAllTags } from '@mana/shared-stores';
import type { ModuleTool } from '$lib/data/tools/types';
import { articlesStore } from './stores/articles.svelte';
import { highlightsStore } from './stores/highlights.svelte';
import { articleTagOps } from './stores/tags.svelte';
import { toArticle } from './queries';
import type { HighlightColor, LocalArticle, ArticleStatus } from './types';
const DEFAULT_LIST_LIMIT = 30;
const MAX_LIST_LIMIT = 100;
const MIN_HIGHLIGHT_TEXT = 10;
const MAX_HIGHLIGHT_TEXT = 500;
export const articlesTools: ModuleTool[] = [
{
name: 'list_articles',
module: 'articles',
description:
'Listet gespeicherte Artikel (id, title, siteName, status, readingTime). Optional nach Status filtern.',
parameters: [
{
name: 'status',
type: 'string',
description:
'Nur Artikel mit diesem Status. Default: ohne Filter (archivierte werden nur bei "archived"/"all" eingeschlossen).',
required: false,
enum: ['unread', 'reading', 'finished', 'archived', 'all'],
},
{
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 / Autor / Quelle',
required: false,
},
],
async execute(params) {
const limit = Math.min(
Math.max(Number(params.limit) || DEFAULT_LIST_LIMIT, 1),
MAX_LIST_LIMIT
);
const statusFilter = typeof params.status === 'string' ? params.status : '';
const query = typeof params.query === 'string' ? params.query.toLowerCase().trim() : '';
const locals = await scopedForModule<LocalArticle, string>('articles', 'articles').toArray();
const visible = locals.filter((a) => {
if (a.deletedAt) return false;
if (statusFilter === 'all') return true;
if (statusFilter === '' || !statusFilter) return a.status !== 'archived';
return a.status === statusFilter;
});
const decrypted = await decryptRecords('articles', visible);
const matches = query
? decrypted.filter(
(a) =>
a.title.toLowerCase().includes(query) ||
(a.author?.toLowerCase().includes(query) ?? false) ||
(a.siteName?.toLowerCase().includes(query) ?? false)
)
: decrypted;
const rows = matches
.sort((a, b) => (b.savedAt ?? '').localeCompare(a.savedAt ?? ''))
.slice(0, limit)
.map((a) => ({
id: a.id,
title: a.title,
siteName: a.siteName,
status: a.status,
readingTimeMinutes: a.readingTimeMinutes,
url: a.originalUrl,
savedAt: a.savedAt,
}));
return {
success: true,
message: `${rows.length} Artikel gefunden`,
data: { articles: rows, total: matches.length },
};
},
},
{
name: 'save_article',
module: 'articles',
description:
'Speichert einen Artikel von einer URL in die Leseliste. URL wird serverseitig per Readability extrahiert.',
parameters: [
{ name: 'url', type: 'string', description: 'Die Artikel-URL', required: true },
{
name: 'title',
type: 'string',
description: 'Anzeigetitel für den Approval-Dialog (informativ)',
required: false,
},
{
name: 'reason',
type: 'string',
description: 'Kurze Begründung warum der Artikel für den Nutzer relevant ist',
required: false,
},
],
async execute(params) {
const url = String(params.url ?? '').trim();
if (!url) return { success: false, message: 'URL fehlt' };
const { article, duplicate } = await articlesStore.saveFromUrl(url);
return {
success: true,
message: duplicate
? `Artikel bereits gespeichert: ${article.title}`
: `Artikel gespeichert: ${article.title}`,
data: { articleId: article.id, title: article.title, duplicate },
};
},
},
{
name: 'archive_article',
module: 'articles',
description: 'Verschiebt einen Artikel ins Archiv.',
parameters: [
{
name: 'articleId',
type: 'string',
description: 'ID des Artikels (aus list_articles)',
required: true,
},
],
async execute(params) {
const id = String(params.articleId ?? '').trim();
if (!id) return { success: false, message: 'articleId fehlt' };
const existing = await scopedGet<LocalArticle>('articles', id);
if (!existing || existing.deletedAt) {
return { success: false, message: `Kein Artikel mit id ${id}` };
}
await articlesStore.setStatus(id, 'archived' satisfies ArticleStatus);
return { success: true, message: 'Artikel archiviert', data: { articleId: id } };
},
},
{
name: 'tag_article',
module: 'articles',
description:
'Vergibt einen Tag auf einen Artikel. Tag wird angelegt falls er noch nicht existiert.',
parameters: [
{
name: 'articleId',
type: 'string',
description: 'ID des Artikels (aus list_articles)',
required: true,
},
{
name: 'tagName',
type: 'string',
description: 'Tag-Name (z.B. "KI", "lesen bald")',
required: true,
},
],
async execute(params) {
const id = String(params.articleId ?? '').trim();
const rawName = String(params.tagName ?? '').trim();
if (!id) return { success: false, message: 'articleId fehlt' };
if (!rawName) return { success: false, message: 'tagName fehlt' };
const name = rawName.slice(0, 60);
const existing = await scopedGet<LocalArticle>('articles', id);
if (!existing || existing.deletedAt) {
return { success: false, message: `Kein Artikel mit id ${id}` };
}
// useAllTags().value works even outside a Svelte reactive scope —
// it returns the current in-memory snapshot. Match by lower-case
// name so "KI" and "ki" dedupe.
const pool = useAllTags().value;
const needle = name.toLowerCase();
let tag = pool.find((t) => t.name.toLowerCase() === needle);
if (!tag) {
tag = await tagMutations.createTag({ name });
}
await articleTagOps.addTag(id, tag.id);
return {
success: true,
message: `Tag „${tag.name}" gesetzt`,
data: {
articleId: id,
tagId: tag.id,
tagName: tag.name,
created: !pool.some((t) => t.id === tag!.id),
},
};
},
},
{
name: 'add_article_highlight',
module: 'articles',
description:
'Markiert eine Textstelle in einem Artikel als Highlight. Der Text muss wörtlich im Artikel vorkommen.',
parameters: [
{
name: 'articleId',
type: 'string',
description: 'ID des Artikels (aus list_articles)',
required: true,
},
{
name: 'text',
type: 'string',
description: 'Wörtliche Textstelle die markiert werden soll (10500 Zeichen)',
required: true,
},
{
name: 'color',
type: 'string',
description: 'Highlight-Farbe',
required: false,
enum: ['yellow', 'green', 'blue', 'pink'],
},
{
name: 'note',
type: 'string',
description: 'Optionale Notiz zum Highlight',
required: false,
},
],
async execute(params) {
const id = String(params.articleId ?? '').trim();
const text = String(params.text ?? '').trim();
const color = (params.color as HighlightColor | undefined) ?? 'yellow';
const note = typeof params.note === 'string' ? params.note.trim() || null : null;
if (!id) return { success: false, message: 'articleId fehlt' };
if (text.length < MIN_HIGHLIGHT_TEXT) {
return { success: false, message: `Text zu kurz (min ${MIN_HIGHLIGHT_TEXT} Zeichen)` };
}
if (text.length > MAX_HIGHLIGHT_TEXT) {
return { success: false, message: `Text zu lang (max ${MAX_HIGHLIGHT_TEXT} Zeichen)` };
}
const existing = await scopedGet<LocalArticle>('articles', id);
if (!existing || existing.deletedAt) {
return { success: false, message: `Kein Artikel mit id ${id}` };
}
const [decrypted] = await decryptRecords('articles', [existing]);
if (!decrypted) return { success: false, message: 'Entschlüsselung fehlgeschlagen' };
const article = toArticle(decrypted);
// Snap to the first verbatim occurrence of the snippet in the
// Readability-extracted plain content. If the AI is hallucinating
// (or the article was re-extracted and the text shifted) we bail
// instead of persisting an orphan highlight.
const startOffset = article.content.indexOf(text);
if (startOffset < 0) {
return { success: false, message: 'Textstelle nicht im Artikel gefunden' };
}
const endOffset = startOffset + text.length;
const contextBefore =
article.content.slice(Math.max(0, startOffset - 40), startOffset) || null;
const contextAfter = article.content.slice(endOffset, endOffset + 40) || null;
const highlight = await highlightsStore.addHighlight({
articleId: id,
text,
color,
note,
startOffset,
endOffset,
contextBefore,
contextAfter,
});
return {
success: true,
message: 'Highlight gesetzt',
data: { highlightId: highlight.id, articleId: id },
};
},
},
];

View file

@ -2,7 +2,21 @@
## Status (2026-04-21)
Proposed. Noch nichts gebaut.
**M1 Skelett: DONE** (commit `3357e88a1`) — Modul registriert, Dexie v33, Encryption-Registry-Einträge (articles + articleHighlights verschlüsselt, articleTags auf Plaintext-Allowlist als FK-Junction ins globale Tag-System), App-Registry + orangefarbenes Icon, Route mountet, Empty-State ListView.
**M2 URL-Save + Reader: DONE** (commit `3357e88a1`) — `POST /api/v1/articles/extract` (ein Endpoint statt zwei — Client cached die Preview und vermeidet doppelten Server-Fetch), AddUrlForm mit scope-aware Dedupe, DetailView mit ReaderView (Serif/Sans, Light/Sepia/Dark, Size-Slider), auto-getrackter Reading-Progress mit Scroll-Restore.
**M3 Highlights: DONE** (commit `3357e88a1`) — TreeWalker-basierte Plain-Text-Offset-Resolution (`lib/offsets.ts`), `highlightsStore`, Floating `HighlightMenu` mit Create/Edit-Modi, `HighlightLayer`-Orchestrator der Overlays bei jedem Highlights- oder HTML-Change unwrap+re-applies (Range.surroundContents pro Text-Node-Slice). Vier Farben mit Dark-Mode-angepassten Alpha-Werten.
**M4 Tags + Filter: DONE** (commit `04293ed5e`) — `useArticleTagIds` / `useArticleTagMap` Live-Queries gegen `articleTagOps`. DetailView bekommt `<TagField>` aus `@mana/shared-ui` mit globalem Tag-Pool; `onChange``articleTagOps.setTags(id, ids)`. ListView: 6 Filter-Chips (Alle / Ungelesen / In Arbeit / Gelesen / Favoriten / Archiv) mit Live-Counts, `<TagChip>` auf jeder Karte, neue `.status-reading`-Pill, Favoriten-Stern.
**M5 Migration von news:type='saved': DONE** (commit `04293ed5e`) — Boot-gated Migration in `modules/articles/migrations/from-news.ts` (localStorage-Sentinel `mana:articles:from-news-migration:v1`), decrypt→re-encrypt zwischen den beiden Field-Allowlists, Status-Mapping `isArchived→archived` / `isRead→finished` / sonst `unread`, Source-Rows werden soft-deletet. News-Code deprecated: `saveFromUrl` + `extractFromUrl` entfernt, `save_news_article` AI-Tool behält seinen Namen (wegen Mission-History) und leitet intern aufs `articles`-Modul um. `/news/add` + `/news/saved` sind Redirects. `news-research` „Speichern"-Buttons routen auf `/articles/[id]`.
**M6 AI-Tools: DONE** (commit pending) — 5 neue Einträge im `AI_TOOL_CATALOG` (`shared-ai/src/tools/schemas.ts`): `list_articles` (auto), `save_article` / `archive_article` / `tag_article` / `add_article_highlight` (alle propose). `modules/articles/tools.ts` enthält die `execute`-Funktionen, registriert in `data/tools/init.ts`. `tag_article` dedupliziert case-insensitive über den globalen Pool und legt Tags via `tagMutations.createTag` an falls nötig. `add_article_highlight` snappt auf die erste wörtliche Fundstelle in `article.content` und lehnt den Call ab wenn der Text nicht exakt vorkommt (kein Orphan-Highlight). Policy/Executor/Server-Planner leiten sich automatisch aus dem Katalog ab.
**Hinweis AiProposalInbox:** Der apps/mana/CLAUDE.md-Abschnitt erwähnt `<AiProposalInbox module="articles" />` als Inline-Mount, aber die Komponente existiert im aktuellen Codebase nicht — nach dem `pendingProposals`-Table-Drop in Dexie v29 wurde die Proposal-Darstellung auf `server-iteration-staging` + den Cross-Module-Inbox im Mission-Detail-View umgestellt. Articles-Proposals tauchen dort automatisch auf. Falls die Inline-Komponente wieder reaktiviert wird, muss nur der Mount in `ListView.svelte` ergänzt werden.
Nächster Schritt: M7 (Share-Target + Bookmarklet) oder M8 (HighlightsView + Stats + Dashboard-Widget).
## Ziel

View file

@ -384,6 +384,128 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
],
},
// ── Articles (Pocket-style read-it-later) ───────────────
{
name: 'list_articles',
module: 'articles',
description:
'Listet gespeicherte Artikel (id, title, siteName, status, readingTime). Optional nach Status filtern.',
defaultPolicy: 'auto',
parameters: [
{
name: 'status',
type: 'string',
description:
'Nur Artikel mit diesem Status. Default: ohne Filter (archivierte werden nur bei "archived"/"all" eingeschlossen).',
required: false,
enum: ['unread', 'reading', 'finished', 'archived', 'all'],
},
{
name: 'limit',
type: 'number',
description: 'Maximale Anzahl (Standard 30, max 100)',
required: false,
},
{
name: 'query',
type: 'string',
description: 'Case-insensitive Substring-Filter auf Titel / Autor / Quelle',
required: false,
},
],
},
{
name: 'save_article',
module: 'articles',
description:
'Speichert einen Artikel von einer URL in die Leseliste. URL wird serverseitig per Readability extrahiert.',
defaultPolicy: 'propose',
parameters: [
{ name: 'url', type: 'string', description: 'Die Artikel-URL', required: true },
{
name: 'title',
type: 'string',
description: 'Anzeigetitel für den Approval-Dialog (informativ)',
required: false,
},
{
name: 'reason',
type: 'string',
description: 'Kurze Begründung warum der Artikel für den Nutzer relevant ist',
required: false,
},
],
},
{
name: 'archive_article',
module: 'articles',
description: 'Verschiebt einen Artikel ins Archiv.',
defaultPolicy: 'propose',
parameters: [
{
name: 'articleId',
type: 'string',
description: 'ID des Artikels (aus list_articles)',
required: true,
},
],
},
{
name: 'tag_article',
module: 'articles',
description:
'Vergibt einen Tag auf einen Artikel. Tag wird angelegt falls er noch nicht existiert.',
defaultPolicy: 'propose',
parameters: [
{
name: 'articleId',
type: 'string',
description: 'ID des Artikels (aus list_articles)',
required: true,
},
{
name: 'tagName',
type: 'string',
description: 'Tag-Name (z.B. "KI", "lesen bald")',
required: true,
},
],
},
{
name: 'add_article_highlight',
module: 'articles',
description:
'Markiert eine Textstelle in einem Artikel als Highlight. Der Text muss wörtlich im Artikel vorkommen — sonst wird der Call abgelehnt.',
defaultPolicy: 'propose',
parameters: [
{
name: 'articleId',
type: 'string',
description: 'ID des Artikels (aus list_articles)',
required: true,
},
{
name: 'text',
type: 'string',
description: 'Wörtliche Textstelle die markiert werden soll (10500 Zeichen)',
required: true,
},
{
name: 'color',
type: 'string',
description: 'Highlight-Farbe',
required: false,
enum: ['yellow', 'green', 'blue', 'pink'],
},
{
name: 'note',
type: 'string',
description: 'Optionale Notiz zum Highlight',
required: false,
},
],
},
// ── News-Research ─────────────────────────────────────────
{
name: 'research_news',