diff --git a/apps/mana/apps/web/src/lib/data/ai/agents/kontext.ts b/apps/mana/apps/web/src/lib/data/ai/agents/kontext.ts new file mode 100644 index 000000000..9d6eaf545 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/agents/kontext.ts @@ -0,0 +1,70 @@ +/** + * Per-agent kontext documents — replaces the global singleton for scoped + * agent context. Each agent can have one markdown document that's injected + * into every planner call for that agent's missions. + * + * Encrypted at rest (registered in crypto/registry.ts under + * 'agentKontextDocs'). Synced via mana-sync under appId='ai'. + */ + +import { db } from '../../database'; +import { encryptRecord, decryptRecords } from '../../crypto'; + +const TABLE = 'agentKontextDocs'; + +export interface LocalAgentKontextDoc { + id: string; + agentId: string; + content: string; + createdAt?: string; + updatedAt?: string; + userId?: string; + deletedAt?: string; +} + +export interface AgentKontextDoc { + id: string; + agentId: string; + content: string; +} + +/** Read + decrypt the kontext doc for a specific agent. Returns null if + * none exists or content is empty. */ +export async function getAgentKontext(agentId: string): Promise { + const locals = await db + .table(TABLE) + .where('agentId') + .equals(agentId) + .toArray(); + const visible = locals.filter((d) => !d.deletedAt); + if (visible.length === 0) return null; + const [decrypted] = await decryptRecords(TABLE, visible); + if (!decrypted || !decrypted.content?.trim()) return null; + return { id: decrypted.id, agentId: decrypted.agentId, content: decrypted.content }; +} + +/** Create or update the kontext doc for an agent. */ +export async function saveAgentKontext(agentId: string, content: string): Promise { + const existing = await db + .table(TABLE) + .where('agentId') + .equals(agentId) + .first(); + + if (existing) { + const diff: Partial = { + content, + updatedAt: new Date().toISOString(), + }; + await encryptRecord(TABLE, diff); + await db.table(TABLE).update(existing.id, diff); + } else { + const doc: LocalAgentKontextDoc = { + id: crypto.randomUUID(), + agentId, + content, + }; + await encryptRecord(TABLE, doc); + await db.table(TABLE).add(doc); + } +} diff --git a/apps/mana/apps/web/src/lib/data/ai/agents/store.ts b/apps/mana/apps/web/src/lib/data/ai/agents/store.ts index c338cf4e5..15393dd99 100644 --- a/apps/mana/apps/web/src/lib/data/ai/agents/store.ts +++ b/apps/mana/apps/web/src/lib/data/ai/agents/store.ts @@ -129,6 +129,7 @@ export interface AgentPatch { policy?: AiPolicy; maxTokensPerDay?: number; maxConcurrentMissions?: number; + scopeTagIds?: string[]; state?: AgentState; } 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 ff0c17dbc..2ce7dbe4b 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 @@ -32,6 +32,8 @@ import { executeTool } from '../../tools/executor'; import { db } from '../../database'; import { decryptRecords } from '../../crypto'; import { discoverByQuery, searchFeeds } from '$lib/modules/news-research/api'; +import { getAgentKontext } from '../agents/kontext'; +import { withAgentScope } from '../scope-context'; import { isAiDebugEnabled, recordAiDebug, type AiDebugEntry, type PlannerCallDebug } from './debug'; import { makeAgentActor, LEGACY_AI_PRINCIPAL, type Actor } from '../../events/actor'; import { getAgent } from '../agents/store'; @@ -192,12 +194,14 @@ export async function runMission( const resolvedInputs: ResolvedInput[] = [...baseInputs]; const preStep: AiDebugEntry['preStep'] = { kontextInjected: false }; - // 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. + // Auto-inject agent-specific kontext doc (if non-empty) — replaces + // the old global singleton inject. Falls back to the global singleton + // when the agent doesn't have its own doc. Decrypted client-side. const alreadyHasKontext = mission!.inputs.some((i) => i.module === 'kontext'); if (!alreadyHasKontext) { - const kontextEntry = await loadKontextAsResolvedInput(); + const kontextEntry = owningAgent + ? await loadAgentKontextAsResolvedInput(owningAgent.id) + : await loadKontextAsResolvedInput(); if (kontextEntry) { resolvedInputs.push(kontextEntry); preStep.kontextInjected = true; @@ -449,7 +453,10 @@ export async function runMission( let planSummary = ''; let planStepCount = 0; try { - const result = await Promise.race([runPipeline(), timeoutPromise]); + const result = await Promise.race([ + withAgentScope(owningAgent?.scopeTagIds, runPipeline), + timeoutPromise, + ]); recordedSteps = result.recordedSteps; stagedCount = result.stagedCount; failedCount = result.failedCount; @@ -543,6 +550,25 @@ async function loadKontextAsResolvedInput(): Promise { } } +/** Load the agent-specific kontext doc. Falls back to null (caller + * may then fall back to the global singleton if desired). */ +async function loadAgentKontextAsResolvedInput(agentId: string): Promise { + try { + const doc = await getAgentKontext(agentId); + if (!doc) return loadKontextAsResolvedInput(); // fallback to global + return { + id: doc.id, + module: 'kontext', + table: 'agentKontextDocs', + title: 'Agent-Kontext', + content: doc.content, + }; + } catch (err) { + console.warn('[MissionRunner] agent kontext load 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. */ diff --git a/apps/mana/apps/web/src/lib/data/ai/module.config.ts b/apps/mana/apps/web/src/lib/data/ai/module.config.ts index ea2cd6063..84e4889d3 100644 --- a/apps/mana/apps/web/src/lib/data/ai/module.config.ts +++ b/apps/mana/apps/web/src/lib/data/ai/module.config.ts @@ -16,5 +16,5 @@ import type { ModuleConfig } from '$lib/data/module-registry'; export const aiModuleConfig: ModuleConfig = { appId: 'ai', - tables: [{ name: 'aiMissions' }, { name: 'agents' }], + tables: [{ name: 'aiMissions' }, { name: 'agents' }, { name: 'agentKontextDocs' }], }; diff --git a/apps/mana/apps/web/src/lib/data/ai/scope-context.ts b/apps/mana/apps/web/src/lib/data/ai/scope-context.ts new file mode 100644 index 000000000..35b9e6e56 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/scope-context.ts @@ -0,0 +1,63 @@ +/** + * Ambient scope context for AI tool execution. + * + * When a mission runs under an agent with scopeTagIds, the runner calls + * `withAgentScope(tagIds, fn)` around the reasoning loop. Auto-tools + * like `list_notes` check `getAgentScopeTagIds()` and filter their + * results to records tagged with at least one of those IDs (plus + * untagged records, which are globally visible). + * + * Pattern mirrors `runAs()` in events/actor.ts — module-level mutable + * state, single-threaded browser runtime, scoped via try/finally. + */ + +let currentScopeTagIds: readonly string[] | null = null; + +/** + * Run `fn` with the given scope tag IDs as ambient context. Clears + * the scope when `fn` completes (or throws). + */ +export async function withAgentScope( + scopeTagIds: readonly string[] | undefined, + fn: () => Promise +): Promise { + const prev = currentScopeTagIds; + currentScopeTagIds = scopeTagIds?.length ? scopeTagIds : null; + try { + return await fn(); + } finally { + currentScopeTagIds = prev; + } +} + +/** + * Read the current ambient scope. Returns null when no scope is set + * (meaning the tool should return everything — General-Agent behavior). + */ +export function getAgentScopeTagIds(): readonly string[] | null { + return currentScopeTagIds; +} + +/** + * Given a list of records + a function that returns their tag IDs, + * filter down to records that match the ambient scope. Records with + * no tags pass through (globally visible). + */ +export async function filterByScope( + records: T[], + getTagIdsForRecord: (record: T) => Promise +): Promise { + const scope = currentScopeTagIds; + if (!scope) return records; // no scope = everything visible + + const scopeSet = new Set(scope); + const results: T[] = []; + for (const r of records) { + const tagIds = await getTagIdsForRecord(r); + // Untagged records are globally visible; tagged records must match scope + if (tagIds.length === 0 || tagIds.some((id) => scopeSet.has(id))) { + results.push(r); + } + } + return results; +} diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index f35763076..d8ee306c2 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -472,6 +472,10 @@ export const ENCRYPTION_REGISTRY: Record = { // Free-form user text — encrypt the content, leave the fixed id plaintext. kontextDoc: { enabled: true, fields: ['content'] }, + // Per-agent kontext documents — same schema as kontextDoc but keyed + // per agent. Content is free-form markdown. + agentKontextDocs: { enabled: true, fields: ['content'] }, + // ─── Quiz ──────────────────────────────────────────────── // User-typed text on the container (title, description, category, tags) // plus the whole question payload (questionText, explanation, options). diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index c56593ade..f1a505f1b 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -548,6 +548,13 @@ db.version(21).stores({ quizAttempts: 'id, quizId, startedAt, [quizId+startedAt]', }); +// v22 — Notes tag junction (mirrors eventTags/contactTags/taskLabels pattern) +// + per-agent kontext documents (replaces global singleton auto-inject). +db.version(22).stores({ + noteTags: 'id, noteId, tagId, [noteId+tagId]', + agentKontextDocs: 'id, agentId', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/modules/ai-agents/ListView.svelte b/apps/mana/apps/web/src/lib/modules/ai-agents/ListView.svelte index 5093a5c91..b4b7ad9e3 100644 --- a/apps/mana/apps/web/src/lib/modules/ai-agents/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/ai-agents/ListView.svelte @@ -33,8 +33,11 @@ import { DEFAULT_AI_POLICY } from '$lib/data/ai/policy'; import type { Agent } from '$lib/data/ai/agents/types'; import type { AiPolicy, PolicyDecision } from '@mana/shared-ai'; + import { TagSelector } from '@mana/shared-ui'; + import { useAllTags } from '@mana/shared-stores'; const agents = $derived(useAgents()); + const allTags = $derived(useAllTags()); let mode = $state<'list' | 'create' | 'detail'>('list'); let selectedId = $state(null); @@ -83,6 +86,7 @@ let editMemory = $state(''); let editMaxConcurrent = $state(1); let editMaxTokensPerDay = $state(null); + let editScopeTagIds = $state([]); let lastSyncedId = $state(null); let saveError = $state(null); let saving = $state(false); @@ -96,6 +100,7 @@ editMemory = selected.memory ?? ''; editMaxConcurrent = selected.maxConcurrentMissions; editMaxTokensPerDay = selected.maxTokensPerDay ?? null; + editScopeTagIds = [...(selected.scopeTagIds ?? [])]; lastSyncedId = selected.id; saveError = null; } @@ -113,6 +118,7 @@ memory: editMemory || undefined, maxConcurrentMissions: editMaxConcurrent, maxTokensPerDay: editMaxTokensPerDay ?? undefined, + scopeTagIds: editScopeTagIds.length > 0 ? editScopeTagIds : undefined, }); } catch (err) { if (err instanceof DuplicateAgentNameError) { @@ -357,6 +363,20 @@ +
+

Bereiche (Tag-Scope)

+

Der Agent sieht nur Records mit diesen Tags. Leer = alles sichtbar.

+ editScopeTagIds.includes(t.id))} + onTagsChange={(tags) => { + editScopeTagIds = tags.map((t) => t.id); + }} + placeholder="Bereiche wählen…" + addTagLabel="Bereich hinzufügen" + /> +
+

Grenzen