diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts
index b5c9f31be..9480315c4 100644
--- a/apps/mana/apps/web/src/lib/data/database.ts
+++ b/apps/mana/apps/web/src/lib/data/database.ts
@@ -398,6 +398,36 @@ db.version(8).stores({
recipes: 'id, difficulty, isFavorite, *tags',
});
+// Schema version 9 — adds the Stretch module (guided stretching routines
+// with mobility assessments, session tracking, and reminders).
+// Additive only; no prior tables touched.
+//
+// Index strategy:
+// - stretchExercises indexes bodyRegion + difficulty for the exercise picker
+// filter strip, isPreset to separate seeds from custom.
+// - stretchRoutines indexes routineType for the type-based filter tabs,
+// order for the user's custom sort.
+// - stretchSessions indexes startedAt for the history timeline view
+// (range scan descending).
+// - stretchAssessments indexes assessedAt for the trend chart.
+// - stretchReminders indexes isActive so the reminder engine can quickly
+// find enabled reminders without scanning the full table.
+db.version(9).stores({
+ stretchExercises: 'id, bodyRegion, difficulty, isPreset, isArchived, order',
+ stretchRoutines: 'id, routineType, order, isPinned, isPreset',
+ stretchSessions: 'id, routineId, startedAt, [startedAt]',
+ stretchAssessments: 'id, assessedAt',
+ stretchReminders: 'id, isActive',
+});
+
+// v10 — Domain Event Store for the Companion Brain.
+// Append-only log of semantic events emitted by module stores.
+// NOT synced (local intelligence only). Replaces _activity long-term.
+db.version(10).stores({
+ _events:
+ '++seq, type, meta.appId, meta.timestamp, meta.recordId, [meta.appId+meta.timestamp], [type+meta.timestamp]',
+});
+
// ─── 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/events/catalog.ts b/apps/mana/apps/web/src/lib/data/events/catalog.ts
new file mode 100644
index 000000000..c719fabfb
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/data/events/catalog.ts
@@ -0,0 +1,296 @@
+/**
+ * Domain Event Catalog — Typed event definitions for all modules.
+ *
+ * Each module section defines payload interfaces and a string-literal
+ * union of event types. The top-level ManaEvent union covers every
+ * possible event so the EventStore and Projection Engine can work
+ * with full type safety.
+ *
+ * Pilot modules: Todo, Calendar, Drink, Nutriphi, Places.
+ */
+
+import type { DomainEvent } from './types';
+
+// ── Todo ────────────────────────────────────────────
+
+export interface TaskCreatedPayload {
+ taskId: string;
+ title: string;
+ dueDate?: string;
+ priority?: number;
+ projectId?: string;
+ labelIds?: string[];
+}
+
+export interface TaskCompletedPayload {
+ taskId: string;
+ title: string;
+ projectId?: string;
+ wasOverdue: boolean;
+}
+
+export interface TaskUncompletedPayload {
+ taskId: string;
+ title: string;
+}
+
+export interface TaskUpdatedPayload {
+ taskId: string;
+ fields: string[];
+}
+
+export interface TaskDeletedPayload {
+ taskId: string;
+ title: string;
+}
+
+export interface TaskRescheduledPayload {
+ taskId: string;
+ title: string;
+ oldDate?: string;
+ newDate: string;
+}
+
+export interface SubtasksUpdatedPayload {
+ taskId: string;
+ total: number;
+ completed: number;
+}
+
+export interface ReminderSetPayload {
+ taskId: string;
+ reminderId: string;
+ minutesBefore: number;
+ type?: string;
+}
+
+export interface ReminderDeletedPayload {
+ taskId: string;
+ reminderId: string;
+}
+
+export type TodoEventType =
+ | 'TaskCreated'
+ | 'TaskCompleted'
+ | 'TaskUncompleted'
+ | 'TaskUpdated'
+ | 'TaskDeleted'
+ | 'TaskRescheduled'
+ | 'SubtasksUpdated'
+ | 'ReminderSet'
+ | 'ReminderDeleted';
+
+// ── Calendar ────────────────────────────────────────
+
+export interface CalendarEventCreatedPayload {
+ eventId: string;
+ title: string;
+ startTime: string;
+ endTime: string;
+ isAllDay: boolean;
+ isRecurring: boolean;
+ calendarId: string;
+ location?: string;
+}
+
+export interface CalendarEventUpdatedPayload {
+ eventId: string;
+ fields: string[];
+}
+
+export interface CalendarEventDeletedPayload {
+ eventId: string;
+ title: string;
+ wasRecurring: boolean;
+}
+
+export interface CalendarEventMovedPayload {
+ eventId: string;
+ title: string;
+ oldStart: string;
+ newStart: string;
+}
+
+export type CalendarEventType =
+ | 'CalendarEventCreated'
+ | 'CalendarEventUpdated'
+ | 'CalendarEventDeleted'
+ | 'CalendarEventMoved';
+
+// ── Drink ───────────────────────────────────────────
+
+export interface DrinkLoggedPayload {
+ entryId: string;
+ drinkType: string;
+ quantityMl: number;
+ name: string;
+ date: string;
+ time: string;
+ fromPreset: boolean;
+}
+
+export interface DrinkEntryDeletedPayload {
+ entryId: string;
+ drinkType: string;
+ quantityMl: number;
+}
+
+export interface DrinkEntryUndonePayload {
+ entryId: string;
+}
+
+export type DrinkEventType = 'DrinkLogged' | 'DrinkEntryDeleted' | 'DrinkEntryUndone';
+
+// ── Nutriphi ────────────────────────────────────────
+
+export interface MealLoggedPayload {
+ mealId: string;
+ mealType: string;
+ inputType: string;
+ description: string;
+ calories?: number;
+ protein?: number;
+ date: string;
+}
+
+export interface MealFromPhotoLoggedPayload {
+ mealId: string;
+ mealType: string;
+ photoMediaId: string;
+ confidence: number;
+ calories?: number;
+}
+
+export interface MealDeletedPayload {
+ mealId: string;
+ mealType: string;
+}
+
+export interface NutritionGoalSetPayload {
+ goalId: string;
+ dailyCalories: number;
+ dailyProtein?: number;
+ dailyCarbs?: number;
+ dailyFat?: number;
+}
+
+export type NutriphiEventType =
+ | 'MealLogged'
+ | 'MealFromPhotoLogged'
+ | 'MealDeleted'
+ | 'NutritionGoalSet';
+
+// ── Places ──────────────────────────────────────────
+
+export interface PlaceCreatedPayload {
+ placeId: string;
+ name: string;
+ category?: string;
+ lat: number;
+ lng: number;
+}
+
+export interface PlaceDeletedPayload {
+ placeId: string;
+ name: string;
+}
+
+export interface PlaceVisitedPayload {
+ placeId: string;
+ name: string;
+ visitCount: number;
+}
+
+export interface LocationLoggedPayload {
+ logId: string;
+ lat: number;
+ lng: number;
+ placeId?: string;
+ accuracy?: number;
+}
+
+export interface TrackingStartedPayload {
+ timestamp: string;
+}
+
+export interface TrackingStoppedPayload {
+ durationMs: number;
+ logCount: number;
+}
+
+export type PlacesEventType =
+ | 'PlaceCreated'
+ | 'PlaceDeleted'
+ | 'PlaceVisited'
+ | 'LocationLogged'
+ | 'TrackingStarted'
+ | 'TrackingStopped';
+
+// ── System Events (Goals, Companion) ────────────────
+
+export interface GoalReachedPayload {
+ goalId: string;
+ title: string;
+ value: number;
+ target: number;
+ period: string;
+}
+
+export interface GoalProgressPayload {
+ goalId: string;
+ title: string;
+ value: number;
+ target: number;
+}
+
+export type SystemEventType = 'GoalReached' | 'GoalProgress';
+
+// ── Union of all event types ────────────────────────
+
+export type ManaEventType =
+ | TodoEventType
+ | CalendarEventType
+ | DrinkEventType
+ | NutriphiEventType
+ | PlacesEventType
+ | SystemEventType;
+
+/**
+ * Discriminated union of all domain events.
+ * Use this for the EventStore subscriber and Projection handlers.
+ */
+export type ManaEvent =
+ // Todo
+ | DomainEvent<'TaskCreated', TaskCreatedPayload>
+ | DomainEvent<'TaskCompleted', TaskCompletedPayload>
+ | DomainEvent<'TaskUncompleted', TaskUncompletedPayload>
+ | DomainEvent<'TaskUpdated', TaskUpdatedPayload>
+ | DomainEvent<'TaskDeleted', TaskDeletedPayload>
+ | DomainEvent<'TaskRescheduled', TaskRescheduledPayload>
+ | DomainEvent<'SubtasksUpdated', SubtasksUpdatedPayload>
+ | DomainEvent<'ReminderSet', ReminderSetPayload>
+ | DomainEvent<'ReminderDeleted', ReminderDeletedPayload>
+ // Calendar
+ | DomainEvent<'CalendarEventCreated', CalendarEventCreatedPayload>
+ | DomainEvent<'CalendarEventUpdated', CalendarEventUpdatedPayload>
+ | DomainEvent<'CalendarEventDeleted', CalendarEventDeletedPayload>
+ | DomainEvent<'CalendarEventMoved', CalendarEventMovedPayload>
+ // Drink
+ | DomainEvent<'DrinkLogged', DrinkLoggedPayload>
+ | DomainEvent<'DrinkEntryDeleted', DrinkEntryDeletedPayload>
+ | DomainEvent<'DrinkEntryUndone', DrinkEntryUndonePayload>
+ // Nutriphi
+ | DomainEvent<'MealLogged', MealLoggedPayload>
+ | DomainEvent<'MealFromPhotoLogged', MealFromPhotoLoggedPayload>
+ | DomainEvent<'MealDeleted', MealDeletedPayload>
+ | DomainEvent<'NutritionGoalSet', NutritionGoalSetPayload>
+ // Places
+ | DomainEvent<'PlaceCreated', PlaceCreatedPayload>
+ | DomainEvent<'PlaceDeleted', PlaceDeletedPayload>
+ | DomainEvent<'PlaceVisited', PlaceVisitedPayload>
+ | DomainEvent<'LocationLogged', LocationLoggedPayload>
+ | DomainEvent<'TrackingStarted', TrackingStartedPayload>
+ | DomainEvent<'TrackingStopped', TrackingStoppedPayload>
+ // System
+ | DomainEvent<'GoalReached', GoalReachedPayload>
+ | DomainEvent<'GoalProgress', GoalProgressPayload>;
diff --git a/apps/mana/apps/web/src/lib/data/events/emit.ts b/apps/mana/apps/web/src/lib/data/events/emit.ts
new file mode 100644
index 000000000..d87516a13
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/data/events/emit.ts
@@ -0,0 +1,44 @@
+/**
+ * Convenience helper for emitting domain events from module stores.
+ *
+ * Builds the EventMeta automatically so stores only need to specify
+ * the event type, routing info, and payload.
+ */
+
+import { eventBus } from './event-bus';
+import { getEffectiveUserId } from '../current-user';
+import type { DomainEvent } from './types';
+
+/**
+ * Emit a domain event on the shared bus.
+ *
+ * @example
+ * ```ts
+ * emitDomainEvent('TaskCompleted', 'todo', 'tasks', id, {
+ * taskId: id, title: task.title, wasOverdue: true,
+ * });
+ * ```
+ */
+export function emitDomainEvent
(
+ type: string,
+ appId: string,
+ collection: string,
+ recordId: string,
+ payload: P,
+ causedBy?: string
+): void {
+ const event: DomainEvent = {
+ type,
+ payload,
+ meta: {
+ id: crypto.randomUUID(),
+ timestamp: new Date().toISOString(),
+ appId,
+ collection,
+ recordId,
+ userId: getEffectiveUserId(),
+ causedBy,
+ },
+ };
+ eventBus.emit(event);
+}
diff --git a/apps/mana/apps/web/src/lib/data/events/event-bus.ts b/apps/mana/apps/web/src/lib/data/events/event-bus.ts
new file mode 100644
index 000000000..5170b9ffb
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/data/events/event-bus.ts
@@ -0,0 +1,69 @@
+/**
+ * EventBus — Synchronous in-process event dispatcher.
+ *
+ * Handlers are invoked via queueMicrotask so they run after the current
+ * Dexie transaction commits but before the next frame — no setTimeout
+ * delay, no transaction interference.
+ *
+ * A re-entrancy guard prevents infinite loops when a handler emits
+ * another event of the same type.
+ */
+
+import type { DomainEvent, EventBus, EventHandler } from './types';
+
+export function createEventBus(): EventBus {
+ const handlers = new Map>();
+ const anyHandlers = new Set();
+ const emitting = new Set();
+
+ return {
+ emit(event: DomainEvent) {
+ queueMicrotask(() => {
+ if (emitting.has(event.type)) {
+ console.warn(`[event-bus] re-entrant emit blocked: ${event.type}`);
+ return;
+ }
+ emitting.add(event.type);
+ try {
+ const typeHandlers = handlers.get(event.type);
+ if (typeHandlers) {
+ for (const h of typeHandlers) {
+ try {
+ h(event);
+ } catch (err) {
+ console.error(`[event-bus] handler error for ${event.type}:`, err);
+ }
+ }
+ }
+ for (const h of anyHandlers) {
+ try {
+ h(event);
+ } catch (err) {
+ console.error(`[event-bus] anyHandler error for ${event.type}:`, err);
+ }
+ }
+ } finally {
+ emitting.delete(event.type);
+ }
+ });
+ },
+
+ on(type, handler) {
+ if (!handlers.has(type)) handlers.set(type, new Set());
+ handlers.get(type)!.add(handler);
+ return () => handlers.get(type)?.delete(handler);
+ },
+
+ onAny(handler) {
+ anyHandlers.add(handler);
+ return () => anyHandlers.delete(handler);
+ },
+
+ off(type, handler) {
+ handlers.get(type)?.delete(handler);
+ },
+ };
+}
+
+/** Singleton bus shared across the entire app. */
+export const eventBus: EventBus = createEventBus();
diff --git a/apps/mana/apps/web/src/lib/data/events/event-store.ts b/apps/mana/apps/web/src/lib/data/events/event-store.ts
new file mode 100644
index 000000000..a6d94f5ed
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/data/events/event-store.ts
@@ -0,0 +1,156 @@
+/**
+ * Event Store — Persists all domain events to the _events Dexie table.
+ *
+ * Subscribes to eventBus.onAny() and writes each event as an append-only
+ * row. Provides query helpers for Projections and the Correlation Engine.
+ *
+ * Retention: 90 days / 50,000 events max (whichever is reached first).
+ */
+
+import { db } from '../database';
+import { eventBus } from './event-bus';
+import type { DomainEvent } from './types';
+
+const EVENTS_TABLE = '_events';
+const MAX_EVENTS = 50_000;
+const TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
+
+let unsubscribe: (() => void) | null = null;
+
+/** Start persisting all domain events. Call once at app init. */
+export function startEventStore(): void {
+ if (unsubscribe) return;
+ unsubscribe = eventBus.onAny((event: DomainEvent) => {
+ db.table(EVENTS_TABLE)
+ .add({
+ type: event.type,
+ payload: event.payload,
+ meta: event.meta,
+ })
+ .catch((err: unknown) => {
+ console.error('[event-store] failed to persist event:', err);
+ });
+ });
+}
+
+/** Stop persisting events (for cleanup / tests). */
+export function stopEventStore(): void {
+ unsubscribe?.();
+ unsubscribe = null;
+}
+
+// ── Queries ─────────────────────────────────────────
+
+export interface EventQuery {
+ appId?: string;
+ type?: string;
+ since?: string; // ISO timestamp
+ until?: string; // ISO timestamp
+ limit?: number; // default 100
+}
+
+interface StoredEvent {
+ seq?: number;
+ type: string;
+ payload: unknown;
+ meta: {
+ id: string;
+ timestamp: string;
+ appId: string;
+ collection: string;
+ recordId: string;
+ userId: string;
+ causedBy?: string;
+ };
+}
+
+/** Query persisted events. Most recent first. */
+export async function queryEvents(query: EventQuery = {}): Promise {
+ const limit = Math.min(query.limit ?? 100, 1000);
+ let collection;
+
+ if (query.appId && query.type) {
+ // Use compound index for appId, filter type in JS
+ collection = db
+ .table(EVENTS_TABLE)
+ .where('[meta.appId+meta.timestamp]')
+ .between([query.appId, query.since ?? ''], [query.appId, query.until ?? '\uffff']);
+ } else if (query.appId) {
+ collection = db
+ .table(EVENTS_TABLE)
+ .where('[meta.appId+meta.timestamp]')
+ .between([query.appId, query.since ?? ''], [query.appId, query.until ?? '\uffff']);
+ } else if (query.type) {
+ collection = db
+ .table(EVENTS_TABLE)
+ .where('[type+meta.timestamp]')
+ .between([query.type, query.since ?? ''], [query.type, query.until ?? '\uffff']);
+ } else {
+ collection = db.table(EVENTS_TABLE).orderBy('seq');
+ }
+
+ let results: StoredEvent[] = await collection.reverse().limit(limit).toArray();
+
+ // Apply additional filters not covered by the index
+ if (query.type && query.appId) {
+ results = results.filter((e) => e.type === query.type);
+ }
+ if (query.since && !query.appId && !query.type) {
+ results = results.filter((e) => e.meta.timestamp >= query.since!);
+ }
+ if (query.until && !query.appId && !query.type) {
+ results = results.filter((e) => e.meta.timestamp <= query.until!);
+ }
+
+ return results.map((row) => ({
+ type: row.type,
+ payload: row.payload,
+ meta: row.meta,
+ }));
+}
+
+/** Count events by type within a date range. */
+export async function countEventsByType(
+ since: string,
+ until: string
+): Promise> {
+ const rows: StoredEvent[] = await db
+ .table(EVENTS_TABLE)
+ .where('meta.timestamp')
+ .between(since, until)
+ .toArray();
+
+ const counts: Record = {};
+ for (const row of rows) {
+ counts[row.type] = (counts[row.type] ?? 0) + 1;
+ }
+ return counts;
+}
+
+// ── Pruning ─────────────────────────────────────────
+
+/** Remove events older than TTL or exceeding max count. */
+export async function pruneEventStore(): Promise {
+ const cutoff = new Date(Date.now() - TTL_MS).toISOString();
+ let deleted = 0;
+
+ // Delete by age
+ const old = await db.table(EVENTS_TABLE).where('meta.timestamp').below(cutoff).primaryKeys();
+ if (old.length > 0) {
+ await db.table(EVENTS_TABLE).bulkDelete(old);
+ deleted += old.length;
+ }
+
+ // Delete overflow (keep newest MAX_EVENTS)
+ const total = await db.table(EVENTS_TABLE).count();
+ if (total > MAX_EVENTS) {
+ const overflow = total - MAX_EVENTS;
+ const oldest = await db.table(EVENTS_TABLE).orderBy('seq').limit(overflow).primaryKeys();
+ if (oldest.length > 0) {
+ await db.table(EVENTS_TABLE).bulkDelete(oldest);
+ deleted += oldest.length;
+ }
+ }
+
+ return deleted;
+}
diff --git a/apps/mana/apps/web/src/lib/data/events/index.ts b/apps/mana/apps/web/src/lib/data/events/index.ts
new file mode 100644
index 000000000..3687db1f0
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/data/events/index.ts
@@ -0,0 +1,4 @@
+export { eventBus, createEventBus } from './event-bus';
+export { emitDomainEvent } from './emit';
+export type { DomainEvent, EventMeta, EventBus, EventHandler } from './types';
+export type * from './catalog';
diff --git a/apps/mana/apps/web/src/lib/data/events/types.ts b/apps/mana/apps/web/src/lib/data/events/types.ts
new file mode 100644
index 000000000..0abb305b3
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/data/events/types.ts
@@ -0,0 +1,48 @@
+/**
+ * Domain Event types for the Mana Companion Brain.
+ *
+ * Every module mutation emits a typed DomainEvent via the EventBus.
+ * Events carry semantic meaning ("TaskCompleted") rather than raw CRUD
+ * operations ("tasks table updated"), enabling Projections, Rules, and
+ * the LLM Context Builder to work without reverse-engineering field diffs.
+ */
+
+// ── Core Event Shape ────────────────────────────────
+
+export interface DomainEvent {
+ readonly type: T;
+ readonly payload: P;
+ readonly meta: EventMeta;
+}
+
+export interface EventMeta {
+ /** Unique event ID */
+ readonly id: string;
+ /** ISO timestamp */
+ readonly timestamp: string;
+ /** Source module (e.g. 'todo', 'drink') */
+ readonly appId: string;
+ /** Source Dexie table */
+ readonly collection: string;
+ /** Affected record ID */
+ readonly recordId: string;
+ /** User who triggered this */
+ readonly userId: string;
+ /** Parent event ID (for trigger chains / cascades) */
+ readonly causedBy?: string;
+}
+
+// ── Bus Interface ───────────────────────────────────
+
+export type EventHandler = (event: E) => void;
+
+export interface EventBus {
+ /** Emit a domain event. Handlers run asynchronously via queueMicrotask. */
+ emit(event: DomainEvent): void;
+ /** Subscribe to a specific event type. Returns unsubscribe function. */
+ on(type: T, handler: EventHandler): () => void;
+ /** Subscribe to all events. Returns unsubscribe function. */
+ onAny(handler: EventHandler): () => void;
+ /** Unsubscribe a handler from a specific event type. */
+ off(type: string, handler: EventHandler): void;
+}
diff --git a/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts b/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts
index a0bb49534..8c7ae3224 100644
--- a/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts
+++ b/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts
@@ -10,6 +10,7 @@
import { db } from '$lib/data/database';
import { encryptRecord } from '$lib/data/crypto';
+import { emitDomainEvent } from '$lib/data/events';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import { timeBlockTable } from '$lib/data/time-blocks/collections';
import {
@@ -85,6 +86,16 @@ export const eventsStore = {
// reads go through queries.ts which decrypts on the way out.
await encryptRecord('events', newLocal);
await db.table('events').add(newLocal);
+ emitDomainEvent('CalendarEventCreated', 'calendar', 'events', eventId, {
+ eventId,
+ title: input.title,
+ startTime: input.startTime,
+ endTime: input.endTime,
+ isAllDay: input.isAllDay ?? false,
+ isRecurring: !!input.recurrenceRule,
+ calendarId: input.calendarId,
+ location: input.location,
+ });
CalendarEvents.eventCreated(!!input.recurrenceRule);
return { success: true, data: { id: eventId, timeBlockId } };
} catch (e) {
@@ -142,6 +153,10 @@ export const eventsStore = {
await encryptRecord('events', localData);
await db.table('events').update(id, localData);
+ emitDomainEvent('CalendarEventUpdated', 'calendar', 'events', id, {
+ eventId: id,
+ fields: Object.keys(input).filter((k) => input[k as keyof typeof input] !== undefined),
+ });
CalendarEvents.eventUpdated();
return { success: true };
} catch (e) {
@@ -342,6 +357,11 @@ export const eventsStore = {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
+ emitDomainEvent('CalendarEventDeleted', 'calendar', 'events', id, {
+ eventId: id,
+ title: event?.title ?? '',
+ wasRecurring: false,
+ });
CalendarEvents.eventDeleted();
return { success: true };
} catch (e) {
diff --git a/apps/mana/apps/web/src/lib/modules/drink/stores/drink.svelte.ts b/apps/mana/apps/web/src/lib/modules/drink/stores/drink.svelte.ts
index 0cab7cfdc..0072b7704 100644
--- a/apps/mana/apps/web/src/lib/modules/drink/stores/drink.svelte.ts
+++ b/apps/mana/apps/web/src/lib/modules/drink/stores/drink.svelte.ts
@@ -5,6 +5,7 @@
*/
import { encryptRecord } from '$lib/data/crypto';
+import { emitDomainEvent } from '$lib/data/events';
import { drinkEntryTable, drinkPresetTable } from '../collections';
import { toDrinkEntry, toDrinkPreset, todayStr, nowTime } from '../queries';
import type { LocalDrinkEntry, LocalDrinkPreset, DrinkType } from '../types';
@@ -34,6 +35,15 @@ export const drinkStore = {
const snapshot = toDrinkEntry({ ...newLocal });
await encryptRecord('drinkEntries', newLocal);
await drinkEntryTable.add(newLocal);
+ emitDomainEvent('DrinkLogged', 'drink', 'drinkEntries', newLocal.id, {
+ entryId: newLocal.id,
+ drinkType: input.drinkType,
+ quantityMl: input.quantityMl,
+ name: input.name,
+ date: newLocal.date,
+ time: newLocal.time,
+ fromPreset: !!input.presetId,
+ });
return snapshot;
},
@@ -64,10 +74,18 @@ export const drinkStore = {
},
async deleteEntry(id: string) {
+ const entry = await drinkEntryTable.get(id);
await drinkEntryTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
+ if (entry) {
+ emitDomainEvent('DrinkEntryDeleted', 'drink', 'drinkEntries', id, {
+ entryId: id,
+ drinkType: entry.drinkType,
+ quantityMl: entry.quantityMl,
+ });
+ }
},
async undoLastEntry() {
@@ -76,10 +94,14 @@ export const drinkStore = {
.filter((e) => !e.deletedAt)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
if (active.length > 0) {
- await drinkEntryTable.update(active[0].id, {
+ const entry = active[0];
+ await drinkEntryTable.update(entry.id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
+ emitDomainEvent('DrinkEntryUndone', 'drink', 'drinkEntries', entry.id, {
+ entryId: entry.id,
+ });
}
},
diff --git a/apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts b/apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts
index 32ce1adda..1d1605cfb 100644
--- a/apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts
+++ b/apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts
@@ -14,6 +14,7 @@
import { db } from '$lib/data/database';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
+import { emitDomainEvent } from '$lib/data/events';
import {
uploadMealPhoto,
analyzeMealPhoto,
@@ -74,6 +75,15 @@ export const mealMutations = {
const encrypted: Record = { ...row };
await encryptRecord('meals', encrypted);
await db.table('meals').add(encrypted);
+ emitDomainEvent('MealLogged', 'nutriphi', 'meals', row.id, {
+ mealId: row.id,
+ mealType: dto.mealType,
+ inputType: 'text',
+ description: dto.description,
+ calories: dto.nutrition?.calories,
+ protein: dto.nutrition?.protein,
+ date: row.date,
+ });
return row;
},
@@ -99,6 +109,13 @@ export const mealMutations = {
const encrypted: Record = { ...row };
await encryptRecord('meals', encrypted);
await db.table('meals').add(encrypted);
+ emitDomainEvent('MealFromPhotoLogged', 'nutriphi', 'meals', row.id, {
+ mealId: row.id,
+ mealType: dto.mealType,
+ photoMediaId: dto.photoMediaId,
+ confidence: dto.confidence,
+ calories: dto.nutrition?.calories,
+ });
return row;
},
@@ -131,8 +148,13 @@ export const mealMutations = {
},
async delete(id: string): Promise {
+ const existing = await db.table('meals').get(id);
const now = new Date().toISOString();
await db.table('meals').update(id, { deletedAt: now, updatedAt: now });
+ emitDomainEvent('MealDeleted', 'nutriphi', 'meals', id, {
+ mealId: id,
+ mealType: existing?.mealType ?? '',
+ });
},
};
diff --git a/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts b/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts
index 677b21a26..7af8af449 100644
--- a/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts
+++ b/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts
@@ -6,6 +6,7 @@
*/
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
+import { emitDomainEvent } from '$lib/data/events';
import { createBlock } from '$lib/data/time-blocks/service';
import { placeTable } from '../collections';
import { toPlace } from '../queries';
@@ -41,6 +42,13 @@ export const placesStore = {
const plaintextSnapshot = toPlace({ ...newLocal });
await encryptRecord('places', newLocal);
await placeTable.add(newLocal);
+ emitDomainEvent('PlaceCreated', 'places', 'places', newLocal.id, {
+ placeId: newLocal.id,
+ name: data.name,
+ category: data.category,
+ lat: data.latitude,
+ lng: data.longitude,
+ });
return plaintextSnapshot;
},
@@ -67,10 +75,16 @@ export const placesStore = {
},
async deletePlace(id: string) {
+ const local = await placeTable.get(id);
+ const decrypted = local ? await decryptRecord('places', { ...local }) : null;
await placeTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
+ emitDomainEvent('PlaceDeleted', 'places', 'places', id, {
+ placeId: id,
+ name: (decrypted?.name as string) ?? '',
+ });
},
async toggleFavorite(id: string) {
@@ -114,5 +128,10 @@ export const placesStore = {
title: placeName,
color: '#a855f7',
});
+ emitDomainEvent('PlaceVisited', 'places', 'places', id, {
+ placeId: id,
+ name: placeName,
+ visitCount: (local.visitCount ?? 0) + 1,
+ });
},
};
diff --git a/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts b/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts
index 6caace052..a9e682831 100644
--- a/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts
+++ b/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts
@@ -6,6 +6,7 @@
*/
import { decryptRecords, encryptRecord } from '$lib/data/crypto';
+import { emitDomainEvent } from '$lib/data/events';
import { createBlock } from '$lib/data/time-blocks/service';
import { locationLogTable, placeTable } from '../collections';
import { getDistanceKm, findNearestPlace, toPlace } from '../queries';
@@ -48,6 +49,9 @@ function startTracking() {
error = null;
isTracking = true;
+ emitDomainEvent('TrackingStarted', 'places', 'locationLogs', '', {
+ timestamp: new Date().toISOString(),
+ });
_watchId = navigator.geolocation.watchPosition(
async (pos) => {
@@ -79,6 +83,10 @@ function stopTracking() {
_watchId = null;
}
isTracking = false;
+ emitDomainEvent('TrackingStopped', 'places', 'locationLogs', '', {
+ durationMs: 0,
+ logCount: 0,
+ });
}
async function getCurrentPosition(): Promise {
@@ -135,6 +143,13 @@ async function logPosition(pos: GeolocationPosition) {
await encryptRecord('locationLogs', log);
await locationLogTable.add(log);
+ emitDomainEvent('LocationLogged', 'places', 'locationLogs', log.id, {
+ logId: log.id,
+ lat,
+ lng,
+ placeId: nearest?.id,
+ accuracy: pos.coords.accuracy,
+ });
// Update visit count on the matched place + create TimeBlock
if (nearest) {
diff --git a/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts b/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts
index 67d53b8e8..c97944859 100644
--- a/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts
+++ b/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts
@@ -10,6 +10,7 @@ import { toTask } from '../queries';
import type { LocalTask, TaskPriority, Subtask } from '../types';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
+import { emitDomainEvent } from '$lib/data/events';
import { transcribeAudio } from '$lib/voice/transcribe';
import { TodoEvents } from '@mana/shared-utils/analytics';
import { tagCollection, type LocalTag } from '@mana/shared-stores';
@@ -153,6 +154,14 @@ export const tasksStore = {
const plaintextSnapshot = toTask({ ...newLocal });
await encryptRecord('tasks', newLocal);
await taskTable.add(newLocal);
+ emitDomainEvent('TaskCreated', 'todo', 'tasks', taskId, {
+ taskId,
+ title: plaintextSnapshot.title,
+ dueDate: data.dueDate,
+ priority: data.priority,
+ projectId: data.projectId,
+ labelIds: data.labelIds,
+ });
TodoEvents.taskCreated(!!data.dueDate);
return plaintextSnapshot;
},
@@ -362,6 +371,7 @@ export const tasksStore = {
async deleteTask(id: string) {
const task = await taskTable.get(id);
+ const decrypted = task ? await decryptRecord('tasks', { ...task }) : null;
if (task?.scheduledBlockId) {
await deleteBlock(task.scheduledBlockId);
}
@@ -370,24 +380,44 @@ export const tasksStore = {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
+ emitDomainEvent('TaskDeleted', 'todo', 'tasks', id, {
+ taskId: id,
+ title: (decrypted?.title as string) ?? '',
+ });
TodoEvents.taskDeleted();
},
async completeTask(id: string) {
+ const task = await taskTable.get(id);
+ const decrypted = task ? await decryptRecord('tasks', { ...task }) : null;
+ const now = new Date().toISOString();
+ const wasOverdue = task?.dueDate != null && (task.dueDate as string) < now.slice(0, 10);
await taskTable.update(id, {
isCompleted: true,
- completedAt: new Date().toISOString(),
- updatedAt: new Date().toISOString(),
+ completedAt: now,
+ updatedAt: now,
+ });
+ emitDomainEvent('TaskCompleted', 'todo', 'tasks', id, {
+ taskId: id,
+ title: (decrypted?.title as string) ?? '',
+ projectId: task?.projectId,
+ wasOverdue,
});
TodoEvents.taskCompleted();
},
async uncompleteTask(id: string) {
+ const task = await taskTable.get(id);
+ const decrypted = task ? await decryptRecord('tasks', { ...task }) : null;
await taskTable.update(id, {
isCompleted: false,
completedAt: null,
updatedAt: new Date().toISOString(),
});
+ emitDomainEvent('TaskUncompleted', 'todo', 'tasks', id, {
+ taskId: id,
+ title: (decrypted?.title as string) ?? '',
+ });
},
async toggleComplete(id: string) {
@@ -408,6 +438,11 @@ export const tasksStore = {
};
await encryptRecord('tasks', diff);
await taskTable.update(id, diff);
+ emitDomainEvent('SubtasksUpdated', 'todo', 'tasks', id, {
+ taskId: id,
+ total: subtasks.length,
+ completed: subtasks.filter((s) => s.isCompleted).length,
+ });
},
async updateLabels(id: string, labelIds: string[]) {
diff --git a/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md b/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md
new file mode 100644
index 000000000..eb1a9ed00
--- /dev/null
+++ b/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md
@@ -0,0 +1,1333 @@
+# Mana Companion Brain — Architecture & Implementation Plan
+
+> Vollstaendiger Umbau-Plan fuer ein zentrales Intelligenz-System ueber alle Module.
+> Start mit 5 Pilot-Modulen: **Todo, Calendar, Drink, Nutriphi, Places**.
+> Stand: April 2026
+
+---
+
+## 1. Vision
+
+Mana hat 40+ Module, die isoliert arbeiten. Der Companion Brain verbindet sie zu einem System, das den Nutzer proaktiv begleitet — erinnert, motiviert, Muster erkennt und Zusammenhaenge zwischen Modulen herstellt. Alles lokal, privacy-first.
+
+**Drei Saeulen:**
+1. **Pulse** — Regelbasierte Nudges & Tageszusammenfassungen (kein LLM)
+2. **Rituale** — Gefuehrte Routinen die Daten in Module schreiben (AI-generiert)
+3. **Companion Chat** — LLM mit Tool-Zugriff auf alle Module
+
+**Fundament:**
+- Domain Event Bus (semantische Events statt CRUD-Logs)
+- Projection Engine (live-reaktive Aggregation ueber alle Module)
+- Goal System (moduluebergreifende Ziele mit Fortschritt)
+- Semantic Memory (extrahiertes Nutzerwissen, persistent)
+- Tool Layer (standardisierter LLM-Zugriff auf Module)
+- Feedback Loop (Nudge-Outcomes fuer Lernfaehigkeit)
+
+---
+
+## 2. Architektur-Uebersicht
+
+```
++---------------------------------------------------+
+| MODULE LAYER |
+| Todo - Calendar - Drink - Nutriphi - Places |
+| Jedes Modul emittiert Domain Events via Stores |
++------------------------+--------------------------+
+ | emit()
+ v
++---------------------------------------------------+
+| EVENT BUS |
+| Typed, synchron, in-process |
+| TaskCompleted - DrinkLogged - EventCreated ... |
++--+--------+--------+--------+--------+-----------+
+ | | | | |
+ v v v v v
++------+ +------+ +------+ +------+ +------+
+|Event | |State | |Proj. | |Rule | |Trig- |
+|Store | |Write | |Engine| |Engine| |gers |
+| | |Dexie | | | | | | |
++------+ +------+ +--+---+ +--+---+ +------+
+ | |
+ +----------+--------+----------+
+ v v v v
++---------------------------------------------------+
+| INTELLIGENCE LAYER |
+| |
+| +------------+ +----------+ +-------+ +--------+ |
+| |Projections | | Memory | | Goals | |Feedback| |
+| |DaySnapshot | | Patterns | | Meter | | Loop | |
+| |Streaks | | Prefs | | Track | | Nudge | |
+| |Correlations| | Context | | Link | | Outcome| |
+| +-----+------+ +----+-----+ +---+---+ +---+----+ |
+| | | | | |
+| +--------------+-----------+---------+ |
+| v |
+| Context Document Generator |
+| (~500 Token Nutzer-Snapshot) |
++------------------------+--------------------------+
+ |
+ v
++---------------------------------------------------+
+| INTERACTION LAYER |
+| |
+| +----------+ +----------+ +---------+ +---------+ |
+| | Pulse | | Rituale | |Companion| |Insights | |
+| | Engine | | (AI-gen) | | Chat | | Cards | |
+| | regelb. | | | |LLM+Tool | | | |
+| +----------+ +----------+ +---------+ +---------+ |
+| |
+| Feedback: Nudge -> Outcome -> Memory Update |
++---------------------------------------------------+
+```
+
+---
+
+## 3. Domain Event System
+
+### 3.1 Warum Domain Events statt CRUD-Logs
+
+Aktuell loggt `_activity` nur `{ op: 'update', collection: 'tasks', recordId }`. Daraus laesst sich nicht ableiten, **was** passiert ist. Wurde der Task erledigt? Umbenannt? Verschoben? Das erzwingt Archaeologie — Felder vergleichen, Semantik raten.
+
+Domain Events tragen Bedeutung: `TaskCompleted { taskId, title, project }` ist sofort verstaendlich fuer Projections, Rules, LLM und Mensch.
+
+### 3.2 Event Bus Interface
+
+**Neues File: `apps/mana/apps/web/src/lib/data/events/event-bus.ts`**
+
+```typescript
+// ── Core Types ──────────────────────────────────────
+
+export interface DomainEvent {
+ type: T;
+ payload: P;
+ meta: EventMeta;
+}
+
+export interface EventMeta {
+ id: string; // crypto.randomUUID()
+ timestamp: string; // ISO
+ appId: string; // source module
+ collection: string; // source table
+ recordId: string; // affected record
+ userId: string; // from getEffectiveUserId()
+ causedBy?: string; // parent event id (for trigger chains)
+}
+
+// ── Bus Interface ───────────────────────────────────
+
+export type EventHandler = (event: E) => void;
+
+export interface EventBus {
+ emit(event: DomainEvent): void;
+ on(type: T, handler: EventHandler): () => void;
+ onAny(handler: EventHandler): () => void;
+ off(type: string, handler: EventHandler): void;
+}
+```
+
+**Implementierung:** Einfacher synchroner Dispatcher mit async Subscribers.
+- `emit()` ist synchron (blockiert Dexie-Hook nicht)
+- Handlers laufen in `queueMicrotask()` — nach dem Hook, aber vor dem naechsten Frame
+- Guard gegen Endlos-Loops: `_emitting` Set verhindert re-entrant emits vom selben Event-Typ
+
+```typescript
+export function createEventBus(): EventBus {
+ const handlers = new Map>();
+ const anyHandlers = new Set();
+
+ return {
+ emit(event: DomainEvent) {
+ queueMicrotask(() => {
+ const typeHandlers = handlers.get(event.type);
+ if (typeHandlers) {
+ for (const h of typeHandlers) h(event);
+ }
+ for (const h of anyHandlers) h(event);
+ });
+ },
+
+ on(type, handler) {
+ if (!handlers.has(type)) handlers.set(type, new Set());
+ handlers.get(type)!.add(handler);
+ return () => handlers.get(type)?.delete(handler);
+ },
+
+ onAny(handler) {
+ anyHandlers.add(handler);
+ return () => anyHandlers.delete(handler);
+ },
+
+ off(type, handler) {
+ handlers.get(type)?.delete(handler);
+ },
+ };
+}
+
+// Singleton
+export const eventBus = createEventBus();
+```
+
+### 3.3 Event Store
+
+Ersetzt die `_activity`-Tabelle als primaere Quelle fuer "was ist passiert".
+
+**Neue Dexie-Tabelle `_events`:**
+```
+_events: '++seq, meta.id, meta.type, meta.appId, meta.timestamp,
+ [meta.appId+meta.timestamp], [meta.type+meta.timestamp]'
+```
+
+Felder:
+- `seq` — Auto-increment (Reihenfolge-Garantie)
+- `type` — Domain Event Type (z.B. 'TaskCompleted')
+- `payload` — Serialisiertes Event-Payload (verschluesselt fuer sensitive Felder)
+- `meta` — EventMeta Objekt
+
+**Retention:** 90 Tage (wie `_activity`), max 50.000 Events. Pruning via bestehender Quota-Recovery.
+
+**Subscriber:** `eventBus.onAny()` schreibt jedes Event in `_events`.
+
+### 3.4 Domain Events pro Modul (5 Pilot-Module)
+
+#### Todo Events
+
+| Event | Payload | Abgeleitet aus |
+|-------|---------|----------------|
+| `TaskCreated` | `{ taskId, title, dueDate?, priority?, projectId?, labelIds? }` | `tasksStore.createTask()` |
+| `TaskCompleted` | `{ taskId, title, projectId?, wasOverdue: boolean }` | `tasksStore.completeTask()` |
+| `TaskUncompleted` | `{ taskId, title }` | `tasksStore.uncompleteTask()` |
+| `TaskUpdated` | `{ taskId, fields: string[] }` | `tasksStore.updateTask()` |
+| `TaskDeleted` | `{ taskId, title }` | `tasksStore.deleteTask()` |
+| `TaskRescheduled` | `{ taskId, title, oldDate?, newDate }` | `updateTask` wenn `dueDate` aendert |
+| `SubtasksUpdated` | `{ taskId, total, completed }` | `tasksStore.updateSubtasks()` |
+| `ReminderSet` | `{ taskId, minutesBefore, type }` | `remindersStore.createReminder()` |
+
+#### Calendar Events
+
+| Event | Payload | Abgeleitet aus |
+|-------|---------|----------------|
+| `CalendarEventCreated` | `{ eventId, title, startTime, endTime, isAllDay, isRecurring, calendarId }` | `eventsStore.createEvent()` |
+| `CalendarEventUpdated` | `{ eventId, fields: string[] }` | `eventsStore.updateEvent()` |
+| `CalendarEventDeleted` | `{ eventId, title, wasRecurring }` | `eventsStore.deleteEvent()` |
+| `CalendarEventMoved` | `{ eventId, title, oldStart, newStart }` | `updateEvent` wenn `startTime` aendert |
+
+#### Drink Events
+
+| Event | Payload | Abgeleitet aus |
+|-------|---------|----------------|
+| `DrinkLogged` | `{ entryId, drinkType, quantityMl, name, date, time, fromPreset: boolean }` | `drinkStore.logDrink()`, `logFromPreset()` |
+| `DrinkEntryDeleted` | `{ entryId, drinkType, quantityMl }` | `drinkStore.deleteEntry()` |
+| `DrinkEntryUndone` | `{ entryId }` | `drinkStore.undoLastEntry()` |
+| `DrinkGoalReached` | `{ date, goalMl, actualMl, drinkType: 'water' }` | Projection erkennt Zielerreichung |
+
+#### Nutriphi Events
+
+| Event | Payload | Abgeleitet aus |
+|-------|---------|----------------|
+| `MealLogged` | `{ mealId, mealType, inputType, description, calories?, protein?, date }` | `mealMutations.create()` |
+| `MealFromPhotoLogged` | `{ mealId, mealType, photoMediaId, confidence, foods? }` | `mealMutations.createFromPhoto()` |
+| `MealDeleted` | `{ mealId, mealType }` | `mealMutations.delete()` |
+| `NutritionGoalSet` | `{ dailyCalories, dailyProtein?, dailyCarbs?, dailyFat? }` | `goalMutations.create/update()` |
+| `DailyCalorieGoalReached` | `{ date, goal, actual }` | Projection erkennt Zielerreichung |
+
+#### Places Events
+
+| Event | Payload | Abgeleitet aus |
+|-------|---------|----------------|
+| `PlaceCreated` | `{ placeId, name, category?, lat, lng }` | `placesStore.createPlace()` |
+| `PlaceVisited` | `{ placeId, name, visitCount }` | `placesStore.recordVisit()` |
+| `LocationLogged` | `{ logId, lat, lng, placeId?, accuracy }` | `trackingStore.logNow()` |
+| `TrackingStarted` | `{}` | `trackingStore.startTracking()` |
+| `TrackingStopped` | `{ durationMs, logCount }` | `trackingStore.stopTracking()` |
+
+### 3.5 Event-Emission aus Module Stores
+
+Jeder Store bekommt `emit()`-Calls in seinen Mutations. Kein Umbau der Dexie-Hooks noetig — Events werden **im Store** emittiert, nicht im Hook.
+
+**Warum im Store statt im Hook?** Der Hook sieht nur CRUD. Der Store kennt die Semantik. `completeTask()` weiss, dass es ein Completion ist — der Hook sieht nur `update({ completedAt })`.
+
+**Beispiel: Todo Store nach Umbau:**
+
+```typescript
+// stores/tasks.svelte.ts
+import { eventBus } from '$lib/data/events/event-bus';
+
+export const tasksStore = {
+ async completeTask(id: string) {
+ const task = await taskTable.get(id);
+ if (!task) return;
+ const now = new Date().toISOString();
+ const wasOverdue = task.dueDate && task.dueDate < now.slice(0, 10);
+
+ await taskTable.update(id, { completedAt: now, updatedAt: now });
+
+ eventBus.emit({
+ type: 'TaskCompleted',
+ payload: {
+ taskId: id,
+ title: task.title, // plaintext snapshot (pre-encryption)
+ projectId: task.projectId,
+ wasOverdue,
+ },
+ meta: {
+ id: crypto.randomUUID(),
+ timestamp: now,
+ appId: 'todo',
+ collection: 'tasks',
+ recordId: id,
+ userId: getEffectiveUserId(),
+ },
+ });
+ },
+ // ... andere Mutations analog
+};
+```
+
+**Konvention:** Jede Store-Mutation die einen Seiteneffekt hat, emittiert ein Event. Reine UI-State-Aenderungen (z.B. `calendarViewStore.setDate()`) emittieren nicht.
+
+### 3.6 Event Helper fuer Module
+
+Um Boilerplate zu reduzieren, ein `createEventEmitter` Helper:
+
+**Neues File: `apps/mana/apps/web/src/lib/data/events/emit.ts`**
+
+```typescript
+import { eventBus } from './event-bus';
+import { getEffectiveUserId } from '../current-user';
+
+export function emitDomainEvent(
+ type: string,
+ appId: string,
+ collection: string,
+ recordId: string,
+ payload: P,
+ causedBy?: string
+): void {
+ eventBus.emit({
+ type,
+ payload,
+ meta: {
+ id: crypto.randomUUID(),
+ timestamp: new Date().toISOString(),
+ appId,
+ collection,
+ recordId,
+ userId: getEffectiveUserId(),
+ causedBy,
+ },
+ });
+}
+```
+
+Aufruf im Store wird dann einzeilig:
+
+```typescript
+emitDomainEvent('TaskCompleted', 'todo', 'tasks', id, {
+ taskId: id, title: task.title, projectId: task.projectId, wasOverdue,
+});
+```
+
+---
+
+## 4. Projection Engine
+
+### 4.1 Prinzip
+
+Projections sind **live-reaktive Aggregationen** ueber Modul-Daten. Sie hoeren Domain Events und aktualisieren sich inkrementell. Consumers (Pulse, Companion, Dashboard) lesen Projections — nie Rohdaten.
+
+**Neuer Ordner: `apps/mana/apps/web/src/lib/data/projections/`**
+
+### 4.2 DaySnapshot
+
+Beantwortet: "Was ist heute los?"
+
+```typescript
+// projections/day-snapshot.ts
+
+export interface DaySnapshot {
+ date: string; // YYYY-MM-DD
+
+ // Todo
+ tasks: {
+ total: number;
+ completed: number;
+ overdue: number;
+ dueToday: TaskSummary[];
+ };
+
+ // Calendar
+ events: {
+ upcoming: EventSummary[]; // naechste 5 Events
+ total: number;
+ nextEvent?: EventSummary;
+ };
+
+ // Drink
+ drinks: {
+ water: { ml: number; goal: number; percent: number };
+ coffee: { ml: number; count: number };
+ other: { ml: number; count: number };
+ total: { ml: number; count: number };
+ };
+
+ // Nutriphi
+ nutrition: {
+ meals: number;
+ calories: { actual: number; goal: number; percent: number };
+ protein?: { actual: number; goal: number };
+ };
+
+ // Places
+ places: {
+ visited: number;
+ currentLocation?: { lat: number; lng: number; placeName?: string };
+ tracking: boolean;
+ };
+}
+```
+
+**Implementierung:** Dexie liveQueries die auf `$derived` gemapped werden. Event-Listener fuer inkrementelle Updates (z.B. `DrinkLogged` addiert direkt statt neu zu querien).
+
+### 4.3 Streaks
+
+Beantwortet: "Was laeuft gut, was droht zu brechen?"
+
+```typescript
+// projections/streaks.ts
+
+export interface StreakInfo {
+ moduleId: string;
+ label: string; // "Wasser-Ziel", "Journal", "Sport"
+ currentStreak: number; // Tage in Folge
+ longestStreak: number;
+ lastActiveDate: string; // YYYY-MM-DD
+ status: 'active' | 'at_risk' | 'broken';
+ // active: heute oder gestern aktiv
+ // at_risk: gestern nicht aktiv, vorgestern schon
+ // broken: >1 Tag Pause
+}
+```
+
+Berechnet aus: TimeBlocks + Modul-spezifische Logik (Drink: Tagesziel erreicht, Todo: mindestens 1 Task erledigt, etc.)
+
+### 4.4 Correlations
+
+Beantwortet: "Was haengt zusammen?"
+
+```typescript
+// projections/correlations.ts
+
+export interface Correlation {
+ id: string;
+ factorA: { module: string; metric: string; label: string };
+ factorB: { module: string; metric: string; label: string };
+ coefficient: number; // Pearson r, -1 bis +1
+ pValue: number; // Statistische Signifikanz
+ sampleSize: number; // Anzahl Tage
+ direction: 'positive' | 'negative';
+ sentence: string; // "An Tagen mit Sport trinkst du 30% mehr Wasser"
+ computedAt: string;
+}
+```
+
+**Berechnung:** 1x taeglich, ueber TimeBlocks + Tagesaggregate der letzten 30-90 Tage. Pearson-Korrelation zwischen Paaren. Nur Korrelationen mit |r| > 0.3 und p < 0.05 werden gespeichert.
+
+**Metriken pro Modul:**
+- Todo: tasks_completed_count, overdue_count
+- Calendar: events_count, meeting_hours
+- Drink: water_ml, coffee_count, goal_reached (boolean)
+- Nutriphi: calories, protein, meals_count
+- Places: places_visited, distance_km
+
+### 4.5 ContactHealth (spaeter, nicht in Pilot)
+
+Wird mit dem Contacts-Modul relevant. Trackt Kontakthaeufigkeit vs. erwartete Frequenz.
+
+---
+
+## 5. Goal System
+
+### 5.1 Datenmodell
+
+**Neue Dexie-Tabelle: `goals`**
+```
+goals: 'id, moduleId, status, [moduleId+status]'
+```
+
+```typescript
+export interface LocalGoal {
+ id: string;
+ title: string; // "4x Sport pro Woche"
+ description?: string;
+
+ // Metrik-Definition
+ metric: GoalMetric;
+ target: GoalTarget;
+
+ // Verknuepfung
+ moduleId: string; // primaeres Modul
+ linkedModules: string[]; // weitere beteiligte Module
+
+ // Status
+ status: 'active' | 'paused' | 'completed' | 'abandoned';
+
+ // Tracking
+ currentValue: number; // live berechnet
+ currentPeriodStart: string; // Beginn der aktuellen Periode
+ history: GoalPeriodResult[]; // vergangene Perioden
+
+ createdAt: string;
+ updatedAt: string;
+ deletedAt?: string;
+}
+
+export interface GoalMetric {
+ source: 'event_count' | 'event_sum' | 'streak_days' | 'custom';
+ eventType?: string; // Domain Event Type (z.B. 'DrinkLogged')
+ filterField?: string; // z.B. 'drinkType'
+ filterValue?: string; // z.B. 'water'
+ sumField?: string; // z.B. 'quantityMl' (fuer event_sum)
+}
+
+export interface GoalTarget {
+ value: number; // Zielwert
+ period: 'day' | 'week' | 'month';
+ comparison: 'gte' | 'lte' | 'eq'; // >= (Sport), <= (Kaffee), = (exakt)
+}
+
+export interface GoalPeriodResult {
+ periodStart: string;
+ periodEnd: string;
+ value: number;
+ reached: boolean;
+}
+```
+
+### 5.2 Goal-Tracking via Events
+
+Der Goal-Tracker subscribed auf den Event Bus und aktualisiert `currentValue` inkrementell:
+
+```typescript
+// Beispiel: Ziel "8 Glaeser Wasser/Tag"
+// metric: { source: 'event_count', eventType: 'DrinkLogged', filterField: 'drinkType', filterValue: 'water' }
+// target: { value: 8, period: 'day', comparison: 'gte' }
+
+eventBus.on('DrinkLogged', (event) => {
+ if (event.payload.drinkType === 'water') {
+ goal.currentValue += 1;
+ if (goal.currentValue >= goal.target.value) {
+ emitDomainEvent('GoalReached', 'companion', 'goals', goal.id, {
+ goalId: goal.id, title: goal.title, value: goal.currentValue,
+ });
+ }
+ }
+});
+```
+
+### 5.3 Vordefinierte Ziel-Templates
+
+Fuer den Start 10-15 Templates die der Nutzer mit einem Tap aktiviert:
+
+- 8 Glaeser Wasser/Tag (Drink, event_count, DrinkLogged, water)
+- 2000 kcal/Tag (Nutriphi, event_sum, MealLogged, calories)
+- 5 Tasks/Tag erledigen (Todo, event_count, TaskCompleted)
+- Alle Mahlzeiten tracken (Nutriphi, event_count, MealLogged, 3/day)
+- Jeden Tag einen neuen Ort besuchen (Places, event_count, PlaceVisited, 1/day)
+
+---
+
+## 6. Semantic Memory
+
+### 6.1 Datenmodell
+
+**Neue Dexie-Tabelle: `_memory`**
+```
+_memory: 'id, category, confidence, lastConfirmed, [category+confidence]'
+```
+
+```typescript
+export interface MemoryFact {
+ id: string;
+ category: 'pattern' | 'preference' | 'relationship' | 'context';
+
+ content: string; // Menschenlesbarer Fakt
+ // "Trainiert typischerweise Mo/Mi/Fr abends"
+ // "Trinkt morgens immer zuerst Kaffee, dann Wasser"
+ // "Meetings haeufig Di/Do vormittags"
+
+ confidence: number; // 0.0 - 1.0
+ confirmations: number; // wie oft bestaetigt
+ contradictions: number; // wie oft widersprochen
+
+ sourceEvents: string[]; // Event-IDs die diesen Fakt stuetzen
+ sourceModules: string[]; // beteiligte Module
+
+ firstSeen: string; // wann erstmals erkannt
+ lastConfirmed: string; // letzte Bestaetigung
+ expiresAt?: string; // fuer temporaere Kontexte
+
+ createdAt: string;
+ updatedAt: string;
+ deletedAt?: string;
+}
+```
+
+### 6.2 Extraktion
+
+**Zwei Wege:**
+
+1. **Regelbasiert (kein LLM):** Pattern-Detektoren ueber Event-Stream:
+ - Wiederholungs-Detektor: "3x in 2 Wochen am Montag trainiert → Pattern: trainiert montags"
+ - Zeitfenster-Detektor: "Tasks werden zu 80% zwischen 09-12 Uhr erledigt → Preference: Morgen-Produktivitaet"
+ - Sequenz-Detektor: "Kaffee wird immer vor dem ersten Event geloggt → Pattern: Kaffee vor Meetings"
+
+2. **LLM-basiert (Tier 1 browser):** Woechentlich zusammengefasste Events an lokales Gemma-Modell:
+ - "Hier sind die Events der letzten Woche. Welche Muster und Praeferenzen erkennst du?"
+ - Ergebnis als JSON parsen → MemoryFact[] schreiben
+
+### 6.3 Confidence-Lifecycle
+
+```
+Neuer Fakt erkannt → confidence: 0.3
+Nochmal bestaetigt → confidence: 0.5
+3+ Bestaetigungen → confidence: 0.7
+10+ Bestaetigungen → confidence: 0.9
+Widerspruch erkannt → confidence -= 0.15
+Laenger als 30 Tage nicht → confidence -= 0.05/Woche
+ bestaetigt
+confidence < 0.1 → Fakt wird geloescht
+```
+
+---
+
+## 7. Context Document Generator
+
+### 7.1 Zweck
+
+Komprimiert den gesamten Nutzerzustand in ein ~500 Token Dokument, das als System-Prompt an das LLM geht. Aktualisiert sich bei jedem Companion-Aufruf.
+
+### 7.2 Template
+
+```typescript
+// projections/context-document.ts
+
+export function generateContextDocument(
+ day: DaySnapshot,
+ streaks: StreakInfo[],
+ goals: LocalGoal[],
+ memory: MemoryFact[],
+ correlations: Correlation[]
+): string {
+ return `## Nutzer-Kontext (${day.date})
+
+### Heute
+- ${day.tasks.dueToday.length} Tasks faellig (${day.tasks.completed} erledigt, ${day.tasks.overdue} ueberfaellig)
+- ${day.events.total} Termine${day.events.nextEvent ? ` — naechster: ${day.events.nextEvent.title} um ${day.events.nextEvent.startTime}` : ''}
+- Wasser: ${day.drinks.water.ml}ml von ${day.drinks.water.goal}ml (${day.drinks.water.percent}%)
+- Ernaehrung: ${day.nutrition.calories.actual} von ${day.nutrition.calories.goal} kcal, ${day.nutrition.meals} Mahlzeiten
+${day.places.tracking ? `- Standort-Tracking aktiv` : ''}
+
+### Ziele
+${goals.filter(g => g.status === 'active').map(g =>
+ `- "${g.title}" — ${g.currentValue}/${g.target.value} (${g.target.period})`
+).join('\n')}
+
+### Streaks
+${streaks.filter(s => s.status !== 'broken').map(s =>
+ `- ${s.label}: ${s.currentStreak} Tage${s.status === 'at_risk' ? ' (GEFAEHRDET)' : ''}`
+).join('\n')}
+${streaks.filter(s => s.status === 'broken').map(s =>
+ `- ${s.label}: UNTERBROCHEN seit ${daysSince(s.lastActiveDate)} Tagen`
+).join('\n')}
+
+### Bekannte Muster
+${memory.filter(m => m.confidence > 0.5).map(m => `- ${m.content}`).join('\n')}
+
+### Korrelationen
+${correlations.slice(0, 3).map(c => `- ${c.sentence}`).join('\n')}
+`;
+}
+```
+
+---
+
+## 8. Tool Layer (LLM Write-Access)
+
+### 8.1 ModuleTool Interface
+
+**Neues File: `apps/mana/apps/web/src/lib/data/tools/types.ts`**
+
+```typescript
+export interface ModuleTool {
+ name: string; // 'create_task', 'log_drink'
+ module: string; // 'todo', 'drink'
+ description: string; // Fuer LLM Function-Schema
+ parameters: ToolParameter[];
+ execute: (params: Record) => Promise;
+}
+
+export interface ToolParameter {
+ name: string;
+ type: 'string' | 'number' | 'boolean';
+ description: string;
+ required: boolean;
+ enum?: string[]; // z.B. ['water', 'coffee', 'tea']
+}
+
+export interface ToolResult {
+ success: boolean;
+ data?: unknown;
+ message?: string; // Menschenlesbare Bestaetigung
+}
+```
+
+### 8.2 Tool-Definitionen (5 Pilot-Module)
+
+**Jedes Modul bekommt eine `tools.ts`:**
+
+```typescript
+// modules/todo/tools.ts
+export const todoTools: ModuleTool[] = [
+ {
+ name: 'create_task',
+ module: 'todo',
+ description: 'Erstellt einen neuen Task',
+ parameters: [
+ { name: 'title', type: 'string', description: 'Titel des Tasks', required: true },
+ { name: 'dueDate', type: 'string', description: 'Faelligkeitsdatum (YYYY-MM-DD)', required: false },
+ { name: 'priority', type: 'number', description: 'Prioritaet 0-3', required: false },
+ ],
+ execute: async (params) => {
+ const task = await tasksStore.createTask({
+ title: params.title as string,
+ dueDate: params.dueDate as string | undefined,
+ priority: params.priority as number | undefined,
+ });
+ return { success: true, data: task, message: `Task "${task.title}" erstellt` };
+ },
+ },
+ {
+ name: 'complete_task',
+ module: 'todo',
+ description: 'Markiert einen Task als erledigt',
+ parameters: [
+ { name: 'taskId', type: 'string', description: 'ID des Tasks', required: true },
+ ],
+ execute: async (params) => {
+ await tasksStore.completeTask(params.taskId as string);
+ return { success: true, message: 'Task erledigt' };
+ },
+ },
+];
+
+// modules/drink/tools.ts
+export const drinkTools: ModuleTool[] = [
+ {
+ name: 'log_drink',
+ module: 'drink',
+ description: 'Loggt ein Getraenk',
+ parameters: [
+ { name: 'drinkType', type: 'string', description: 'Art', required: true, enum: ['water', 'coffee', 'tea', 'juice', 'alcohol', 'other'] },
+ { name: 'quantityMl', type: 'number', description: 'Menge in ml', required: true },
+ { name: 'name', type: 'string', description: 'Name des Getraenks', required: false },
+ ],
+ execute: async (params) => {
+ const entry = await drinkStore.logDrink({
+ name: (params.name as string) ?? (params.drinkType as string),
+ drinkType: params.drinkType as DrinkType,
+ quantityMl: params.quantityMl as number,
+ });
+ return { success: true, data: entry, message: `${params.quantityMl}ml ${params.drinkType} geloggt` };
+ },
+ },
+];
+
+// modules/calendar/tools.ts — create_event
+// modules/nutriphi/tools.ts — log_meal
+// modules/places/tools.ts — record_visit, create_place
+```
+
+### 8.3 Tool Registry
+
+**Neues File: `apps/mana/apps/web/src/lib/data/tools/registry.ts`**
+
+```typescript
+import { todoTools } from '$lib/modules/todo/tools';
+import { calendarTools } from '$lib/modules/calendar/tools';
+import { drinkTools } from '$lib/modules/drink/tools';
+import { nutriphiTools } from '$lib/modules/nutriphi/tools';
+import { placesTools } from '$lib/modules/places/tools';
+
+const ALL_TOOLS: ModuleTool[] = [
+ ...todoTools,
+ ...calendarTools,
+ ...drinkTools,
+ ...nutriphiTools,
+ ...placesTools,
+];
+
+export function getTools(): ModuleTool[] {
+ return ALL_TOOLS;
+}
+
+export function getTool(name: string): ModuleTool | undefined {
+ return ALL_TOOLS.find((t) => t.name === name);
+}
+
+export function getToolsForLlm(): LlmFunctionSchema[] {
+ return ALL_TOOLS.map((t) => ({
+ name: t.name,
+ description: t.description,
+ parameters: {
+ type: 'object',
+ properties: Object.fromEntries(
+ t.parameters.map((p) => [p.name, {
+ type: p.type,
+ description: p.description,
+ ...(p.enum ? { enum: p.enum } : {}),
+ }])
+ ),
+ required: t.parameters.filter((p) => p.required).map((p) => p.name),
+ },
+ }));
+}
+```
+
+### 8.4 Integration mit LLM Orchestrator
+
+Der bestehende `LlmOrchestrator` in `@mana/shared-llm` bekommt eine neue Methode:
+
+```typescript
+// In shared-llm/src/orchestrator.ts
+
+async runWithTools(
+ task: LlmTask,
+ input: { messages: ChatMessage[]; tools: LlmFunctionSchema[] },
+): Promise>
+```
+
+Das LLM gibt `tool_use` Responses zurueck, die der Orchestrator ueber `getTool(name).execute(params)` ausfuehrt. Das Ergebnis wird als `tool_result` Message zurueckgefuettert.
+
+---
+
+## 9. Rule Engine (Pulse)
+
+### 9.1 Prinzip
+
+Die Rule Engine liest Projections und erzeugt Nudges. Kein LLM — rein deterministisch. Laeuft auf zwei Wegen:
+
+1. **Event-getriggert:** Reagiert auf Domain Events (z.B. `TaskCompleted` → Streak-Check)
+2. **Zeitgesteuert:** Periodische Checks (Morgen-Summary, Abend-Reflexion, stuendlich)
+
+### 9.2 Rule Interface
+
+**Neues File: `apps/mana/apps/web/src/lib/companion/rules/types.ts`**
+
+```typescript
+export interface PulseRule {
+ id: string;
+ name: string;
+ description: string;
+
+ // Trigger
+ trigger:
+ | { kind: 'event'; eventType: string }
+ | { kind: 'schedule'; cron: string } // z.B. '0 8 * * *' (08:00 taeglich)
+ | { kind: 'interval'; minutes: number }; // z.B. 60 (stuendlich)
+
+ // Check — gibt null zurueck wenn kein Nudge noetig
+ check: (ctx: RuleContext) => Promise;
+}
+
+export interface RuleContext {
+ day: DaySnapshot;
+ streaks: StreakInfo[];
+ goals: LocalGoal[];
+ memory: MemoryFact[];
+ now: Date;
+}
+
+export interface Nudge {
+ id: string;
+ type: NudgeType;
+ title: string;
+ body: string;
+ priority: 'low' | 'medium' | 'high';
+ actionLabel?: string; // "Jetzt loggen"
+ actionRoute?: string; // '/drink'
+ actionTool?: string; // 'log_drink' — Companion kann direkt ausfuehren
+ expiresAt?: string; // wann der Nudge irrelevant wird
+}
+
+type NudgeType =
+ | 'streak_warning'
+ | 'goal_progress'
+ | 'goal_reached'
+ | 'morning_summary'
+ | 'evening_reflection'
+ | 'overdue_tasks'
+ | 'water_reminder'
+ | 'meal_reminder'
+ | 'correlation_insight';
+```
+
+### 9.3 Vordefinierte Rules (Pilot)
+
+```typescript
+// rules/water-reminder.ts
+export const waterReminderRule: PulseRule = {
+ id: 'water-reminder',
+ name: 'Wasser-Erinnerung',
+ trigger: { kind: 'interval', minutes: 90 },
+ async check(ctx) {
+ const { water } = ctx.day.drinks;
+ if (water.percent >= 100) return null; // Ziel erreicht
+ const hourOfDay = ctx.now.getHours();
+ if (hourOfDay < 8 || hourOfDay > 21) return null; // Nachtruhe
+
+ const remaining = water.goal - water.ml;
+ const hoursLeft = 21 - hourOfDay;
+ const mlPerHour = Math.ceil(remaining / hoursLeft);
+
+ return {
+ id: `water-${ctx.day.date}-${hourOfDay}`,
+ type: 'water_reminder',
+ title: 'Wasser trinken',
+ body: `Noch ${remaining}ml bis zum Ziel. ~${mlPerHour}ml pro Stunde.`,
+ priority: water.percent < 50 ? 'medium' : 'low',
+ actionLabel: 'Glas loggen',
+ actionTool: 'log_drink',
+ };
+ },
+};
+
+// rules/streak-warning.ts
+export const streakWarningRule: PulseRule = {
+ id: 'streak-warning',
+ name: 'Streak-Warnung',
+ trigger: { kind: 'schedule', cron: '0 18 * * *' }, // 18:00 taeglich
+ async check(ctx) {
+ const atRisk = ctx.streaks.filter(s => s.status === 'at_risk');
+ if (atRisk.length === 0) return null;
+
+ const best = atRisk.reduce((a, b) => a.currentStreak > b.currentStreak ? a : b);
+ return {
+ id: `streak-${ctx.day.date}`,
+ type: 'streak_warning',
+ title: `${best.label}-Streak in Gefahr!`,
+ body: `${best.currentStreak} Tage — nicht heute brechen.`,
+ priority: best.currentStreak > 7 ? 'high' : 'medium',
+ };
+ },
+};
+
+// rules/morning-summary.ts
+// rules/evening-reflection.ts
+// rules/overdue-tasks.ts
+// rules/meal-reminder.ts
+// rules/goal-reached.ts
+```
+
+### 9.4 Rule Engine Runner
+
+Integriert sich in den bestehenden Reminder-Scheduler als zusaetzliche Source:
+
+```typescript
+// companion/rules/engine.ts
+
+export function createRuleEngine(rules: PulseRule[]): ReminderSource {
+ return {
+ id: 'companion-pulse',
+ async checkDue(): Promise {
+ const ctx = await buildRuleContext();
+ const nudges: Nudge[] = [];
+
+ for (const rule of rules) {
+ if (shouldTrigger(rule)) {
+ const nudge = await rule.check(ctx);
+ if (nudge && !isDismissed(nudge.id)) {
+ nudges.push(nudge);
+ }
+ }
+ }
+
+ return nudges.map(toReminder);
+ },
+ async markSent(id) { /* track in localStorage */ },
+ };
+}
+```
+
+---
+
+## 10. Feedback Loop
+
+### 10.1 Datenmodell
+
+**Neue Dexie-Tabelle: `_nudgeOutcomes`**
+```
+_nudgeOutcomes: '++id, nudgeId, nudgeType, outcome, timestamp, [nudgeType+outcome]'
+```
+
+```typescript
+export interface NudgeOutcome {
+ id?: number;
+ nudgeId: string;
+ nudgeType: NudgeType;
+ outcome: 'acted' | 'dismissed' | 'snoozed' | 'expired' | 'auto_resolved';
+ latencyMs?: number; // Zeit bis Reaktion
+ timestamp: string;
+}
+```
+
+### 10.2 Lerneffekt
+
+Aggregation ueber `_nudgeOutcomes` beeinflusst die Rule Engine:
+
+```typescript
+// Beispiel: Wasser-Reminder wird um 10:00 immer dismissed
+// → confidence fuer "Nutzer mag morgens keine Wasser-Reminder" steigt
+// → Rule Engine verschiebt auf 11:00
+
+// Beispiel: Streak-Warning um 18:00 fuehrt zu 80% zu Aktion
+// → confidence fuer "18:00 ist guter Zeitpunkt" steigt
+// → bleibt bei 18:00
+```
+
+Memory-Facts werden aus Outcome-Patterns extrahiert und fliessen in den Context Document Generator.
+
+---
+
+## 11. Companion Chat (Interaction Layer)
+
+### 11.1 Modul-Struktur
+
+**Neues Modul: `apps/mana/apps/web/src/lib/modules/companion/`**
+
+```
+companion/
+ module.config.ts — Registriert companion-Tabellen
+ collections.ts — conversations, messages, rituals, goals
+ stores/
+ chat.svelte.ts — Chat-Mutations (send, receive, tool-call)
+ rituals.svelte.ts — Ritual-CRUD + Step-Execution
+ goals.svelte.ts — Goal-CRUD + Progress-Tracking
+ queries.ts — Live-Queries fuer Chat, Rituals, Goals
+ tools.ts — Companion-eigene Tools (read_context, get_insights)
+ components/
+ CompanionChat.svelte — Chat-Interface mit Tool-Execution
+ CompanionFeed.svelte — Timeline von Nudges + Insights + Chat
+ RitualRunner.svelte — Step-by-Step Ritual-UI
+ GoalCard.svelte — Ziel-Fortschritts-Anzeige
+```
+
+### 11.2 Chat-Flow
+
+```
+User: "Wie laeuft mein Tag?"
+ |
+ v
+CompanionChat → LLM Orchestrator
+ |
+ | System Prompt = Context Document (~500 Tokens)
+ | + Tool Schemas (getToolsForLlm())
+ | + User Message
+ |
+ v
+LLM (Gemma lokal ODER Gemini Cloud)
+ |
+ | Response: "Du hast heute 3/7 Tasks erledigt und erst 400ml
+ | Wasser getrunken. Dein Kalender ist ab 15:00 frei — guter
+ | Zeitpunkt fuer die ueberfaelligen Tasks. Soll ich dich
+ | in 2 Stunden ans Wasser erinnern?"
+ |
+ | tool_use: create_reminder(...)
+ |
+ v
+Tool Execution → drinkStore / remindersStore
+ |
+ v
+CompanionChat zeigt Antwort + Aktions-Bestaetigung
+```
+
+### 11.3 Ritual-Generierung
+
+```
+User: "Erstell mir eine Morgenroutine"
+ |
+ v
+LLM + Context Document + Tool Schemas
+ |
+ | LLM sieht: Nutzer hat Drink, Todo, Nutriphi, Calendar aktiv
+ | Memory: "Trinkt morgens zuerst Kaffee"
+ | Goals: "8 Glaeser Wasser/Tag"
+ |
+ v
+Generiertes Ritual:
+ 1. Glas Wasser loggen (tool: log_drink, water, 250ml)
+ 2. Stimmung checken (free_text → journal)
+ 3. Tages-Tasks priorisieren (zeigt DaySnapshot.tasks.dueToday)
+ 4. Kalender-Ueberblick (zeigt DaySnapshot.events.upcoming)
+ 5. Fruehstueck loggen (tool: log_meal)
+```
+
+---
+
+## 12. Neue Dateien & Ordnerstruktur
+
+```
+apps/mana/apps/web/src/lib/
+ data/
+ events/
+ event-bus.ts — EventBus Singleton
+ event-store.ts — Persistenz in _events Tabelle
+ emit.ts — Helper fuer Module
+ types.ts — DomainEvent, EventMeta Interfaces
+ catalog.ts — Alle Event-Type Definitionen (union type)
+ projections/
+ day-snapshot.ts — DaySnapshot Aggregation
+ streaks.ts — Streak-Berechnung
+ correlations.ts — Statistische Korrelationen
+ context-document.ts — LLM-Prompt-Generator
+ tools/
+ types.ts — ModuleTool Interface
+ registry.ts — Tool-Sammlung + LLM-Schema-Generator
+ executor.ts — Tool-Ausfuehrung mit Validierung
+ companion/
+ rules/
+ types.ts — PulseRule, Nudge, RuleContext
+ engine.ts — Rule Runner (als ReminderSource)
+ water-reminder.ts
+ streak-warning.ts
+ morning-summary.ts
+ evening-reflection.ts
+ overdue-tasks.ts
+ meal-reminder.ts
+ goal-check.ts
+ feedback/
+ types.ts — NudgeOutcome
+ tracker.ts — Outcome-Recording
+ analyzer.ts — Pattern-Extraktion aus Outcomes
+ modules/
+ companion/
+ module.config.ts
+ collections.ts
+ stores/
+ chat.svelte.ts
+ rituals.svelte.ts
+ goals.svelte.ts
+ queries.ts
+ tools.ts
+ components/
+ CompanionChat.svelte
+ CompanionFeed.svelte
+ RitualRunner.svelte
+ GoalCard.svelte
+ todo/
+ tools.ts — NEU: Tool-Definitionen
+ stores/tasks.svelte.ts — ANPASSEN: emit() Calls
+ calendar/
+ tools.ts — NEU
+ stores/events.svelte.ts — ANPASSEN
+ drink/
+ tools.ts — NEU
+ stores/drink.svelte.ts — ANPASSEN
+ nutriphi/
+ tools.ts — NEU
+ mutations.ts — ANPASSEN
+ places/
+ tools.ts — NEU
+ stores/places.svelte.ts — ANPASSEN
+ stores/tracking.svelte.ts — ANPASSEN
+```
+
+---
+
+## 13. Neue Dexie-Tabellen
+
+```typescript
+// In database.ts, naechste Version:
+
+// Event Store (ersetzt _activity langfristig)
+_events: '++seq, type, [meta.appId+meta.timestamp], [meta.type+meta.timestamp], meta.recordId',
+
+// Goals
+goals: 'id, moduleId, status, [moduleId+status]',
+goalHistory: '++id, goalId, periodStart',
+
+// Semantic Memory
+_memory: 'id, category, confidence, lastConfirmed, [category+confidence]',
+
+// Feedback Loop
+_nudgeOutcomes: '++id, nudgeId, nudgeType, outcome, timestamp, [nudgeType+outcome]',
+
+// Companion Chat
+companionConversations: 'id, createdAt',
+companionMessages: 'id, conversationId, role, createdAt, [conversationId+createdAt]',
+
+// Rituals
+rituals: 'id, status, createdAt',
+ritualSteps: 'id, ritualId, order, [ritualId+order]',
+ritualLogs: '++id, ritualId, date, [ritualId+date]',
+```
+
+---
+
+## 14. Implementierungs-Reihenfolge
+
+### Phase 1: Event-Fundament (Woche 1-2)
+
+1. `data/events/` — EventBus, EventStore, emit Helper, Type Catalog
+2. Domain Events fuer 5 Pilot-Module definieren (catalog.ts)
+3. Stores der 5 Module umbauen: `emit()` Calls einfuegen
+4. Event Store Subscriber: `eventBus.onAny()` → `_events` Tabelle
+5. Tests: Events werden korrekt emittiert und persistiert
+
+**Ergebnis:** Semantischer Event-Stream fliesst, Dexie-Writes + Events parallel.
+
+### Phase 2: Projections (Woche 2-3)
+
+1. DaySnapshot Projection (live Dexie-Queries + Event-Listener)
+2. Streaks Projection (basierend auf Events + TimeBlocks)
+3. Context Document Generator (Template-basiert)
+4. Dashboard-Widget: "Mein Tag" Karte mit DaySnapshot-Daten
+
+**Ergebnis:** Zentraler Ueberblick ueber alle 5 Module, live-reaktiv.
+
+### Phase 3: Goals + Pulse (Woche 3-4)
+
+1. Goal Datenmodell + Store + Queries
+2. Goal-Tracking via Event-Subscription
+3. Goal-Templates (5 vordefinierte)
+4. Rule Engine mit 5 initialen Rules
+5. Integration in Reminder-Scheduler
+6. Nudge-UI: Toast / Bottom-Sheet
+
+**Ergebnis:** Nutzer setzt Ziele, bekommt proaktive Nudges.
+
+### Phase 4: Tool Layer (Woche 4-5)
+
+1. ModuleTool Interface + Registry
+2. tools.ts fuer 5 Pilot-Module
+3. Tool Executor mit Validierung
+4. LLM Function Schema Generator
+5. Integration in LLM Orchestrator (`runWithTools`)
+
+**Ergebnis:** LLM kann Module lesen und beschreiben.
+
+### Phase 5: Companion Chat (Woche 5-6)
+
+1. Companion Modul (collections, stores, queries)
+2. CompanionChat Svelte-Komponente
+3. Chat-Flow: Context Document + Tools + LLM
+4. CompanionFeed: Timeline von Nudges + Chat
+
+**Ergebnis:** Nutzer kann mit dem System sprechen und Aktionen ausfuehren.
+
+### Phase 6: Rituale (Woche 6-7)
+
+1. Ritual Datenmodell (steps, logs)
+2. RitualRunner Komponente
+3. AI-Ritual-Generierung via Companion Chat
+4. Vordefinierte Ritual-Templates (Morgen, Abend, Wasser)
+
+**Ergebnis:** Gefuehrte Routinen die in Module schreiben.
+
+### Phase 7: Memory + Correlations (Woche 7-8)
+
+1. Semantic Memory Tabelle + Store
+2. Regelbasierte Pattern-Extraktion
+3. Correlation Engine ueber TimeBlocks
+4. Memory + Correlations in Context Document
+5. Feedback Loop (NudgeOutcome Tracking)
+6. LLM-basierte Memory-Extraktion (optional, Tier 1)
+
+**Ergebnis:** System lernt ueber Zeit, Insights werden praeziser.
+
+### Phase 8: Rollout auf weitere Module (Woche 8+)
+
+Pro Modul:
+1. Domain Events definieren (catalog.ts erweitern)
+2. Store Mutations mit emit() versehen
+3. tools.ts schreiben
+4. Projections erweitern (DaySnapshot Felder)
+5. Goal-Templates hinzufuegen
+6. Pulse Rules hinzufuegen
+
+Geschaetzter Aufwand pro Modul: 1-2 Tage.
+
+---
+
+## 15. Abhaengigkeiten & Reihenfolge-Graph
+
+```
+Phase 1 (Events) ──────┬──> Phase 2 (Projections)
+ | |
+ | v
+ ├──> Phase 3 (Goals + Pulse)
+ | |
+ v v
+ Phase 4 (Tools) ──> Phase 5 (Companion Chat)
+ |
+ v
+ Phase 6 (Rituale)
+ |
+ v
+ Phase 7 (Memory)
+ |
+ v
+ Phase 8 (Rollout)
+```
+
+Phase 1 ist Voraussetzung fuer alles. Phase 2-4 koennen teilweise parallel laufen.
+
+---
+
+## 16. Privacy-Garantien
+
+| Daten | Verarbeitung | Speicherung |
+|-------|-------------|-------------|
+| Domain Events | Lokal (Browser) | IndexedDB, encrypted |
+| Projections | Lokal (Browser) | In-Memory, nicht persistiert |
+| Goals | Lokal + Sync | IndexedDB → mana-sync (encrypted) |
+| Memory Facts | Lokal (Browser) | IndexedDB, encrypted |
+| Context Document | Lokal (Browser) | In-Memory, nie persistiert |
+| LLM Inference | Tier 1: Browser (Gemma) | Kein Server-Kontakt |
+| | Tier 2: mana-llm (Ollama) | Context geht an eigenen Server |
+| | Tier 3: Cloud (Gemini) | Nur mit explizitem Consent |
+| Nudge Outcomes | Lokal (Browser) | IndexedDB, nicht synced |
+| Tool Execution | Lokal (Browser) | Writes gehen in Module-Tabellen |
+
+**Invariante:** Sensitive Daten (Journal, Dreams, Finance, Nutriphi) werden **nie** an Tier 2/3 gesendet — erzwungen durch `contentClass: 'sensitive'` im LLM Orchestrator.
+
+---
+
+## 17. Migration: _activity → _events
+
+Die `_activity`-Tabelle bleibt vorerst bestehen (Sync-Debugging). Langfristig:
+
+1. Phase 1-2: `_events` wird parallel zu `_activity` befuellt
+2. Phase 7: Alle Consumers (Activity-Page, Debug-Tools) auf `_events` umstellen
+3. Danach: `_activity`-Befuellung aus Hooks entfernen, Tabelle als deprecated markieren
+4. Naechste Major-Version: Tabelle loeschen
+
+---
+
+## 18. Testing-Strategie
+
+### Unit Tests
+- Event Bus: emit/subscribe/unsubscribe, ordering, no re-entrant loops
+- Projections: DaySnapshot korrekt aus Mock-Daten, Streak-Berechnung
+- Rules: Nudge-Generierung unter verschiedenen Bedingungen
+- Tools: Parameter-Validierung, Execute-Flow
+- Correlations: Pearson-Berechnung, Signifikanz-Filter
+
+### Integration Tests
+- Store emit() → EventBus → EventStore → Projection Update
+- Rule Engine → Nudge → UI → Outcome → Memory Update
+- Companion Chat → LLM Mock → Tool Call → Store Mutation → Event
+
+### E2E Tests
+- Morgenroutine-Ritual durchspielen: 5 Steps → Daten in 3 Modulen
+- Wasser-Ziel erreichen: 8x log_drink → GoalReached Event → Nudge
+- Companion-Frage: "Wie war meine Woche?" → Context Document → Antwort