feat(ai): input picker — link notes / kontext / goals to a Mission

Closes the "blind Planner" gap: users can now attach context records to
a Mission, and the Runner resolves them through the existing resolver
registry before calling the Planner. The LLM sees the actual linked
note content, not just the mission objective.

- `data/ai/missions/input-index.ts` — sibling registry to input-resolvers.
  Resolvers turn a ref into prompt text (Runner path); indexers list
  candidates for the picker UI (create-form path). Same shape, different
  direction; keeps modules decoupled from the AI layer on both ends.
- `data/ai/missions/default-resolvers.ts` — registers indexers for
  notes (title + content preview, capped at 200), kontext (the singleton
  doc), goals (title + progress). Co-located with the resolvers so the
  two halves stay in sync.
- `components/ai/MissionInputPicker.svelte` — drop-in picker: module
  selector → candidate list → chip-style selected display. Binds
  directly to `MissionInputRef[]` so forms use it as a single control.
- `/companion/missions` — picker wired into the create form between
  Konzept and Cadence; detail view's meta block now lists the linked
  inputs so users can see what context the Planner will see.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 22:25:07 +02:00
parent 3a8c019ab0
commit 41052f769e
5 changed files with 398 additions and 2 deletions

View file

@ -0,0 +1,241 @@
<!--
MissionInputPicker — lets the user link records (notes, goals, kontext,
future modules…) as inputs for an AI Mission.
Binds to a `MissionInputRef[]` so the caller can drop it into any form:
<MissionInputPicker bind:value={formInputs} />
The list of modules that appear in the picker is driven by whoever
registered an indexer via `registerInputIndexer(name, fn)` — today that's
the default resolvers (notes / kontext / goals), but each module can
register a richer projection later without this component changing.
-->
<script lang="ts">
import { Plus, X } from '@mana/shared-icons';
import {
listIndexedModules,
getInputCandidates,
type InputCandidate,
} from '$lib/data/ai/missions/input-index';
import { registerDefaultInputResolvers } from '$lib/data/ai/missions/default-resolvers';
import type { MissionInputRef } from '$lib/data/ai/missions/types';
interface Props {
value: MissionInputRef[];
}
let { value = $bindable([]) }: Props = $props();
// Ensure defaults are registered even if the layout tick hasn't started
// yet (e.g. the create form opens before the first tick).
registerDefaultInputResolvers();
const modules = $derived(listIndexedModules());
let activeModule = $state<string | null>(null);
let candidates = $state<InputCandidate[]>([]);
let loading = $state(false);
$effect(() => {
if (!activeModule) {
candidates = [];
return;
}
loading = true;
void getInputCandidates(activeModule)
.then((list) => {
candidates = list;
})
.finally(() => {
loading = false;
});
});
function keyOf(ref: MissionInputRef): string {
return `${ref.module}::${ref.table}::${ref.id}`;
}
const selectedKeys = $derived(new Set(value.map(keyOf)));
function toggle(candidate: InputCandidate) {
const key = keyOf(candidate);
if (selectedKeys.has(key)) {
value = value.filter((v) => keyOf(v) !== key);
} else {
value = [...value, { module: candidate.module, table: candidate.table, id: candidate.id }];
}
}
function removeInput(ref: MissionInputRef) {
const key = keyOf(ref);
value = value.filter((v) => keyOf(v) !== key);
}
function labelFor(ref: MissionInputRef): string {
const match = candidates.find(
(c) => c.module === ref.module && c.table === ref.table && c.id === ref.id
);
return match?.label ?? `${ref.module}/${ref.id}`;
}
</script>
<div class="picker">
<div class="selected" aria-label="Verlinkte Inputs">
{#if value.length === 0}
<span class="hint">Keine Inputs verlinkt.</span>
{:else}
{#each value as ref (keyOf(ref))}
<span class="chip">
<span class="chip-mod">{ref.module}</span>
<span class="chip-label">{labelFor(ref)}</span>
<button type="button" onclick={() => removeInput(ref)} aria-label="Entfernen">
<X size={12} weight="bold" />
</button>
</span>
{/each}
{/if}
</div>
<div class="picker-controls">
<label>
<span class="small">Modul</span>
<select bind:value={activeModule}>
<option value={null}> wählen —</option>
{#each modules as m}
<option value={m}>{m}</option>
{/each}
</select>
</label>
{#if activeModule}
<div class="candidates">
{#if loading}
<p class="hint">lade…</p>
{:else if candidates.length === 0}
<p class="hint">Nichts in "{activeModule}" vorhanden.</p>
{:else}
{#each candidates as c (keyOf(c))}
<button
type="button"
class="candidate"
class:selected={selectedKeys.has(keyOf(c))}
onclick={() => toggle(c)}
>
<Plus size={12} weight="bold" />
<span class="cand-label">{c.label}</span>
{#if c.hint}
<span class="cand-hint">{c.hint}</span>
{/if}
</button>
{/each}
{/if}
</div>
{/if}
</div>
</div>
<style>
.picker {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.selected {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
min-height: 1.75rem;
align-items: center;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.25rem 0.125rem 0.5rem;
border: 1px solid color-mix(in oklab, var(--color-primary, #6b5bff) 45%, transparent);
border-radius: 999px;
background: color-mix(in oklab, var(--color-primary, #6b5bff) 10%, var(--color-bg, #fff));
font-size: 0.75rem;
}
.chip-mod {
color: var(--color-muted, #888);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.625rem;
}
.chip button {
border: none;
background: transparent;
padding: 0.125rem;
cursor: pointer;
color: var(--color-muted, #888);
display: inline-flex;
}
.chip button:hover {
color: var(--color-fg, inherit);
}
.picker-controls {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.picker-controls label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.small {
font-size: 0.75rem;
color: var(--color-muted, #888);
}
.picker-controls select {
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.25rem;
font: inherit;
}
.candidates {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 12rem;
overflow-y: auto;
padding: 0.25rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.375rem;
}
.candidate {
display: grid;
grid-template-columns: 1rem 1fr auto;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border: 1px solid transparent;
border-radius: 0.25rem;
background: transparent;
cursor: pointer;
text-align: left;
font-size: 0.8125rem;
}
.candidate:hover {
border-color: var(--color-border, #ddd);
}
.candidate.selected {
background: color-mix(in oklab, var(--color-primary, #6b5bff) 10%, transparent);
border-color: color-mix(in oklab, var(--color-primary, #6b5bff) 45%, transparent);
}
.cand-hint {
color: var(--color-muted, #888);
font-size: 0.75rem;
}
.hint {
color: var(--color-muted, #888);
font-size: 0.8125rem;
padding: 0.25rem 0;
}
</style>

View file

@ -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<NoteLike>('notes').toArray();
const visible = all.filter((n) => !n.deletedAt);
const decrypted = await decryptRecords('notes', visible);
return decrypted
.map<InputCandidate>((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<KontextDocLike>('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<GoalLike>('companionGoals').toArray();
const visible = all.filter((g) => !g.deletedAt);
return visible.map<InputCandidate>((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;
}

View file

@ -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([]);
});
});

View file

@ -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<InputCandidate[]>;
const indexers = new Map<string, InputIndexer>();
/** 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<InputCandidate[]> {
const indexer = indexers.get(moduleName);
if (!indexer) return [];
try {
return await indexer();
} catch (err) {
console.error(`[MissionInputPicker] indexer for ${moduleName} threw:`, err);
return [];
}
}

View file

@ -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<MissionCadence['kind']>('manual');
let formIntervalMin = $state(60);
let formDailyHour = $state(9);
let formInputs = $state<MissionInputRef[]>([]);
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 @@
></textarea>
</label>
<fieldset>
<legend>Inputs (Kontext für die KI)</legend>
<MissionInputPicker bind:value={formInputs} />
</fieldset>
<fieldset>
<legend>Cadence</legend>
<div class="cadence-row">
@ -285,6 +294,14 @@
<dd>{describeCadence(selected.cadence)}</dd>
<dt>Nächster Run</dt>
<dd>{formatRelative(selected.nextRunAt)}</dd>
<dt>Inputs</dt>
<dd>
{#if selected.inputs.length === 0}
{:else}
{selected.inputs.map((i) => `${i.module}/${i.id}`).join(', ')}
{/if}
</dd>
</dl>
{#if selected.conceptMarkdown}