diff --git a/apps/mana/apps/web/src/lib/api/research.ts b/apps/mana/apps/web/src/lib/api/research.ts index 9dcc21eca..c7424a721 100644 --- a/apps/mana/apps/web/src/lib/api/research.ts +++ b/apps/mana/apps/web/src/lib/api/research.ts @@ -146,6 +146,18 @@ export const researchApi = { }); }, + /** + * Synchronous variant — blocks until the pipeline is done. Used by the + * AI Mission runner for its web-research pre-step where we need the + * sources synchronously before handing off to the planner. + */ + async startSync(input: StartResearchInput): Promise { + return jsonRequest('/api/v1/research/start-sync', { + method: 'POST', + body: JSON.stringify(input), + }); + }, + /** Fetch a single research result row by id. */ async get(researchResultId: string): Promise { return jsonRequest(`/api/v1/research/${researchResultId}`); diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts b/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts index fd6e52f4f..a1e006dd4 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts @@ -29,9 +29,20 @@ import { import { resolveMissionInputs } from './input-resolvers'; import { getAvailableToolsForAi } from './available-tools'; import { executeTool } from '../../tools/executor'; +import { db } from '../../database'; +import { decryptRecords } from '../../crypto'; +import { researchApi } from '$lib/api/research'; import type { Actor } from '../../events/actor'; import type { Mission, MissionIteration, PlanStep } from './types'; -import type { AiPlanInput, AiPlanOutput, PlannedStep } from './planner/types'; +import type { AiPlanInput, AiPlanOutput, PlannedStep, ResolvedInput } from './planner/types'; + +/** Heuristic: mission objective text that should trigger a pre-step + * web-research call. Keeps the trigger explicit so unrelated missions + * don't burn credits accidentally. */ +const RESEARCH_TRIGGER = /\b(recherchier|research|news|finde|suche|aktuelle|neueste)/i; +/** Singleton row id of the kontext doc — kept in sync with + * `modules/kontext/types.ts` (KONTEXT_SINGLETON_ID). */ +const KONTEXT_SINGLETON_ID = 'singleton'; /** Hard timeout for one mission run. Cancels the in-flight planner call * and finalises the iteration as failed. 90 s is comfortable for a @@ -144,7 +155,34 @@ export async function runMission( 'resolving-inputs', mission!.inputs.length > 0 ? `${mission!.inputs.length} Input(s)` : 'keine Inputs' ); - const resolvedInputs = await resolveMissionInputs(mission!.inputs); + const baseInputs = await resolveMissionInputs(mission!.inputs); + const resolvedInputs: ResolvedInput[] = [...baseInputs]; + + // Auto-inject the kontext singleton (if non-empty and not already + // linked) so every mission has the user's standing context as + // background. Decrypted client-side; never reaches the server. + const alreadyHasKontext = mission!.inputs.some((i) => i.module === 'kontext'); + if (!alreadyHasKontext) { + const kontextEntry = await loadKontextAsResolvedInput(); + if (kontextEntry) resolvedInputs.push(kontextEntry); + } + + // Pre-step web research: if the objective looks like research, + // run the deep-research pipeline (mana-search + mana-llm) and + // attach the summary + sources so the planner can decide which + // to save via save_news_article. Failures are non-fatal — the + // planner still runs with whatever inputs we have. + if (RESEARCH_TRIGGER.test(mission!.objective)) { + await enterPhase('resolving-inputs', 'Web-Recherche…'); + try { + const researchEntry = await runWebResearch(mission!); + if (researchEntry) resolvedInputs.push(researchEntry); + } catch (err) { + console.warn('[MissionRunner] web-research pre-step failed:', err); + } + await checkCancel(); + } + const availableTools = getAvailableToolsForAi(aiActor); await checkCancel(); @@ -275,6 +313,67 @@ function emptyResult( }; } +/** Read the kontext singleton + decrypt; returns null if empty/missing. */ +async function loadKontextAsResolvedInput(): Promise { + try { + const local = await db + .table<{ id: string; content?: string; deletedAt?: string }>('kontextDoc') + .get(KONTEXT_SINGLETON_ID); + if (!local || local.deletedAt) return null; + const [decrypted] = await decryptRecords('kontextDoc', [local]); + const content = decrypted?.content?.trim(); + if (!content) return null; + return { + id: KONTEXT_SINGLETON_ID, + module: 'kontext', + table: 'kontextDoc', + title: 'Kontext (Standing)', + content, + }; + } catch (err) { + console.warn('[MissionRunner] kontext auto-inject failed:', err); + return null; + } +} + +/** Run the deep-research pipeline against the mission objective and + * collapse its summary + sources into one ResolvedInput formatted so + * the planner can copy URLs into save_news_article calls. */ +async function runWebResearch(mission: Mission): Promise { + const result = await researchApi.startSync({ + // Tag the run with the mission id so backend logs can correlate. + questionId: `mission:${mission.id}`, + title: mission.objective.slice(0, 500), + description: mission.conceptMarkdown?.slice(0, 4000), + depth: 'quick', + }); + if (result.status === 'error' || !result.summary) return null; + + const sources = await researchApi.listSources(result.id); + const sourcesBlock = sources + .slice(0, 8) + .map((s, i) => + `[${i + 1}] ${s.title || s.url}\n URL: ${s.url}\n ${s.snippet ?? ''}`.trim() + ) + .join('\n\n'); + + const content = [ + `Zusammenfassung (Tiefe: ${result.depth}):`, + result.summary, + '', + 'Quellen (kopiere die URL beim Aufruf von save_news_article):', + sourcesBlock || '(keine Quellen)', + ].join('\n'); + + return { + id: result.id, + module: 'research', + table: 'researchResults', + title: 'Web-Recherche zu diesem Auftrag', + content, + }; +} + /** * Scan all active missions whose `nextRunAt` has passed and run them once * each. Used by the foreground tick that wires this into `+layout.svelte`. diff --git a/apps/mana/apps/web/src/lib/modules/news/tools.ts b/apps/mana/apps/web/src/lib/modules/news/tools.ts index 64a1a067c..4ffef8e99 100644 --- a/apps/mana/apps/web/src/lib/modules/news/tools.ts +++ b/apps/mana/apps/web/src/lib/modules/news/tools.ts @@ -1,4 +1,46 @@ +/** + * News Tools — LLM-accessible operations for the news module. + * + * `save_news_article` is the agent's path into the user's reading list. + * On approve, the executor calls `articlesStore.saveFromUrl(url)` which + * routes through `apps/api /api/v1/news/extract/save` (Readability) and + * stores the encrypted result in `newsArticles`. `title` and `summary` + * are display hints — the canonical title/excerpt come back from the + * extractor so the AI can't lie about content. + */ + import type { ModuleTool } from '$lib/data/tools/types'; -// News tools are limited — saveFromCurated requires a full LocalCachedArticle -// which is complex for LLM tool calling. Read-only for now. -export const newsTools: ModuleTool[] = []; +import { articlesStore } from './stores/articles.svelte'; + +export const newsTools: ModuleTool[] = [ + { + name: 'save_news_article', + module: 'news', + 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: 'summary', + type: 'string', + description: 'Kurze Begründung warum dieser Artikel relevant ist', + required: false, + }, + ], + async execute(params) { + const url = params.url as string; + const article = await articlesStore.saveFromUrl(url); + return { + success: true, + message: `Artikel gespeichert: ${article.title}`, + data: { articleId: article.id, title: article.title }, + }; + }, + }, +]; diff --git a/apps/mana/apps/web/src/routes/(app)/news/+page.svelte b/apps/mana/apps/web/src/routes/(app)/news/+page.svelte index c8bf98540..f31ff076a 100644 --- a/apps/mana/apps/web/src/routes/(app)/news/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/news/+page.svelte @@ -18,6 +18,7 @@ import { reactionsStore } from '$lib/modules/news/stores/reactions.svelte'; import { articlesStore } from '$lib/modules/news/stores/articles.svelte'; import { feedCacheStore } from '$lib/modules/news/stores/feed-cache.svelte'; + import AiProposalInbox from '$lib/components/ai/AiProposalInbox.svelte'; import { ALL_TOPICS, type Topic, @@ -146,6 +147,7 @@
+ {#if !isOnboarded}
diff --git a/packages/shared-ai/src/policy/proposable-tools.ts b/packages/shared-ai/src/policy/proposable-tools.ts index 416468f17..b3f5618cc 100644 --- a/packages/shared-ai/src/policy/proposable-tools.ts +++ b/packages/shared-ai/src/policy/proposable-tools.ts @@ -24,6 +24,7 @@ export const AI_PROPOSABLE_TOOL_NAMES = [ 'create_place', 'visit_place', 'undo_drink', + 'save_news_article', ] as const; export type AiProposableToolName = (typeof AI_PROPOSABLE_TOOL_NAMES)[number]; diff --git a/services/mana-ai/src/planner/tools.ts b/services/mana-ai/src/planner/tools.ts index e58d39061..263d084e8 100644 --- a/services/mana-ai/src/planner/tools.ts +++ b/services/mana-ai/src/planner/tools.ts @@ -83,6 +83,27 @@ export const AI_AVAILABLE_TOOLS: readonly AvailableTool[] = [ description: 'Macht den letzten Drink-Eintrag rückgängig', parameters: [], }, + { + name: 'save_news_article', + module: 'news', + 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: 'summary', + type: 'string', + description: 'Kurze Begründung warum dieser Artikel relevant ist', + required: false, + }, + ], + }, ]; export const AI_AVAILABLE_TOOL_NAMES = new Set(AI_AVAILABLE_TOOLS.map((t) => t.name));