diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/cadence.test.ts b/apps/mana/apps/web/src/lib/data/ai/missions/cadence.test.ts new file mode 100644 index 000000000..7bafca76b --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/missions/cadence.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { nextRunForCadence } from './cadence'; + +describe('nextRunForCadence', () => { + const from = new Date('2026-04-14T10:00:00.000Z'); + + it('returns undefined for manual', () => { + expect(nextRunForCadence({ kind: 'manual' }, from)).toBeUndefined(); + }); + + it('adds the interval for interval cadence', () => { + const next = nextRunForCadence({ kind: 'interval', everyMinutes: 60 }, from); + expect(next).toBe(new Date('2026-04-14T11:00:00.000Z').toISOString()); + }); + + it('schedules daily cadence later today when the hour has not passed', () => { + const next = nextRunForCadence({ kind: 'daily', atHour: 18, atMinute: 30 }, from); + // local-time dependent — we only assert the delta is positive and under 24h + const deltaMs = new Date(next!).getTime() - from.getTime(); + expect(deltaMs).toBeGreaterThan(0); + expect(deltaMs).toBeLessThan(24 * 60 * 60_000); + }); + + it('schedules daily cadence tomorrow when the hour has already passed', () => { + const next = nextRunForCadence({ kind: 'daily', atHour: 6, atMinute: 0 }, from); + const deltaMs = new Date(next!).getTime() - from.getTime(); + expect(deltaMs).toBeGreaterThan(0); + }); + + it('schedules weekly cadence on the target day-of-week', () => { + // 2026-04-14 is a Tuesday (day 2). Target: Friday (day 5). + const next = nextRunForCadence({ kind: 'weekly', dayOfWeek: 5, atHour: 9 }, from); + expect(new Date(next!).getDay()).toBe(5); + }); + + it('returns undefined for cron until implemented', () => { + expect(nextRunForCadence({ kind: 'cron', expression: '0 9 * * *' }, from)).toBeUndefined(); + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/cadence.ts b/apps/mana/apps/web/src/lib/data/ai/missions/cadence.ts new file mode 100644 index 000000000..d44a4d47d --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/missions/cadence.ts @@ -0,0 +1,50 @@ +/** + * Cadence → next-run calculation. + * + * Used by the mission store to stamp `nextRunAt` on create / update / + * finishIteration. Pure function — the Runner never calls this directly, + * it just reads `nextRunAt`. + * + * Kept intentionally simple: no RRULE parser, no timezone gymnastics. If we + * need cron, we'll plug in a lib later via the `cron` variant; for now the + * common cases (manual / interval / daily / weekly) cover what real users + * will configure in a settings UI. + */ + +import type { MissionCadence } from './types'; + +export function nextRunForCadence(cadence: MissionCadence, from: Date): string | undefined { + switch (cadence.kind) { + case 'manual': + return undefined; + + case 'interval': { + const t = new Date(from.getTime() + cadence.everyMinutes * 60_000); + return t.toISOString(); + } + + case 'daily': { + const t = new Date(from); + t.setHours(cadence.atHour, cadence.atMinute, 0, 0); + if (t <= from) t.setDate(t.getDate() + 1); + return t.toISOString(); + } + + case 'weekly': { + const t = new Date(from); + t.setHours(cadence.atHour, 0, 0, 0); + const diff = (cadence.dayOfWeek - t.getDay() + 7) % 7; + if (diff === 0 && t <= from) { + t.setDate(t.getDate() + 7); + } else { + t.setDate(t.getDate() + diff); + } + return t.toISOString(); + } + + case 'cron': + // Not implemented — caller must schedule nextRunAt explicitly until + // a cron parser is wired in. Return undefined so the Runner ignores it. + return undefined; + } +} diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/store.test.ts b/apps/mana/apps/web/src/lib/data/ai/missions/store.test.ts new file mode 100644 index 000000000..9ebb070ba --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/missions/store.test.ts @@ -0,0 +1,188 @@ +import 'fake-indexeddb/auto'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() })); +vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() })); +vi.mock('$lib/triggers/inline-suggest', () => ({ + checkInlineSuggestion: vi.fn().mockResolvedValue(null), +})); + +import { db } from '../../database'; +import { + createMission, + getMission, + listMissions, + updateMission, + pauseMission, + resumeMission, + completeMission, + archiveMission, + deleteMission, + startIteration, + finishIteration, + addIterationFeedback, +} from './store'; +import { MISSIONS_TABLE } from './types'; +import type { MissionCadence } from './types'; + +const DAILY: MissionCadence = { kind: 'daily', atHour: 9, atMinute: 0 }; + +beforeEach(async () => { + await db.table(MISSIONS_TABLE).clear(); +}); + +describe('mission CRUD', () => { + it('creates an active mission with a scheduled next run', async () => { + const m = await createMission({ + title: 'Weekly review', + conceptMarkdown: '# Do a weekly review', + objective: 'Review progress every Monday', + cadence: DAILY, + }); + expect(m.state).toBe('active'); + expect(m.iterations).toHaveLength(0); + expect(m.nextRunAt).toBeTruthy(); + expect(await getMission(m.id)).toBeTruthy(); + }); + + it('listMissions filters by state', async () => { + const a = await createMission({ + title: 'A', + conceptMarkdown: '', + objective: 'x', + cadence: { kind: 'manual' }, + }); + await createMission({ + title: 'B', + conceptMarkdown: '', + objective: 'y', + cadence: { kind: 'manual' }, + }); + await pauseMission(a.id); + + const active = await listMissions({ state: 'active' }); + const paused = await listMissions({ state: 'paused' }); + expect(active).toHaveLength(1); + expect(paused).toHaveLength(1); + }); + + it('listMissions dueBefore only returns active missions past their nextRunAt', async () => { + const m = await createMission({ + title: 'Due now', + conceptMarkdown: '', + objective: 'x', + cadence: { kind: 'interval', everyMinutes: 1 }, + }); + // force nextRunAt into the past + await db.table(MISSIONS_TABLE).update(m.id, { + nextRunAt: '2020-01-01T00:00:00.000Z', + }); + const due = await listMissions({ dueBefore: new Date().toISOString() }); + expect(due).toHaveLength(1); + }); + + it('updateMission with a new cadence recomputes nextRunAt', async () => { + const m = await createMission({ + title: 'x', + conceptMarkdown: '', + objective: 'x', + cadence: { kind: 'manual' }, + }); + expect(m.nextRunAt).toBeUndefined(); + await updateMission(m.id, { cadence: { kind: 'interval', everyMinutes: 30 } }); + const updated = await getMission(m.id); + expect(updated?.nextRunAt).toBeTruthy(); + }); + + it('pause / resume flip state and rescheduling', async () => { + const m = await createMission({ + title: 'x', + conceptMarkdown: '', + objective: 'x', + cadence: DAILY, + }); + await pauseMission(m.id); + expect((await getMission(m.id))?.state).toBe('paused'); + await resumeMission(m.id); + const resumed = await getMission(m.id); + expect(resumed?.state).toBe('active'); + expect(resumed?.nextRunAt).toBeTruthy(); + }); + + it('complete clears nextRunAt', async () => { + const m = await createMission({ + title: 'x', + conceptMarkdown: '', + objective: 'x', + cadence: DAILY, + }); + await completeMission(m.id); + const done = await getMission(m.id); + expect(done?.state).toBe('done'); + expect(done?.nextRunAt).toBeUndefined(); + }); + + it('archive + soft-delete', async () => { + const m = await createMission({ + title: 'x', + conceptMarkdown: '', + objective: 'x', + cadence: { kind: 'manual' }, + }); + await archiveMission(m.id); + expect((await getMission(m.id))?.state).toBe('archived'); + + await deleteMission(m.id); + const all = await listMissions(); + expect(all).toHaveLength(0); + }); +}); + +describe('mission iterations', () => { + it('appends a running iteration and finishes with summary + status', async () => { + const m = await createMission({ + title: 'x', + conceptMarkdown: '', + objective: 'x', + cadence: DAILY, + }); + const it = await startIteration(m.id, { + plan: [ + { + id: 'step-1', + summary: 'Propose a task', + intent: { kind: 'toolCall', toolName: 'create_task', params: { title: 'foo' } }, + status: 'planned', + }, + ], + }); + + const midRun = await getMission(m.id); + expect(midRun?.iterations).toHaveLength(1); + expect(midRun?.iterations[0].overallStatus).toBe('running'); + + await finishIteration(m.id, it.id, { + summary: 'Proposed a task', + overallStatus: 'awaiting-review', + }); + const done = await getMission(m.id); + expect(done?.iterations[0].overallStatus).toBe('awaiting-review'); + expect(done?.iterations[0].finishedAt).toBeTruthy(); + expect(done?.iterations[0].summary).toBe('Proposed a task'); + }); + + it('attaches user feedback to the iteration', async () => { + const m = await createMission({ + title: 'x', + conceptMarkdown: '', + objective: 'x', + cadence: DAILY, + }); + const it = await startIteration(m.id, { plan: [] }); + await finishIteration(m.id, it.id, { overallStatus: 'awaiting-review' }); + await addIterationFeedback(m.id, it.id, 'too aggressive — dial back'); + + const after = await getMission(m.id); + expect(after?.iterations[0].userFeedback).toBe('too aggressive — dial back'); + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/store.ts b/apps/mana/apps/web/src/lib/data/ai/missions/store.ts new file mode 100644 index 000000000..74d72ca13 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/missions/store.ts @@ -0,0 +1,211 @@ +/** + * Mission store — CRUD + lifecycle operations. + * + * Missions go through the unified Dexie write path, which means the Dexie + * hooks stamp `userId`, `__lastActor`, `__fieldTimestamps`, `__fieldActors` + * and track the row into `_pendingChanges`. Callers never touch those + * fields directly. + * + * Iterations are intentionally stored inline (`Mission.iterations`) rather + * than in a child table. They are append-only, each Mission stays small + * (tens of iterations at most), and keeping them together lets the Planner + * load the full history with a single `get(id)` — which it does every run. + */ + +import { db } from '../../database'; +import { nextRunForCadence } from './cadence'; +import type { + Mission, + MissionCadence, + MissionInputRef, + MissionIteration, + MissionState, + PlanStep, +} from './types'; +import { MISSIONS_TABLE } from './types'; + +const table = () => db.table(MISSIONS_TABLE); + +// ── Create ───────────────────────────────────────────────── + +export interface CreateMissionInput { + title: string; + conceptMarkdown: string; + objective: string; + inputs?: MissionInputRef[]; + cadence: MissionCadence; +} + +export async function createMission(input: CreateMissionInput): Promise { + const now = new Date().toISOString(); + const mission: Mission = { + id: crypto.randomUUID(), + createdAt: now, + updatedAt: now, + title: input.title, + conceptMarkdown: input.conceptMarkdown, + objective: input.objective, + inputs: input.inputs ?? [], + cadence: input.cadence, + state: 'active', + nextRunAt: nextRunForCadence(input.cadence, new Date()), + iterations: [], + }; + await table().add(mission); + return mission; +} + +// ── Read ─────────────────────────────────────────────────── + +export async function getMission(id: string): Promise { + return table().get(id); +} + +export interface ListMissionsFilter { + state?: MissionState; + /** Only Missions whose `nextRunAt` has passed — used by the Runner. */ + dueBefore?: string; +} + +export async function listMissions(filter: ListMissionsFilter = {}): Promise { + let coll = table().orderBy('createdAt').reverse(); + if (filter.state) coll = coll.filter((m) => m.state === filter.state); + if (filter.dueBefore) { + const cutoff = filter.dueBefore; + coll = coll.filter( + (m) => m.state === 'active' && typeof m.nextRunAt === 'string' && m.nextRunAt <= cutoff + ); + } + const all = await coll.toArray(); + return all.filter((m) => !m.deletedAt); +} + +// ── Update ───────────────────────────────────────────────── + +export interface MissionPatch { + title?: string; + conceptMarkdown?: string; + objective?: string; + inputs?: MissionInputRef[]; + cadence?: MissionCadence; +} + +export async function updateMission(id: string, patch: MissionPatch): Promise { + const mods: Partial = { + ...patch, + updatedAt: new Date().toISOString(), + }; + if (patch.cadence) { + mods.nextRunAt = nextRunForCadence(patch.cadence, new Date()); + } + await table().update(id, mods); +} + +// ── Lifecycle ────────────────────────────────────────────── + +export async function pauseMission(id: string): Promise { + await table().update(id, { state: 'paused', updatedAt: new Date().toISOString() }); +} + +export async function resumeMission(id: string): Promise { + const mission = await getMission(id); + if (!mission) throw new Error(`Mission not found: ${id}`); + await table().update(id, { + state: 'active', + nextRunAt: nextRunForCadence(mission.cadence, new Date()), + updatedAt: new Date().toISOString(), + }); +} + +export async function completeMission(id: string): Promise { + await table().update(id, { + state: 'done', + nextRunAt: undefined, + updatedAt: new Date().toISOString(), + }); +} + +export async function archiveMission(id: string): Promise { + await table().update(id, { state: 'archived', updatedAt: new Date().toISOString() }); +} + +export async function deleteMission(id: string): Promise { + await table().update(id, { deletedAt: new Date().toISOString() }); +} + +// ── Iterations ───────────────────────────────────────────── + +export interface StartIterationInput { + plan: PlanStep[]; +} + +/** Begin a new iteration — appends it with status `running`. */ +export async function startIteration( + missionId: string, + input: StartIterationInput +): Promise { + const mission = await getMission(missionId); + if (!mission) throw new Error(`Mission not found: ${missionId}`); + const iteration: MissionIteration = { + id: crypto.randomUUID(), + startedAt: new Date().toISOString(), + plan: input.plan, + overallStatus: 'running', + }; + await table().update(missionId, { + iterations: [...mission.iterations, iteration], + updatedAt: new Date().toISOString(), + }); + return iteration; +} + +export interface FinishIterationInput { + summary?: string; + overallStatus: MissionIteration['overallStatus']; + /** Replace the plan with the post-run state (steps with proposal ids / final statuses). */ + plan?: PlanStep[]; +} + +export async function finishIteration( + missionId: string, + iterationId: string, + input: FinishIterationInput +): Promise { + const mission = await getMission(missionId); + if (!mission) throw new Error(`Mission not found: ${missionId}`); + + const updatedIterations = mission.iterations.map((it) => + it.id === iterationId + ? { + ...it, + finishedAt: new Date().toISOString(), + overallStatus: input.overallStatus, + ...(input.summary !== undefined ? { summary: input.summary } : {}), + ...(input.plan !== undefined ? { plan: input.plan } : {}), + } + : it + ); + await table().update(missionId, { + iterations: updatedIterations, + // Advance nextRunAt now that this iteration is done + nextRunAt: nextRunForCadence(mission.cadence, new Date()), + updatedAt: new Date().toISOString(), + }); +} + +/** Attach free-text user feedback to the most recent iteration. */ +export async function addIterationFeedback( + missionId: string, + iterationId: string, + userFeedback: string +): Promise { + const mission = await getMission(missionId); + if (!mission) throw new Error(`Mission not found: ${missionId}`); + const updatedIterations = mission.iterations.map((it) => + it.id === iterationId ? { ...it, userFeedback } : it + ); + await table().update(missionId, { + iterations: updatedIterations, + updatedAt: new Date().toISOString(), + }); +} diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/types.ts b/apps/mana/apps/web/src/lib/data/ai/missions/types.ts new file mode 100644 index 000000000..e6feb2526 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/missions/types.ts @@ -0,0 +1,119 @@ +/** + * Missions — long-lived AI work items. + * + * A Mission is the user's standing instruction to the AI ("review my goals + * every Monday and propose three concrete actions"). The AI plans, stages + * proposals (via `pendingProposals`), collects feedback, and iterates. + * + * This file is data-model only. The Planner and Runner live elsewhere and + * operate on Missions — see `COMPANION_BRAIN_ARCHITECTURE.md §20` for the + * full pipeline. + * + * Missions sync (unlike proposals) — they are long-lived user-authored + * artifacts and should be available cross-device. Sensitive user-typed + * text (conceptMarkdown, objective, rationale strings in iterations) is + * encryption-eligible; IDs, timestamps, state enums stay plaintext. + */ + +import type { ProposalStatus } from '../proposals/types'; + +/** Lifecycle of a Mission. */ +export type MissionState = 'active' | 'paused' | 'done' | 'archived'; + +/** How often the Runner should pick this Mission up. */ +export type MissionCadence = + | { readonly kind: 'manual' } + | { readonly kind: 'interval'; readonly everyMinutes: number } + | { readonly kind: 'daily'; readonly atHour: number; readonly atMinute: number } + | { + readonly kind: 'weekly'; + readonly dayOfWeek: 0 | 1 | 2 | 3 | 4 | 5 | 6; + readonly atHour: number; + } + | { readonly kind: 'cron'; readonly expression: string }; + +/** Reference to a record in another module this Mission draws context from. */ +export interface MissionInputRef { + /** Source module — e.g. `'notes'`, `'goals'`, `'kontext'`. */ + readonly module: string; + /** Dexie table name (needed because some modules have multiple). */ + readonly table: string; + /** Record id. */ + readonly id: string; +} + +/** A single step the Planner proposed for the current iteration. */ +export interface PlanStep { + readonly id: string; + /** Human-readable summary shown in the Workbench. */ + readonly summary: string; + /** What runs — today only toolCalls; later: sub-missions, text-generation, etc. */ + readonly intent: + | { + readonly kind: 'toolCall'; + readonly toolName: string; + readonly params: Record; + } + | { readonly kind: 'note'; readonly body: string }; + /** Proposal id if this step was staged; undefined before execution. */ + readonly proposalId?: string; + /** Outcome of this step. */ + readonly status: 'planned' | 'staged' | 'approved' | 'rejected' | 'skipped' | 'failed'; +} + +/** One autonomous run of the Mission, produced by the Runner invoking the Planner. */ +export interface MissionIteration { + readonly id: string; + readonly startedAt: string; + readonly finishedAt?: string; + /** Plan the Planner generated this run. */ + readonly plan: readonly PlanStep[]; + /** AI's own notes on what it did and why (for the next iteration's context). */ + readonly summary?: string; + /** Free-text feedback the user attached on review. */ + readonly userFeedback?: string; + /** Shortcut derived from step proposal statuses — used by queries / UI. */ + readonly overallStatus: 'running' | 'awaiting-review' | 'approved' | 'rejected' | 'failed'; +} + +export interface Mission { + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + /** Short user-facing name. */ + title: string; + /** Markdown doc describing the concept / rules of engagement. */ + conceptMarkdown: string; + /** One-sentence concrete objective. */ + objective: string; + /** Module records this Mission reads from for context. */ + inputs: readonly MissionInputRef[]; + /** Cadence the Runner honours. */ + cadence: MissionCadence; + /** Lifecycle. */ + state: MissionState; + /** ISO timestamp of the next scheduled run, or undefined if not scheduled. */ + nextRunAt?: string; + /** All past iterations, newest last. */ + iterations: readonly MissionIteration[]; + + // ── Bookkeeping ────────────────────────── + userId?: string; + deletedAt?: string; +} + +export const MISSIONS_TABLE = 'aiMissions'; + +/** Helper — derive a summary status for a proposal-id lookup. */ +export function planStepStatusFromProposal(status: ProposalStatus): PlanStep['status'] { + switch (status) { + case 'pending': + return 'staged'; + case 'approved': + return 'approved'; + case 'rejected': + return 'rejected'; + case 'expired': + return 'skipped'; + } +} 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 new file mode 100644 index 000000000..727d6833b --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/module.config.ts @@ -0,0 +1,18 @@ +/** + * Module config for AI Workbench tables that participate in sync. + * + * Sync surface: + * - `aiMissions` — long-lived user-authored AI work items (cross-device) + * + * NOT in this config: + * - `pendingProposals` — intentionally local-only (see proposals/types.ts). + * Approvals run the underlying tool which writes through its own module's + * sync path, so proposals don't need to travel. + */ + +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const aiModuleConfig: ModuleConfig = { + appId: 'ai', + tables: [{ name: 'aiMissions' }], +}; diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index a19450271..8f7dcc930 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -508,6 +508,14 @@ db.version(17).stores({ pendingProposals: 'id, status, createdAt, missionId, [status+createdAt]', }); +// v18 — AI Missions: long-lived autonomous work items. Syncs cross-device +// (unlike `pendingProposals`) so the user can configure a mission on one +// device and see it run on another. Indexes power the Runner's "due now" +// query and the Workbench's state filters. +db.version(18).stores({ + aiMissions: 'id, state, createdAt, nextRunAt, [state+nextRunAt]', +}); + // ─── 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/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index 4eb886f57..6ad674ef7 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -96,6 +96,7 @@ import { meditateModuleConfig } from '$lib/modules/meditate/module.config'; import { sleepModuleConfig } from '$lib/modules/sleep/module.config'; import { moodModuleConfig } from '$lib/modules/mood/module.config'; import { kontextModuleConfig } from '$lib/modules/kontext/module.config'; +import { aiModuleConfig } from '$lib/data/ai/module.config'; export const MODULE_CONFIGS: readonly ModuleConfig[] = [ manaCoreConfig, @@ -147,6 +148,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ sleepModuleConfig, moodModuleConfig, kontextModuleConfig, + aiModuleConfig, ]; // ─── Derived Maps ──────────────────────────────────────────