feat(templates): generalise to WorkbenchTemplate + ship Calmness pilot (T1)

First pass of the workbench-templates plan (docs/plans/workbench-
templates.md) — templates are no longer agent-centric but a general
"starter kit" bundle: optional agent + optional scene + optional
missions + optional per-module seeds. Pilot non-AI template "Calmness"
ships alongside.

Shape generalisation (packages/shared-ai/src/agents/templates/types.ts):
- AgentTemplate renamed to WorkbenchTemplate; all fields now optional
  (agent, scene, missions, seeds). Back-compat AgentTemplate alias
  kept so research/context/today keep compiling.
- Added `category: 'ai'|'wellness'|'work'|'lifeEvent'|'delight'` +
  `icon` (for non-agent templates that have no avatar) + `version`
  field (for future update-detection).
- New WorkbenchTemplateSeedItem shape: `{stableId?, data: unknown}`.
  Module-specific seed payloads are typed at the handler side.
- Existing three AI templates nachgezogen: category='ai' (or
  'delight' for today-agent), icon, version='1'.

Seed infrastructure:
- apps/mana/apps/web/src/lib/data/ai/agents/seed-registry.ts — in-
  memory handler map keyed by module name; module-local seed.ts files
  register themselves at import time.
- apps/mana/apps/web/src/lib/modules/meditate/seed.ts — first handler:
  createPreset-based, idempotent via stableId embedded as HTML
  comment in the preset description (T1 pragmatism; T2 adds a proper
  column on the preset schema).
- data/ai/missions/setup.ts pulls `import '$lib/modules/meditate/seed'`
  so the handler is registered before any template is applied.

Applicator upgrades (data/ai/agents/apply-template.ts):
- Agent step now optional — skipped cleanly when template has no
  agent part.
- New step 4: seeds. Walks template.seeds, looks up the handler for
  each module, aggregates per-item outcomes (created/skipped-exists/
  failed) into result.seedOutcomes. Missing handler = warning, not
  fatal. Crypto/encryption unchanged — seeds go through the same
  module stores that module code already uses.
- Result shape gains `seedOutcomes: Record<string, SeedOutcome[]>`
  so the gallery can show "3 new, 1 already there".

Calmness pilot (packages/shared-ai/src/agents/templates/calmness.ts):
- category='wellness', NO agent, scene with meditate/mood/journal/
  sleep apps, two meditate preset seeds:
  * 4-7-8 Atmung (breathing preset)
  * Body-Scan 10min (bodyscan preset with 9 scan steps)
- Each seed has a stableId so re-apply is idempotent.

Gallery updates (routes/(app)/agents/templates/+page.svelte):
- Card avatar falls back to t.icon when no agent. "Agent" chip shows
  only for agent-templates; "N Seeds" chip shows for templates with
  seeds.
- Detail header shows "Workbench-Setup ohne AI-Agent" when no agent.
- New "Seeds" preview section: lists per-module counts + item names.
- Options section gains a "Seed-Daten in Module einpflegen" checkbox.
- Success panel shows seed summary: "3 Seeds neu, 1 bereits
  vorhanden".

Tests: shared-ai 26/26, webapp svelte-check 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-16 01:07:41 +02:00
parent a524997a2f
commit a08e45ca16
14 changed files with 887 additions and 119 deletions

View file

