diff --git a/apps/mana/apps/web/src/lib/components/ai/MissionInputPicker.svelte b/apps/mana/apps/web/src/lib/components/ai/MissionInputPicker.svelte
new file mode 100644
index 000000000..1738b6205
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/components/ai/MissionInputPicker.svelte
@@ -0,0 +1,241 @@
+
+
+
+
+
+ {#if value.length === 0}
+ Keine Inputs verlinkt.
+ {:else}
+ {#each value as ref (keyOf(ref))}
+
+ {ref.module}
+ {labelFor(ref)}
+
+
+ {/each}
+ {/if}
+
+
+
+
+
+ {#if activeModule}
+
+ {#if loading}
+
lade…
+ {:else if candidates.length === 0}
+
Nichts in "{activeModule}" vorhanden.
+ {:else}
+ {#each candidates as c (keyOf(c))}
+
+ {/each}
+ {/if}
+
+ {/if}
+
+
+
+
diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts b/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts
index 70f786573..df686d42e 100644
--- a/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts
+++ b/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts
@@ -10,7 +10,9 @@
import { db } from '../../database';
import { decryptRecords } from '../../crypto';
import { registerInputResolver } from './input-resolvers';
+import { registerInputIndexer } from './input-index';
import type { InputResolver } from './input-resolvers';
+import type { InputCandidate, InputIndexer } from './input-index';
interface NoteLike {
id: string;
@@ -73,13 +75,59 @@ const goalsResolver: InputResolver = async (ref) => {
};
};
+// ── Indexers: list candidates for the picker UI ────────────
+
+const notesIndexer: InputIndexer = async () => {
+ const all = await db.table('notes').toArray();
+ const visible = all.filter((n) => !n.deletedAt);
+ const decrypted = await decryptRecords('notes', visible);
+ return decrypted
+ .map((n) => ({
+ module: 'notes',
+ table: 'notes',
+ id: n.id,
+ label: (n.title && n.title.trim()) || '(ohne Titel)',
+ hint: n.content ? `${n.content.slice(0, 60).replace(/\s+/g, ' ')}…` : undefined,
+ }))
+ .slice(0, 200); // cap — Mission picker isn't meant to list thousands
+};
+
+const kontextIndexer: InputIndexer = async () => {
+ const doc = await db.table('kontextDoc').get('singleton');
+ if (!doc) return [];
+ return [
+ {
+ module: 'kontext',
+ table: 'kontextDoc',
+ id: 'singleton',
+ label: 'Kontext-Dokument',
+ hint: 'Dein zentrales Markdown-Dokument',
+ },
+ ];
+};
+
+const goalsIndexer: InputIndexer = async () => {
+ const all = await db.table('companionGoals').toArray();
+ const visible = all.filter((g) => !g.deletedAt);
+ return visible.map((g) => ({
+ module: 'goals',
+ table: 'companionGoals',
+ id: g.id,
+ label: g.title ?? 'Goal',
+ hint: `${g.currentValue ?? 0} / ${g.target?.value ?? '?'} (${g.period ?? '—'})`,
+ }));
+};
+
let registered = false;
-/** Register the default resolvers once. Idempotent. */
+/** Register the default resolvers + indexers once. Idempotent. */
export function registerDefaultInputResolvers(): void {
if (registered) return;
registerInputResolver('notes', notesResolver);
registerInputResolver('kontext', kontextResolver);
registerInputResolver('goals', goalsResolver);
+ registerInputIndexer('notes', notesIndexer);
+ registerInputIndexer('kontext', kontextIndexer);
+ registerInputIndexer('goals', goalsIndexer);
registered = true;
}
diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/input-index.test.ts b/apps/mana/apps/web/src/lib/data/ai/missions/input-index.test.ts
new file mode 100644
index 000000000..0ad0aa0c0
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/data/ai/missions/input-index.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import {
+ registerInputIndexer,
+ unregisterInputIndexer,
+ listIndexedModules,
+ getInputCandidates,
+} from './input-index';
+
+afterEach(() => {
+ unregisterInputIndexer('index_test_mod');
+ unregisterInputIndexer('index_test_boom');
+});
+
+describe('input-index registry', () => {
+ it('lists registered modules sorted', () => {
+ registerInputIndexer('index_test_mod', async () => []);
+ expect(listIndexedModules()).toContain('index_test_mod');
+ });
+
+ it('returns candidates from the registered indexer', async () => {
+ registerInputIndexer('index_test_mod', async () => [
+ { module: 'index_test_mod', table: 't', id: 'a', label: 'A' },
+ { module: 'index_test_mod', table: 't', id: 'b', label: 'B', hint: 'note' },
+ ]);
+ const list = await getInputCandidates('index_test_mod');
+ expect(list).toHaveLength(2);
+ expect(list[0].label).toBe('A');
+ expect(list[1].hint).toBe('note');
+ });
+
+ it('returns empty array for unknown module', async () => {
+ expect(await getInputCandidates('nope')).toEqual([]);
+ });
+
+ it('catches indexer errors and returns empty', async () => {
+ registerInputIndexer('index_test_boom', async () => {
+ throw new Error('broken');
+ });
+ const list = await getInputCandidates('index_test_boom');
+ expect(list).toEqual([]);
+ });
+});
diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/input-index.ts b/apps/mana/apps/web/src/lib/data/ai/missions/input-index.ts
new file mode 100644
index 000000000..81c5ff5b5
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/data/ai/missions/input-index.ts
@@ -0,0 +1,48 @@
+/**
+ * Input-index registry — "what can the user link to a Mission?"
+ *
+ * Pairs with `input-resolvers.ts`. The resolver turns a
+ * {@link MissionInputRef} into text content the Planner can prompt on;
+ * this index lists the *available* records the user picks from in the
+ * Missions UI. Separate registry so modules stay decoupled from the AI
+ * subsystem — each module can register its own candidate list.
+ */
+
+import type { MissionInputRef } from './types';
+
+export interface InputCandidate extends MissionInputRef {
+ /** Human label for the picker UI. */
+ label: string;
+ /** Optional secondary text (e.g. "last edited 2d ago"). */
+ hint?: string;
+}
+
+export type InputIndexer = () => Promise;
+
+const indexers = new Map();
+
+/** Register (or replace) the indexer for a module. */
+export function registerInputIndexer(moduleName: string, indexer: InputIndexer): void {
+ indexers.set(moduleName, indexer);
+}
+
+export function unregisterInputIndexer(moduleName: string): void {
+ indexers.delete(moduleName);
+}
+
+/** Names of all modules that have registered an indexer. */
+export function listIndexedModules(): string[] {
+ return [...indexers.keys()].sort();
+}
+
+/** Fetch candidates for a single module. Empty array when nothing's registered. */
+export async function getInputCandidates(moduleName: string): Promise {
+ const indexer = indexers.get(moduleName);
+ if (!indexer) return [];
+ try {
+ return await indexer();
+ } catch (err) {
+ console.error(`[MissionInputPicker] indexer for ${moduleName} threw:`, err);
+ return [];
+ }
+}
diff --git a/apps/mana/apps/web/src/routes/(app)/companion/missions/+page.svelte b/apps/mana/apps/web/src/routes/(app)/companion/missions/+page.svelte
index 78d503965..e3a9ebb46 100644
--- a/apps/mana/apps/web/src/routes/(app)/companion/missions/+page.svelte
+++ b/apps/mana/apps/web/src/routes/(app)/companion/missions/+page.svelte
@@ -21,7 +21,8 @@
} from '$lib/data/ai/missions/store';
import { runMission } from '$lib/data/ai/missions/runner';
import { productionDeps } from '$lib/data/ai/missions/setup';
- import type { Mission, MissionCadence } from '$lib/data/ai/missions/types';
+ import MissionInputPicker from '$lib/components/ai/MissionInputPicker.svelte';
+ import type { Mission, MissionCadence, MissionInputRef } from '$lib/data/ai/missions/types';
const missions = $derived(useMissions());
@@ -36,6 +37,7 @@
let formCadenceKind = $state('manual');
let formIntervalMin = $state(60);
let formDailyHour = $state(9);
+ let formInputs = $state([]);
let creating = $state(false);
function buildCadence(): MissionCadence {
@@ -61,11 +63,13 @@
title: formTitle.trim(),
objective: formObjective.trim(),
conceptMarkdown: formConcept,
+ inputs: formInputs,
cadence: buildCadence(),
});
formTitle = '';
formObjective = '';
formConcept = '';
+ formInputs = [];
formCadenceKind = 'manual';
showForm = false;
selectedId = m.id;
@@ -169,6 +173,11 @@
>
+
+