Closes the two remaining gaps after the seeding-cleanup landed: - `data/space-stamping.test.ts` exercises the smart-hook contract end-to-end against fake-indexeddb. Four scenarios: active Brand Space → row carries Brand UUID; no active Space → personal sentinel; explicit spaceId on the record is preserved verbatim; flipping the active Space between writes flips the stamp. The Brand-Space case is the regression guard for the original bug (writes silently routing to Personal after `reconcileSentinels`). - `apps/mana/CLAUDE.md` gets a Per-Space Seeds section so the next module dev who needs to pre-populate something on Space activation finds the `registerSpaceSeed` pattern + `data/seeds/index.ts` barrel + the deterministic-id discipline without grepping the codebase. Reference impl link points at workbench-home. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
19 KiB
CLAUDE.md — Mana Unified App
Project-level guidance for apps/mana/. For monorepo-wide patterns (auth, services, dev commands, env vars), see the root CLAUDE.md.
Project Overview
Mana is the unified web app at mana.how, serving 27+ product modules (todo, calendar, contacts, chat, notes, dreams, memoro, cards, picture, presi, music, storage, …) under one SvelteKit build, one IndexedDB, one auth session, one deployment.
apps/mana/apps/
├── web/ # SvelteKit 2 + Svelte 5 unified app — the main surface
└── landing/ # Astro static landing → Cloudflare Pages
Note: apps/mana/apps/mobile/ was removed on 2026-04-20 along with five
other product mobile apps (cards, chat, context, picture, traces). The
only remaining Expo/React Native surface in the repo is apps/memoro/ apps/mobile/.
Module System
Each module lives in apps/web/src/lib/modules/{name}/ and registers itself via module.config.ts. Module state is split into three files:
| File | Role |
|---|---|
collections.ts |
Dexie table references + (sometimes) seed data |
queries.ts |
Read-side — Dexie liveQuery hooks, type converters, pure helpers for $derived |
stores/*.svelte.ts |
Write-side — mutation methods. Never reads for UI rendering (queries.ts does that). Only reads when a mutation needs existing state (toggle, increment). |
Module store pattern
// modules/todo/stores/tasks.svelte.ts
export const tasksStore = {
async createTask(input: {...}) {
const newLocal: LocalTask = { ...input, id: crypto.randomUUID() };
const plaintextSnapshot = toTask({ ...newLocal });
await encryptRecord('tasks', newLocal);
await taskTable.add(newLocal);
return plaintextSnapshot;
},
};
// modules/todo/queries.ts
export function useAllTasks() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalTask>('tasks').orderBy('order').toArray();
const visible = locals.filter((t) => !t.deletedAt);
const decrypted = await decryptRecords('tasks', visible);
return decrypted.map(toTask);
}, [] as Task[]);
}
Data Layer (Local-First)
The app reads and writes IndexedDB first, then syncs to mana-sync (Go, port 3050) in the background. One Dexie database (mana) holds 120+ collections from every module — colliding table names get a module prefix (e.g. todoProjects, cardDecks, presiDecks).
User action (e.g. tasksStore.createTask)
│
▼
Module store builds the LocalRecord
│
▼
encryptRecord(tableName, record)
│
▼
table.add(encryptedRecord) ← Dexie write
│
▼
Dexie hooks (database.ts):
- stamp userId
- stamp __fieldTimestamps per field
- stamp __lastActor + __fieldActors (user / ai / system — see AI Workbench)
- record into _pendingChanges (tagged with appId + actor)
- record into _activity
│
▼
Sync engine (sync.ts) — debounced 1s
- groups changes by appId
- POSTs to mana-sync
│
▼
mana-sync → PostgreSQL with field-level LWW + RLS
│
▼
Other clients pull via SSE / polling
│
▼
applyServerChanges → Dexie hooks (suppressed) → liveQuery → decryptRecord → UI
Deep dive: apps/web/src/lib/data/DATA_LAYER_AUDIT.md — sync engine, retry/backoff, quota recovery, telemetry, RLS, encryption rollout, threat model. Single most important file for understanding how the app works under the hood.
At-Rest Encryption
User-typed content in 27 tables is encrypted with AES-GCM-256 before it touches IndexedDB. Master key lives in mana-auth (KEK-wrapped) and is fetched on login.
| Mode | Default | What Mana can decrypt |
|---|---|---|
| Standard | ✅ Yes | The user's master key, via the server-side KEK |
| Zero-Knowledge | Opt-in (Settings → Sicherheit) | Nothing — recovery code lives only with the user |
When writing module code that touches sensitive fields:
- Add the table to
apps/web/src/lib/data/crypto/registry.tswith the field allowlist await encryptRecord(tableName, record)beforetable.add()/table.update()await decryptRecords(tableName, visible)after the Dexie query, before the type converter- The Dexie hook in
database.tsdoes NOT auto-encrypt — every store does it explicitly. This is by design (Web Crypto is async, hooks are sync).
Defaults: encrypt for new user-typed text fields; plaintext for IDs / timestamps / sort keys / enum discriminators.
User-facing docs: apps/docs/src/content/docs/architecture/security.mdx.
Routing
apps/web/src/routes/
├── (auth)/ # Public auth pages (login, register, recovery)
├── (app)/ # Auth-gated app surface — 27+ module routes
│ ├── dashboard/ # Customizable widget grid
│ ├── settings/
│ │ └── security/ # Vault status + recovery code + ZK opt-in
│ ├── todo/ # …and many more module routes
│ └── …
└── api/ # SvelteKit API endpoints (rare; most data is local-first)
The (app) group is wrapped by AuthGate, which redirects unauthenticated users to /login and reads the access tier from the JWT to gate beta/alpha/founder-only modules.
Legacy Supabase: removed. Anything mentioning @supabase/ssr, safeGetSession(), or event.locals.supabase is leftover from a much earlier iteration and should be deleted on sight.
Path Aliases (apps/web/svelte.config.js)
$lib → src/lib · $components → src/lib/components · $stores → src/lib/stores · $utils → src/lib/utils · $types → src/lib/types · $server → src/lib/server
Auth Access Pattern
Auth state lives in $lib/stores/auth.svelte.ts. The current user id is also pushed into $lib/data/current-user.ts so the Dexie creating-hook can auto-stamp userId on every record. Module stores never need to know who the current user is — they just write, and the hook stamps the right userId.
Development Commands
For full local-dev (Mana Auth + mana-sync + web together), use the root-level pnpm run mana:dev or pnpm dev:*:full commands. See root CLAUDE.md and docs/LOCAL_DEVELOPMENT.md.
Web-app-only:
cd apps/mana/apps/web
pnpm dev # Dev server on :5173
pnpm build # Production build
pnpm preview # Preview production build
pnpm check # svelte-check type check
pnpm lint # Format check + ESLint
pnpm format # Prettier write
pnpm test # Vitest (unit + integration with fake-indexeddb)
pnpm test:e2e # Playwright
Tech Stack
- Web: SvelteKit 2 + Svelte 5 (runes mode), TailwindCSS, Vite
- Auth: Mana Auth (Better Auth + EdDSA JWT) via
@mana/shared-auth - Data: Dexie.js (local-first) + mana-sync (Go) backend
- Encryption: AES-GCM-256 via Web Crypto, server-wrapped MK with optional zero-knowledge
- Local AI:
@mana/local-llm(Gemma 4 E2B, WebGPU) +@mana/local-stt(Whisper, WebGPU) — both run entirely in-browser via transformers.js - Testing: Vitest, Playwright
- Mobile: removed (see note at top) — Expo stack lives only in
apps/memoro/apps/mobile/now
Svelte 5 runes are mandatory — no legacy let count = 0; $: doubled = count * 2. Always $state, $derived, $effect. See .claude/guidelines/sveltekit-web.md.
Scene Scope
Each workbench scene can carry scopeTagIds — a per-scene tag filter that module queries honour via filterBySceneScopeBatch from $lib/stores/scene-scope.svelte. When the filter hides everything, users need to see why.
When a module wires the scope filter, wire the empty state too:
<script lang="ts">
import ScopeEmptyState from '$lib/components/workbench/ScopeEmptyState.svelte';
import { hasActiveSceneScope } from '$lib/stores/scene-scope.svelte';
</script>
{#if items.length === 0}
{#if hasActiveSceneScope()}
<ScopeEmptyState label="Aufgaben" />
{:else}
<p class="empty">Noch keine Aufgaben</p>
{/if}
{/if}
ScopeEmptyState renders a subdued "Bereichsfilter verbergen alles" message plus a one-click "Bereich zurücksetzen" button that calls workbenchScenesStore.setSceneScopeTags(activeSceneId, undefined). SceneAppBar already shows a Funnel badge on scoped scene pills; the module doesn't need to duplicate that signal. Plan: docs/plans/scene-scope-empty-state.md.
Per-Space Seeds
When a module needs to pre-populate something the first time a Space is activated (e.g. a default workbench layout), register a seeder rather than rolling your own boot-time check. The active-space layer fires every registered seeder on each setActiveSpace and isolates errors per-seeder.
// apps/web/src/lib/data/seeds/my-module.ts
import { db } from '../database';
import { registerSpaceSeed } from '../scope/per-space-seeds';
registerSpaceSeed('my-module-default', async (spaceId) => {
const id = `seed-default-${spaceId}`; // deterministic id
if (await db.table('myTable').get(id)) return; // idempotent
await db.table('myTable').add({ id, spaceId, /* default fields */ });
});
Then add a side-effect import to data/seeds/index.ts so the seeder lands in the registry before the first loadActiveSpace call:
import './my-module';
Two non-negotiables that make this reliable:
- Deterministic id (
seed-default-${spaceId},seed-home-${spaceId}, …) — Dexie's PK uniqueness + aget-then-addguard make duplicates structurally impossible regardless of boot timing. spaceIdset explicitly on the seed row when seeding into a Space that isn't the currently-active one. The creating-hook auto-stampsgetEffectiveSpaceId()for missing-spaceId writes, but seeders are typically called with a target spaceId argument and shouldn't lean on that.
Reference implementation: data/seeds/workbench-home.ts. Background + design rationale: docs/plans/workbench-seeding-cleanup.md.
AI Workbench
The companion is a second actor that works alongside the human in every module. Full pipeline live end-to-end:
- Actor attribution — every event, record, and sync row carries
{ kind, principalId, displayName }(+ mission/iteration/rationale for AI).principalIdis the userId / agentId /system:<source>sentinel;displayNameis cached at write time so rename doesn't rewrite history. Factories in@mana/shared-ai/src/actor.ts; runtime ambient context insrc/lib/data/events/actor.ts. - Agents — named AI personas that own Missions.
/ai-agentsmodule for CRUD (policy editor, memory, budget, concurrency). Default "Mana" agent auto-bootstrapped on first login; legacy missions backfilled.data/ai/agents/{store,queries,bootstrap}.ts. - AI policy — per-tool
auto | propose | deny. Lives on the agent (agent.policy). Proposable tool names come from@mana/shared-ai'sAI_PROPOSABLE_TOOL_NAMES; the mana-ai service runs a boot-time drift guard against the same list. Resolution insrc/lib/data/ai/policy.ts; executor loadsagent.policyfor every AI write. - Proposal inbox — drop
<AiProposalInbox module="…" />into any module page to render pending proposals inline with approve / freitext-reject buttons. Cards show the owning agent's name + avatar chip. Wired in/todo,/calendar,/places,/drink,/food,/news,/notes. The mission-detail view also embeds a cross-module inbox (<AiProposalInbox missionId={id} />): shows all pending proposals for that mission across all modules with a module-badge per card, so the user can review and approve without navigating to individual module pages. - Reasoning loop — the foreground Runner chains up to 5 planner calls per iteration. Read-only tools (
list_notes,get_task_stats, etc.) execute inline as auto-policy, their outputs are fed back as syntheticResolvedInputs for the next planner call. The loop exits when a propose-policy tool is staged (human must approve), the planner returns 0 steps, or the budget exhausts. This enables "read → reason → act" missions like "list all notes and tag them" in a single run. Code:data/ai/missions/runner.tsreasoning loop. - Missions — long-lived autonomous work items at
/ai-missionswith concept + objective + linked inputs + cadence + owning agent (AgentPicker in the create flow). Both the foreground tick AND the server-sidemana-aiservice produce plans under the agent's identity;data/ai/missions/server-iteration-staging.tstranslates server-source iterations into local Proposals on sync. - Input picker —
<MissionInputPicker>sources candidates from theinput-indexregistry (notes / kontext / goals / tasks / calendar). The Runner resolves via the parallelinput-resolversregistry. Encrypted tables (notes, tasks, …) decrypt client-side only. - Auto-injected context — the Runner automatically appends the user's
kontextDocsingleton (decrypted client-side) to every planner call as a standing-context input, unless already linked manually. For missions whose objective matches research keywords (recherchier|research|news|…), a web-research pre-step runs thenews-researchRSS pipeline (discoverByQuery+searchFeeds) and injects results with explicitsave_news_articleinstructions. - Debug log — per-iteration capture of system/user prompts, raw LLM responses, resolved inputs, and auto-tool outputs. Stored in local-only Dexie table
_aiDebugLog(never synced — contains decrypted user content). Toggled vialocalStorage('mana.ai.debug')(on by default in DEV). Rendered as expandable<AiDebugBlock>under each iteration card with copy-as-JSON button. Code:data/ai/missions/debug.ts,components/ai/AiDebugBlock.svelte. - Scene lens — workbench scenes can bind to an agent via
scene.viewingAsAgentId(context menu → "An Agent binden…"). Pure UI lens, not a data-scope change.SceneAppBarshows the agent avatar on bound scene tabs. - Workbench timeline —
/ai-workbenchrenders every AI-attributed event grouped by mission iteration with per-agent filter, per-module, per-mission. Each bucket header shows agent avatar + name + mission title. Per-bucket Revert button undoes the iteration's writes viadata/ai/revert/(TaskCreated → delete, TaskCompleted → uncomplete, etc., newest-first). Separate "Datenzugriff" tab exposes the server-side decrypt audit (for missions with Key-Grants).
Tool Coverage (75 tools, 22 modules)
Agents interact with the app through tools — each one either auto (executes silently during reasoning) or propose (creates a Proposal card the user must approve). Source of truth: AI_TOOL_CATALOG in @mana/shared-ai/src/tools/schemas.ts — both webapp policy (src/lib/data/ai/policy.ts) and server-side planner (services/mana-ai/src/planner/tools.ts) derive from it automatically, so drift is structurally impossible.
| Module | Propose | Auto |
|---|---|---|
| todo | create_task, complete_task, complete_tasks_by_title |
get_task_stats, list_tasks |
| calendar | create_event |
get_todays_events |
| notes | create_note, update_note, append_to_note, add_tag_to_note |
list_notes |
| places | create_place, visit_place |
get_places, get_current_location |
| drink | undo_drink |
get_drink_progress, log_drink |
| food | — | nutrition_summary, log_meal |
| news | save_news_article |
— |
| news-research | research_news |
— |
| journal | create_journal_entry |
— |
| habits | create_habit, log_habit |
get_habits |
| contacts | create_contact |
get_contacts |
| quiz | create_quiz, update_quiz, add_quiz_question, update_quiz_question, delete_quiz_question |
list_quizzes, get_quiz_questions, get_quiz_stats |
| goals | create_goal, pause_goal, resume_goal, complete_goal |
list_goals, get_goal_progress |
| mood | log_mood |
get_mood_today, get_mood_insights |
| myday | — | get_myday_summary |
| events | suggest_event |
discover_events |
| finance | add_transaction |
get_month_summary, list_transactions |
| times | start_timer, stop_timer |
get_time_stats, get_timer_status, list_projects |
| wetter | — | get_weather, get_rain_forecast |
| invoices | create_invoice, mark_invoice_paid |
list_invoices, get_invoice_stats |
| library | create_library_entry, update_library_entry_status, rate_library_entry |
list_library_entries |
| writing | create_draft, generate_draft_content, refine_draft_selection, set_draft_status, save_draft_as_article |
list_drafts, get_draft, list_writing_styles |
| comic | create_comic_story, generate_comic_panel, create_comic_character, generate_character_variant, pin_character_variant |
list_comic_stories, list_comic_characters |
Server-side web-research: mana-ai calls mana-api's /api/v1/news-research/discover + /search directly before the planner prompt is built (pre-planning injection). Missions with research-keyword objectives get real article URLs + excerpts injected as a synthetic ResolvedInput. See services/mana-ai/src/planner/news-research-client.ts.
Templates
Pre-configured starter-kits at /agents/templates — two sections:
- Agent-Templates (with AI): Recherche-Agent, Kontext-Agent, Today-Agent
- Workbench-Templates (no AI): Calmness, Fitness, Deep Work
Each template bundles: optional agent + optional scene layout + optional starter missions (paused) + optional per-module seeds. Template shape: WorkbenchTemplate in @mana/shared-ai/src/agents/templates/types.ts. Applicator: src/lib/data/ai/agents/apply-template.ts. Seed-handler registry: src/lib/data/ai/agents/seed-registry.ts — modules register via side-effect imports in missions/setup.ts. Current handlers: meditate, habits, goals. Plan: docs/plans/workbench-templates.md.
Full architecture (Planner prompt + parser in @mana/shared-ai, server-side runner, Postgres actor column, materialized snapshots, Multi-Agent gating, server-side web-research, Prometheus metrics + status.mana.how integration): docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md §20 (AI Workbench) + §21 (Mission Grants) + §22 (Multi-Agent Workbench).
Reference Documents
| Path | Purpose |
|---|---|
apps/web/src/lib/data/DATA_LAYER_AUDIT.md |
Data-layer + sync deep dive, encryption rollout, threat model, Actor attribution, backlog |
docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md |
Companion brain + AI Workbench (Actor, Policy, Proposals, Missions roadmap) |
apps/docs/src/content/docs/architecture/security.mdx |
User-facing security walkthrough |
apps/docs/src/content/docs/architecture/authentication.mdx |
Auth flow + JWT structure |
Root CLAUDE.md |
Monorepo overview, services, dev commands, env vars |