feat(ai): add tasks + calendar events as Mission inputs (webapp side)

Mission-Input-Picker now surfaces open tasks + upcoming calendar events
alongside notes / kontext / goals. When the foreground runner runs, the
corresponding resolvers decrypt the records client-side and hand real
content (title + due date / event time + description) into the Planner
prompt.

- `tasksResolver` + `tasksIndexer` — reads unencrypted subset + decrypts
  via `decryptRecords('tasks', …)`. Picker shows only OPEN tasks (not
  completed ones) to keep the list relevant. Resolver output is
  `[status]{ · fällig date}{ \n description}` — terse by design.
- `calendarResolver` + `calendarIndexer` — similarly decrypts events;
  picker prioritizes upcoming events (sorted by startIso), shows
  near-term times as "bald: YYYY-MM-DDTHH:MM" to make recency obvious
- Both tables are encrypted client-side — server-side mana-ai resolvers
  remain intentionally absent (per the privacy contract in
  `services/mana-ai/src/db/resolvers/types.ts`)

With this, a user can create a Mission like "plan my week" and link a
few tasks + calendar events as context; the in-browser planner sees the
full picture (decrypted), while the off-tab runner still plans from
objective + concept only for those missions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 00:44:59 +02:00
parent a8425941fb
commit 4b67316343

View file

@ -118,6 +118,105 @@ const goalsIndexer: InputIndexer = async () => {
}));
};
// ── tasks (todo module, encrypted) ─────────────────────────
interface TaskLike {
id: string;
title?: string;
description?: string;
dueDate?: string;
isCompleted?: boolean;
deletedAt?: string;
}
const tasksResolver: InputResolver = async (ref) => {
const local = await db.table<TaskLike>(ref.table).get(ref.id);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords(ref.table, [local]);
const status = decrypted.isCompleted ? 'erledigt' : 'offen';
const due = decrypted.dueDate ? ` · fällig ${decrypted.dueDate}` : '';
const body = decrypted.description ? `\n${decrypted.description}` : '';
return {
id: ref.id,
module: ref.module,
table: ref.table,
title: decrypted.title,
content: `[${status}]${due}${body}`,
};
};
const tasksIndexer: InputIndexer = async () => {
const all = await db.table<TaskLike>('tasks').toArray();
const visible = all.filter((t) => !t.deletedAt && !t.isCompleted);
const decrypted = await decryptRecords('tasks', visible);
return decrypted
.map<InputCandidate>((t) => ({
module: 'todo',
table: 'tasks',
id: t.id,
label: (t.title && t.title.trim()) || '(ohne Titel)',
hint: t.dueDate ? `fällig ${t.dueDate}` : undefined,
}))
.slice(0, 200);
};
// ── calendar events (encrypted) ────────────────────────────
interface CalEventLike {
id: string;
title?: string;
description?: string;
location?: string;
startIso?: string;
endIso?: string;
deletedAt?: string;
}
const calendarResolver: InputResolver = async (ref) => {
const local = await db.table<CalEventLike>(ref.table).get(ref.id);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords(ref.table, [local]);
const when = decrypted.startIso
? decrypted.endIso
? `${decrypted.startIso} ${decrypted.endIso}`
: decrypted.startIso
: '';
const where = decrypted.location ? ` @ ${decrypted.location}` : '';
const body = decrypted.description ? `\n${decrypted.description}` : '';
return {
id: ref.id,
module: ref.module,
table: ref.table,
title: decrypted.title,
content: `${when}${where}${body}`,
};
};
const calendarIndexer: InputIndexer = async () => {
const all = await db.table<CalEventLike>('events').toArray();
// Show upcoming events (next 30 days) first; cap at 200 total.
const now = Date.now();
const horizon = now + 30 * 24 * 60 * 60_000;
const upcoming = all
.filter((e) => !e.deletedAt)
.filter((e) => !e.startIso || new Date(e.startIso).getTime() >= now - 24 * 60 * 60_000)
.sort((a, b) => (a.startIso ?? '').localeCompare(b.startIso ?? ''));
const decrypted = await decryptRecords('events', upcoming);
return decrypted
.map<InputCandidate>((e) => ({
module: 'calendar',
table: 'events',
id: e.id,
label: (e.title && e.title.trim()) || '(ohne Titel)',
hint: e.startIso
? new Date(e.startIso).getTime() < horizon
? `bald: ${e.startIso.slice(0, 16)}`
: e.startIso.slice(0, 10)
: undefined,
}))
.slice(0, 200);
};
let registered = false;
/** Register the default resolvers + indexers once. Idempotent. */
@ -126,8 +225,12 @@ export function registerDefaultInputResolvers(): void {
registerInputResolver('notes', notesResolver);
registerInputResolver('kontext', kontextResolver);
registerInputResolver('goals', goalsResolver);
registerInputResolver('todo', tasksResolver);
registerInputResolver('calendar', calendarResolver);
registerInputIndexer('notes', notesIndexer);
registerInputIndexer('kontext', kontextIndexer);
registerInputIndexer('goals', goalsIndexer);
registerInputIndexer('todo', tasksIndexer);
registerInputIndexer('calendar', calendarIndexer);
registered = true;
}