@ -1,25 +1,30 @@
/**
* Template applicator turns an AgentTemplate from `@mana/shared-ai`
* into concrete Dexie records: an Agent, optionally a workbench Scene,
* optionally starter Missions.
* Template applicator turns a WorkbenchTemplate from `@mana/shared-ai`
* into concrete Dexie records: optionally an Agent, optionally a
* workbench Scene, optionally starter Missions, optionally module-
* scoped seeds.
*
* Ordering matters: agent first (so mission.agentId can reference it),
* then scene (so `setActive` lands on a scene that contains the
* relevant apps), then missions (so they show up under the agent).
* Ordering matters:
* 1. Agent (so mission.agentId can reference it)
* 2. Scene (so `setActive` lands on the right layout)
* 3. Missions (so they show up under the agent)
* 4. Seeds per module (runs last because a seed might reference
* the freshly-active scene conceptually but never programmatically)
*
* Error semantics: failures bubble up but the ones that happened
* before are NOT rolled back user is told what did and didn't land.
* Pure-transaction semantics aren't worth the wrapper complexity for
* a 3-step sequence that is already idempotent:
* Error semantics: failures bubble up as warnings in the result they
* don't abort later steps. Pure-transaction semantics aren't worth the
* wrapper complexity since each step is idempotent-ish on re-apply:
* - duplicate agent name returns existing agent (getOrCreate-ish)
* - scene creation is a fresh insert, no dedup needed
* - missions use fresh UUIDs, no dedup needed
* - scene creation is a fresh insert (skipped if agent was existing)
* - missions use fresh UUIDs
* - seeds check stableId before creating
*/
import { createAgent, findByName, DuplicateAgentNameError } from './store';
import { createMission, pauseMission } from '../missions/store';
import { workbenchScenesStore } from '$lib/stores/workbench-scenes.svelte';
import type { AgentTemplate } from '@mana/shared-ai';
import { getSeedHandler, type SeedOutcome } from './seed-registry';
import type { WorkbenchTemplate, WorkbenchTemplateSeedItem } from '@mana/shared-ai';
import type { Agent } from './types';
export interface ApplyTemplateOptions {
@ -28,6 +33,8 @@ export interface ApplyTemplateOptions {
createScene?: boolean;
/** Create the template's starter missions. Default true. */
createMissions?: boolean;
/** Apply the template's per-module seeds. Default true. */
applySeeds?: boolean;
/** When true, starter missions are left in whatever `startPaused`
* the template declares (usually paused). When false, override to
* active Power-User opt-in that skips the "click Play" step. */
@ -35,70 +42,73 @@ export interface ApplyTemplateOptions {
}
export interface ApplyTemplateResult {
/** The agent that was created OR the pre-existing agent with the
* same name that we re-used. `wasExisting` tells you which. */
readonly agent: Agent;
readonly wasExisting: boolean;
/** The agent that was created OR re-used. Undefined when the
* template had no `agent` part (non-AI templates). */
readonly agent?: Agent;
/** True when we re-used an existing agent with the same name. */
readonly wasExistingAgent: boolean;
readonly sceneId?: string;
readonly missionIds: readonly string[];
/** Any non-fatal errors from the sequence. Agent is guaranteed when
* this array is empty on agent slot; scene/mission failures still
* return here so the UI can surface them without blocking. */
/** Per-module seed outcomes keyed by module name. */
readonly seedOutcomes: Readonly<Record<string, readonly SeedOutcome[]>>;
/** Non-fatal warnings from any step; UI surfaces these alongside
* the success panel. */
readonly warnings: readonly string[];
}
/**
* Apply a template end-to-end. Returns a result object describing what
* actually landed in Dexie. Call sites render a success panel or a
* partial-failure panel based on `warnings` + presence of each field.
* actually landed in Dexie. Call sites render a success or partial-
* failure panel based on warnings + presence of each field.
*/
export async function applyTemplate(
template: AgentTemplate,
template: WorkbenchTemplate,
opts: ApplyTemplateOptions = {}
): Promise<ApplyTemplateResult> {
const {
createScene = template.scene !== undefined,
createMissions = true,
applySeeds = true,
respectPauseHint = true,
} = opts;
const warnings: string[] = [];
let agent: Agent | undefined;
let wasExistingAgent = false;
// 1. Agent — the only required piece. If duplicate name, re-use the
// existing agent (idempotent "apply twice" behavior).
let agent: Agent;
let wasExisting = false;
try {
agent = await createAgent({
name: template.agent.name,
avatar: template.agent.avatar,
role: template.agent.role,
systemPrompt: template.agent.systemPrompt,
memory: template.agent.memory,
policy: template.agent.policy,
maxTokensPerDay: template.agent.maxTokensPerDay,
maxConcurrentMissions: template.agent.maxConcurrentMissions,
});
} catch (err) {
if (err instanceof DuplicateAgentNameError) {
const existing = await findByName(template.agent.name);
if (!existing) {
// 1. Agent (optional) — idempotent via duplicate-name lookup.
if (template.agent) {
try {
agent = await createAgent({
name: template.agent.name,
avatar: template.agent.avatar,
role: template.agent.role,
systemPrompt: template.agent.systemPrompt,
memory: template.agent.memory,
policy: template.agent.policy,
maxTokensPerDay: template.agent.maxTokensPerDay,
maxConcurrentMissions: template.agent.maxConcurrentMissions,
});
} catch (err) {
if (err instanceof DuplicateAgentNameError) {
const existing = await findByName(template.agent.name);
if (!existing) throw err;
agent = existing;
wasExistingAgent = true;
warnings.push(
`Ein Agent mit Namen "${template.agent.name}" existiert bereits — Template nutzt diesen.`
);
} else {
throw err;
}
agent = existing;
wasExisting = true;
warnings.push(
`Ein Agent mit Namen "${template.agent.name}" existiert bereits — Template nutzt diesen.`
);
} else {
throw err;
}
}
// 2. Scene — skipped on re-apply so we don't generate Scene-Clones
// on every click.
// 2. Scene — skipped on re-apply (wasExistingAgent) so we don't
// generate Scene-Clones on every click. For non-agent templates the
// scene is always created (there's no per-apply dedup key).
let sceneId: string | undefined;
if (createScene && template.scene && !wasExisting) {
if (createScene && template.scene && !wasExistingAgent) {
try {
sceneId = await workbenchScenesStore.createScene({
name: template.scene.name,
@ -111,17 +121,15 @@ export async function applyTemplate(
`Scene konnte nicht angelegt werden: ${err instanceof Error ? err.message : String(err)}`
);
}
} else if (createScene && wasExisting) {
} else if (createScene && wasExistingAgent) {
warnings.push(
'Scene übersprungen weil der Agent schon existierte — öffne die Scene manuell falls gewünscht.'
);
}
// 3. Missions — paused by default per template hint. Reapply on an
// existing agent is idempotent-ish: we create NEW missions (they
// have fresh UUIDs) but the UI should make that obvious.
// 3. Missions — paused by default per template hint.
const missionIds: string[] = [];
if (createMissions && template.missions) {
if (createMissions && template.missions && agent) {
for (const m of template.missions) {
try {
const mission = await createMission({
@ -144,11 +152,46 @@ export async function applyTemplate(
}
}
// 4. Per-module seeds — applicator looks up a handler for each
// module name in the template's `seeds` map. Missing handler =
// warning, not fatal (template lists seeds for a module the webapp
// doesn't support yet).
const seedOutcomes: Record<string, readonly SeedOutcome[]> = {};
if (applySeeds && template.seeds) {
const seedEntries = Object.entries(template.seeds) as Array<
[string, readonly WorkbenchTemplateSeedItem[]]
>;
for (const [moduleName, items] of seedEntries) {
const handler = getSeedHandler(moduleName);
if (!handler) {
warnings.push(
`Seed-Handler für Modul "${moduleName}" nicht registriert — ${items.length} Seed(s) übersprungen.`
);
continue;
}
try {
const outcomes = await handler.apply(items);
seedOutcomes[moduleName] = outcomes;
const failures = outcomes.filter((o) => o.outcome === 'failed');
for (const f of failures) {
warnings.push(
`Seed "${f.stableId ?? '(ohne id)'}" in ${moduleName} fehlgeschlagen: ${f.error ?? '(unbekannt)'}`
);
}
} catch (err) {
warnings.push(
`Seed-Handler für "${moduleName}" hat unerwartet geworfen: ${err instanceof Error ? err.message : String(err)}`
);
}
}
}
return {
agent,
wasExisting,
wasExistingAgent,
sceneId,
missionIds,
seedOutcomes,
warnings,
};
}

View file

@ -0,0 +1,50 @@
/**
* Seed-Handler registry module-local bridges between a
* WorkbenchTemplate's `seeds` field and each module's own store.
*
* When a user applies a template, `apply-template.ts` walks
* `template.seeds` (a map `moduleName → items[]`) and calls the
* registered handler for each module. The handler unpacks each item's
* `data` payload according to its own schema + is responsible for
* idempotency via the optional `stableId`.
*
* Handlers register themselves at module-load time via
* `registerSeedHandler(...)`. To ensure the registry is populated
* before a template is applied, the seed modules must be imported
* from somewhere in the eager boot path usually via the mission-
* tick setup in `$lib/data/ai/missions/setup.ts`.
*/
import type { WorkbenchTemplateSeedItem } from '@mana/shared-ai';
export interface SeedOutcome {
/** Carried through from the input item so callers can correlate. */
readonly stableId?: string;
readonly outcome: 'created' | 'skipped-exists' | 'failed';
readonly error?: string;
}
export interface SeedHandler {
/** Module name matching keys in `WorkbenchTemplate.seeds`. */
readonly moduleName: string;
/** Applies all items for this module. Should return one outcome
* per input item in input order. Must NOT throw failures are
* returned as `{outcome: 'failed'}` entries. */
readonly apply: (items: readonly WorkbenchTemplateSeedItem[]) => Promise<readonly SeedOutcome[]>;
}
const handlers = new Map<string, SeedHandler>();
export function registerSeedHandler(handler: SeedHandler): void {
handlers.set(handler.moduleName, handler);
}
export function getSeedHandler(moduleName: string): SeedHandler | undefined {
return handlers.get(moduleName);
}
/** For tests that want to reset the registry between runs. Not
* exported from any barrel; import directly when needed. */
export function _clearSeedHandlersForTesting(): void {
handlers.clear();
}

View file

@ -21,6 +21,12 @@ import { aiPlanTask } from '$lib/llm-tasks/ai-plan';
import { runDueMissions, type MissionRunnerDeps } from './runner';
import { registerDefaultInputResolvers } from './default-resolvers';
import { runAgentsBootstrap } from '../agents/bootstrap';
// Side-effect imports to populate the seed-handler registry before any
// template is applied. Keep this list tight — each module pulls its
// own imports (encryptRecord, Dexie tables, etc.) so adding a seed
// handler to a rarely-used module slows down the hot path otherwise.
// See docs/plans/workbench-templates.md §T1.
import '$lib/modules/meditate/seed';
import type { AiPlanInput, AiPlanOutput } from './planner/types';
/** Default interval between tick scans. One minute is fine for foreground use. */

View file

@ -0,0 +1,78 @@
/**
* Meditate module seed handler applied by the workbench-template
* applicator when a template includes `seeds.meditate`.
*
* Idempotency strategy (T1 pragmatism): the stableId is embedded into
* the preset's description as a fenced-code marker looking for
* `\`template-*\`` in an existing record counts as "already seeded".
* T2 introduces a proper `templateStableId` column on the preset
* schema, which will make this cleaner.
*/
import { db } from '$lib/data/database';
import { registerSeedHandler, type SeedOutcome } from '$lib/data/ai/agents/seed-registry';
import { meditateStore } from './stores/meditate.svelte';
import type { LocalMeditatePreset, MeditateCategory, BreathPattern } from './types';
interface MeditatePresetSeed {
name: string;
description?: string;
category: MeditateCategory;
breathPattern?: BreathPattern | null;
bodyScanSteps?: string[] | null;
defaultDurationSec?: number;
}
/** Build the description string with an appended stable-id marker so
* later apply calls can recognize the seeded preset. Users see only
* the human-readable prose; the marker is invisible in most views. */
function buildDescription(seed: MeditatePresetSeed, stableId: string | undefined): string {
const marker = stableId ? `\n\n<!-- seed:${stableId} -->` : '';
return (seed.description ?? '') + marker;
}
function hasSeedMarker(desc: string | undefined, stableId: string): boolean {
return typeof desc === 'string' && desc.includes(`seed:${stableId}`);
}
registerSeedHandler({
moduleName: 'meditate',
async apply(items) {
const outcomes: SeedOutcome[] = [];
const existing = await db.table<LocalMeditatePreset>('meditatePresets').toArray();
for (const item of items) {
const seed = item.data as MeditatePresetSeed;
if (item.stableId) {
const already = existing.find(
(p) => !p.deletedAt && hasSeedMarker(p.description, item.stableId!)
);
if (already) {
outcomes.push({ stableId: item.stableId, outcome: 'skipped-exists' });
continue;
}
}
try {
await meditateStore.createPreset({
name: seed.name,
description: buildDescription(seed, item.stableId),
category: seed.category,
breathPattern: seed.breathPattern ?? null,
bodyScanSteps: seed.bodyScanSteps ?? null,
defaultDurationSec: seed.defaultDurationSec,
});
outcomes.push({ stableId: item.stableId, outcome: 'created' });
} catch (err) {
outcomes.push({
stableId: item.stableId,
outcome: 'failed',
error: err instanceof Error ? err.message : String(err),
});
}
}
return outcomes;
},
});

View file

@ -17,10 +17,13 @@
let selected = $state<AgentTemplate | null>(null);
let applying = $state(false);
let result = $state<{
agentName: string;
agentName?: string;
sceneCreated: boolean;
missionCount: number;
wasExisting: boolean;
wasExistingAgent: boolean;
seedCreated: number;
seedSkipped: number;
seedFailed: number;
warnings: readonly string[];
} | null>(null);
let error = $state<string | null>(null);
@ -28,6 +31,7 @@
// Override toggles — default to the "smart" values we recommend.
let optCreateScene = $state(true);
let optCreateMissions = $state(true);
let optApplySeeds = $state(true);
let optStartActive = $state(false); // false = respect paused hint
function openDetail(t: AgentTemplate) {
@ -36,9 +40,28 @@
error = null;
optCreateScene = t.scene !== undefined;
optCreateMissions = t.missions !== undefined && t.missions.length > 0;
optApplySeeds = t.seeds !== undefined && Object.keys(t.seeds).length > 0;
optStartActive = false;
}
function countSeedOutcomes(outcomes: Readonly<Record<string, readonly { outcome: string }[]>>): {
created: number;
skipped: number;
failed: number;
} {
let created = 0;
let skipped = 0;
let failed = 0;
for (const items of Object.values(outcomes)) {
for (const o of items) {
if (o.outcome === 'created') created++;
else if (o.outcome === 'skipped-exists') skipped++;
else failed++;
}
}
return { created, skipped, failed };
}
async function handleApply() {
if (!selected) return;
applying = true;
@ -47,13 +70,18 @@
const r = await applyTemplate(selected, {
createScene: optCreateScene,
createMissions: optCreateMissions,
applySeeds: optApplySeeds,
respectPauseHint: !optStartActive,
});
const seedSums = countSeedOutcomes(r.seedOutcomes);
result = {
agentName: r.agent.name,
agentName: r.agent?.name,
sceneCreated: r.sceneId !== undefined,
missionCount: r.missionIds.length,
wasExisting: r.wasExisting,
wasExistingAgent: r.wasExistingAgent,
seedCreated: seedSums.created,
seedSkipped: seedSums.skipped,
seedFailed: seedSums.failed,
warnings: r.warnings,
};
} catch (err) {
@ -90,16 +118,21 @@
style="--accent: {t.color}"
onclick={() => openDetail(t)}
>
<span class="avatar">{t.agent.avatar}</span>
<span class="avatar">{t.agent?.avatar ?? t.icon}</span>
<span class="label">{t.label}</span>
<span class="tagline">{t.tagline}</span>
<span class="meta">
{#if t.agent}<span class="chip">Agent</span>{/if}
{#if t.scene}<span class="chip">Scene</span>{/if}
{#if t.missions && t.missions.length > 0}
<span class="chip"
>{t.missions.length} Mission{t.missions.length !== 1 ? 'en' : ''}</span
>
{/if}
{#if t.seeds}
{@const total = Object.values(t.seeds).reduce((s, items) => s + items.length, 0)}
<span class="chip">{total} Seed{total !== 1 ? 's' : ''}</span>
{/if}
</span>
</button>
{/each}
@ -108,10 +141,14 @@
{#if selected}
<section class="detail" style="--accent: {selected.color}">
<header class="detail-head">
<span class="detail-avatar">{selected.agent.avatar}</span>
<span class="detail-avatar">{selected.agent?.avatar ?? selected.icon}</span>
<div>
<h2>{selected.label}</h2>
<p class="detail-role">{selected.agent.role}</p>
{#if selected.agent}
<p class="detail-role">{selected.agent.role}</p>
{:else}
<p class="detail-role">Workbench-Setup ohne AI-Agent</p>
{/if}
</div>
</header>
@ -161,6 +198,33 @@
</section>
{/if}
{#if selected.seeds && Object.keys(selected.seeds).length > 0}
<section class="preview">
<h3>Seeds</h3>
<p class="seed-hint">
Vorgefüllte Einträge in deinen Modulen. Werden als neue Records angelegt; bestehende
Einträge mit gleicher Seed-ID werden übersprungen (idempotent).
</p>
<ul class="seeds-preview">
{#each Object.entries(selected.seeds) as [moduleName, items]}
<li>
<strong>{moduleName}</strong>
<span class="seed-count">
{items.length} Eintr{items.length !== 1 ? 'äge' : 'ag'}
</span>
<ul class="seed-items">
{#each items as item}
<li>
<code>{(item.data as { name?: string }).name ?? '(unbenannt)'}</code>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
</section>
{/if}
<section class="options">
<h3>Optionen</h3>
{#if selected.scene}
@ -179,6 +243,12 @@
<span>Mission(en) sofort aktivieren (Standard: pausiert)</span>
</label>
{/if}
{#if selected.seeds && Object.keys(selected.seeds).length > 0}
<label class="opt">
<input type="checkbox" bind:checked={optApplySeeds} />
<span>Seed-Daten in Module einpflegen</span>
</label>
{/if}
</section>
{#if result}
@ -186,9 +256,13 @@
<Check size={16} />
<div>
<strong>
{result.wasExisting
? `„${result.agentName}" existierte schon — wiederverwendet.`
: `Agent „${result.agentName}" angelegt.`}
{#if result.agentName}
{result.wasExistingAgent
? `„${result.agentName}" existierte schon — wiederverwendet.`
: `Agent „${result.agentName}" angelegt.`}
{:else}
Template angewendet.
{/if}
</strong>
<p>
{#if result.sceneCreated}Scene angelegt + aktiviert.{/if}
@ -196,6 +270,11 @@
{result.missionCount} Mission{result.missionCount !== 1 ? 'en' : ''}
{optStartActive ? 'aktiviert' : 'pausiert angelegt'}.
{/if}
{#if result.seedCreated + result.seedSkipped + result.seedFailed > 0}
{result.seedCreated} Seed{result.seedCreated !== 1 ? 's' : ''} neu,
{result.seedSkipped} bereits vorhanden{#if result.seedFailed > 0}, {result.seedFailed}
fehlgeschlagen{/if}.
{/if}
</p>
{#if result.warnings.length > 0}
<ul class="warnings">
@ -509,4 +588,49 @@
justify-content: space-between;
gap: 0.5rem;
}
.seed-hint {
margin: 0 0 0.5rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.seeds-preview {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.seeds-preview > li {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.seeds-preview > li > strong {
font-size: 0.8125rem;
text-transform: lowercase;
color: var(--accent);
}
.seed-count {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.seed-items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.seed-items li {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
font-size: 0.75rem;
}
.seed-items code {
font-size: 0.75rem;
}
</style>