feat(brain): add domain events + tools for habits, journal, notes, contacts, body

Extends the Companion Brain to 10 modules (from 5). Adds semantic
domain events and LLM tools for the next 5 most valuable modules.

New domain events (15 types):
- Habits: HabitLogged, HabitCreated, HabitDeleted
- Journal: JournalEntryCreated, JournalMoodSet, JournalEntryDeleted
- Notes: NoteCreated, NoteDeleted
- Contacts: ContactCreated, ContactDeleted
- Body: WorkoutStarted, WorkoutFinished, SetLogged,
  MeasurementLogged, EnergyCheckLogged

New tools (12 tools):
- Habits: log_habit, get_habits, create_habit
- Journal: create_journal_entry, set_mood
- Notes: create_note
- Contacts: create_contact, get_contacts
- Body: start_workout, finish_workout, log_measurement

Totals: 35 event types, 25 tools across 10 modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-13 22:48:15 +02:00
parent 4211ce68da
commit c51382a76e
12 changed files with 480 additions and 0 deletions

View file

@ -226,6 +226,118 @@ export type PlacesEventType =
| 'TrackingStarted'
| 'TrackingStopped';
// ── Habits ──────────────────────────────────────────
export interface HabitLoggedPayload {
logId: string;
habitId: string;
habitTitle: string;
note?: string;
}
export interface HabitCreatedPayload {
habitId: string;
title: string;
}
export interface HabitDeletedPayload {
habitId: string;
title: string;
}
export type HabitsEventType = 'HabitLogged' | 'HabitCreated' | 'HabitDeleted';
// ── Journal ─────────────────────────────────────────
export interface JournalEntryCreatedPayload {
entryId: string;
title?: string;
mood?: string;
hasContent: boolean;
}
export interface JournalMoodSetPayload {
entryId: string;
mood: string;
}
export interface JournalEntryDeletedPayload {
entryId: string;
}
export type JournalEventType = 'JournalEntryCreated' | 'JournalMoodSet' | 'JournalEntryDeleted';
// ── Notes ───────────────────────────────────────────
export interface NoteCreatedPayload {
noteId: string;
title?: string;
}
export interface NoteDeletedPayload {
noteId: string;
}
export type NotesEventType = 'NoteCreated' | 'NoteDeleted';
// ── Contacts ────────────────────────────────────────
export interface ContactCreatedPayload {
contactId: string;
firstName: string;
lastName?: string;
}
export interface ContactDeletedPayload {
contactId: string;
name: string;
}
export type ContactsEventType = 'ContactCreated' | 'ContactDeleted';
// ── Body ────────────────────────────────────────────
export interface WorkoutStartedPayload {
workoutId: string;
title?: string;
routineId?: string;
}
export interface WorkoutFinishedPayload {
workoutId: string;
title: string;
durationMinutes: number;
setCount: number;
}
export interface SetLoggedPayload {
setId: string;
workoutId: string;
exerciseId: string;
reps: number;
weight: number;
}
export interface MeasurementLoggedPayload {
measurementId: string;
type: string;
value: number;
unit: string;
}
export interface EnergyCheckLoggedPayload {
checkId: string;
energy?: number;
mood?: number;
}
export type BodyEventType =
| 'WorkoutStarted'
| 'WorkoutFinished'
| 'SetLogged'
| 'MeasurementLogged'
| 'EnergyCheckLogged';
// ── System Events (Goals, Companion) ────────────────
export interface GoalReachedPayload {
@ -253,6 +365,11 @@ export type ManaEventType =
| DrinkEventType
| NutriphiEventType
| PlacesEventType
| HabitsEventType
| JournalEventType
| NotesEventType
| ContactsEventType
| BodyEventType
| SystemEventType;
/**
@ -291,6 +408,26 @@ export type ManaEvent =
| DomainEvent<'LocationLogged', LocationLoggedPayload>
| DomainEvent<'TrackingStarted', TrackingStartedPayload>
| DomainEvent<'TrackingStopped', TrackingStoppedPayload>
// Habits
| DomainEvent<'HabitLogged', HabitLoggedPayload>
| DomainEvent<'HabitCreated', HabitCreatedPayload>
| DomainEvent<'HabitDeleted', HabitDeletedPayload>
// Journal
| DomainEvent<'JournalEntryCreated', JournalEntryCreatedPayload>
| DomainEvent<'JournalMoodSet', JournalMoodSetPayload>
| DomainEvent<'JournalEntryDeleted', JournalEntryDeletedPayload>
// Notes
| DomainEvent<'NoteCreated', NoteCreatedPayload>
| DomainEvent<'NoteDeleted', NoteDeletedPayload>
// Contacts
| DomainEvent<'ContactCreated', ContactCreatedPayload>
| DomainEvent<'ContactDeleted', ContactDeletedPayload>
// Body
| DomainEvent<'WorkoutStarted', WorkoutStartedPayload>
| DomainEvent<'WorkoutFinished', WorkoutFinishedPayload>
| DomainEvent<'SetLogged', SetLoggedPayload>
| DomainEvent<'MeasurementLogged', MeasurementLoggedPayload>
| DomainEvent<'EnergyCheckLogged', EnergyCheckLoggedPayload>
// System
| DomainEvent<'GoalReached', GoalReachedPayload>
| DomainEvent<'GoalProgress', GoalProgressPayload>;

View file

@ -9,6 +9,11 @@ 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';
import { habitsTools } from '$lib/modules/habits/tools';
import { journalTools } from '$lib/modules/journal/tools';
import { notesTools } from '$lib/modules/notes/tools';
import { contactsTools } from '$lib/modules/contacts/tools';
import { bodyTools } from '$lib/modules/body/tools';
let initialized = false;
@ -19,5 +24,10 @@ export function initTools(): void {
registerTools(drinkTools);
registerTools(nutriphiTools);
registerTools(placesTools);
registerTools(habitsTools);
registerTools(journalTools);
registerTools(notesTools);
registerTools(contactsTools);
registerTools(bodyTools);
initialized = true;
}

View file

@ -13,6 +13,7 @@
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import {
bodyExerciseTable,
@ -183,6 +184,11 @@ export const bodyStore = {
const snapshot = toBodyWorkout({ ...newLocal });
await encryptRecord('bodyWorkouts', newLocal);
await bodyWorkoutTable.add(newLocal);
emitDomainEvent('WorkoutStarted', 'body', 'bodyWorkouts', workoutId, {
workoutId,
title,
routineId: input.routineId,
});
return snapshot;
},
@ -204,6 +210,16 @@ export const bodyStore = {
if (workout?.timeBlockId) {
await updateBlock(workout.timeBlockId, { endDate: now });
}
const sets = await bodySetTable.where('workoutId').equals(id).toArray();
const durationMs = workout?.startedAt
? Date.now() - new Date(workout.startedAt as string).getTime()
: 0;
emitDomainEvent('WorkoutFinished', 'body', 'bodyWorkouts', id, {
workoutId: id,
title: (workout?.title as string) ?? 'Workout',
durationMinutes: Math.round(durationMs / 60000),
setCount: sets.filter((s) => !s.deletedAt).length,
});
},
async updateWorkout(
@ -262,6 +278,13 @@ export const bodyStore = {
const snapshot = toBodySet({ ...newLocal });
await encryptRecord('bodySets', newLocal);
await bodySetTable.add(newLocal);
emitDomainEvent('SetLogged', 'body', 'bodySets', newLocal.id, {
setId: newLocal.id,
workoutId: input.workoutId,
exerciseId: input.exerciseId,
reps: input.reps,
weight: input.weight,
});
return snapshot;
},
@ -299,6 +322,12 @@ export const bodyStore = {
const snapshot = toBodyMeasurement({ ...newLocal });
await encryptRecord('bodyMeasurements', newLocal);
await bodyMeasurementTable.add(newLocal);
emitDomainEvent('MeasurementLogged', 'body', 'bodyMeasurements', newLocal.id, {
measurementId: newLocal.id,
type: input.type,
value: input.value,
unit: input.unit,
});
return snapshot;
},
@ -361,6 +390,11 @@ export const bodyStore = {
const snapshot = toBodyCheck({ ...newLocal });
await encryptRecord('bodyChecks', newLocal);
await bodyCheckTable.add(newLocal);
emitDomainEvent('EnergyCheckLogged', 'body', 'bodyChecks', newLocal.id, {
checkId: newLocal.id,
energy: input.energy,
mood: input.mood,
});
return snapshot;
},

View file

@ -0,0 +1,71 @@
import type { ModuleTool } from '$lib/data/tools/types';
export const bodyTools: ModuleTool[] = [
{
name: 'start_workout',
module: 'body',
description: 'Startet ein neues Workout',
parameters: [
{ name: 'title', type: 'string', description: 'Name des Workouts', required: false },
],
async execute(params) {
const { bodyStore } = await import('./stores/body.svelte');
const workout = await bodyStore.startWorkout({
title: (params.title as string) ?? 'Workout',
});
return {
success: true,
data: workout,
message: `Workout "${params.title ?? 'Workout'}" gestartet`,
};
},
},
{
name: 'finish_workout',
module: 'body',
description: 'Beendet das aktuelle Workout',
parameters: [
{ name: 'workoutId', type: 'string', description: 'ID des Workouts', required: true },
],
async execute(params) {
const { bodyStore } = await import('./stores/body.svelte');
await bodyStore.finishWorkout(params.workoutId as string);
return { success: true, message: 'Workout beendet' };
},
},
{
name: 'log_measurement',
module: 'body',
description: 'Loggt eine Koerpermessung (Gewicht, Koerperfett, etc.)',
parameters: [
{
name: 'type',
type: 'string',
description: 'Art der Messung',
required: true,
enum: ['weight', 'bodyFat', 'chest', 'waist', 'hips', 'biceps', 'thighs'],
},
{ name: 'value', type: 'number', description: 'Messwert', required: true },
{
name: 'unit',
type: 'string',
description: 'Einheit',
required: false,
enum: ['kg', 'lbs', 'percent', 'cm', 'in'],
},
],
async execute(params) {
const { bodyStore } = await import('./stores/body.svelte');
const measurement = await bodyStore.logMeasurement({
type: params.type as 'weight',
value: params.value as number,
unit: (params.unit as 'kg') ?? 'kg',
});
return {
success: true,
data: measurement,
message: `${params.type}: ${params.value} ${params.unit ?? 'kg'}`,
};
},
},
];

View file

@ -10,6 +10,7 @@ import { toContact } from '../queries';
import { createArchiveOps } from '@mana/shared-stores';
import { ContactsEvents } from '@mana/shared-utils/analytics';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import type { LocalContact, Contact } from '../types';
import type { UserProfile } from '$lib/api/profile';
@ -47,6 +48,11 @@ export const contactsStore = {
const plaintextSnapshot = toContact(newLocal);
await encryptRecord('contacts', newLocal);
await contactTable.add(newLocal);
emitDomainEvent('ContactCreated', 'contacts', 'contacts', newLocal.id, {
contactId: newLocal.id,
firstName: data.firstName ?? '',
lastName: data.lastName,
});
ContactsEvents.contactCreated();
return plaintextSnapshot;
},
@ -89,10 +95,16 @@ export const contactsStore = {
},
async deleteContact(id: string) {
const local = await contactTable.get(id);
const decrypted = local ? await decryptRecord('contacts', { ...local }) : null;
await contactTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
emitDomainEvent('ContactDeleted', 'contacts', 'contacts', id, {
contactId: id,
name: [decrypted?.firstName, decrypted?.lastName].filter(Boolean).join(' ') || '',
});
ContactsEvents.contactDeleted();
},

View file

@ -0,0 +1,50 @@
import type { ModuleTool } from '$lib/data/tools/types';
import { contactsStore } from './stores/contacts.svelte';
import { contactTable } from './collections';
import { decryptRecords } from '$lib/data/crypto';
import { toContact } from './queries';
import type { LocalContact } from './types';
export const contactsTools: ModuleTool[] = [
{
name: 'create_contact',
module: 'contacts',
description: 'Erstellt einen neuen Kontakt',
parameters: [
{ name: 'firstName', type: 'string', description: 'Vorname', required: true },
{ name: 'lastName', type: 'string', description: 'Nachname', required: false },
{ name: 'email', type: 'string', description: 'E-Mail', required: false },
{ name: 'phone', type: 'string', description: 'Telefon', required: false },
],
async execute(params) {
const contact = await contactsStore.createContact({
firstName: params.firstName as string,
lastName: params.lastName as string | undefined,
email: params.email as string | undefined,
phone: params.phone as string | undefined,
});
return { success: true, data: contact, message: `Kontakt "${params.firstName}" erstellt` };
},
},
{
name: 'get_contacts',
module: 'contacts',
description: 'Gibt alle Kontakte zurueck',
parameters: [],
async execute() {
const all = await contactTable.toArray();
const active = all.filter((c) => !c.deletedAt && !c.isArchived);
const decrypted = await decryptRecords<LocalContact>('contacts', active);
const contacts = decrypted.map(toContact);
return {
success: true,
data: contacts.map((c) => ({
id: c.id,
name: [c.firstName, c.lastName].filter(Boolean).join(' '),
company: c.company,
})),
message: `${contacts.length} Kontakte`,
};
},
},
];

View file

@ -5,6 +5,7 @@
* All reads are handled by liveQuery hooks in queries.ts.
*/
import { emitDomainEvent } from '$lib/data/events';
import { habitTable, habitLogTable } from '../collections';
import { toHabit } from '../queries';
import {
@ -101,6 +102,10 @@ export const habitsStore = {
};
await habitTable.add(newLocal);
emitDomainEvent('HabitCreated', 'habits', 'habits', newLocal.id, {
habitId: newLocal.id,
title: data.title,
});
return toHabit(newLocal);
},
@ -120,10 +125,15 @@ export const habitsStore = {
},
async deleteHabit(id: string) {
const habit = await habitTable.get(id);
await habitTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
emitDomainEvent('HabitDeleted', 'habits', 'habits', id, {
habitId: id,
title: habit?.title ?? '',
});
// Also soft-delete all logs and their timeBlocks
const logs = await habitLogTable.where('habitId').equals(id).toArray();
const now = new Date().toISOString();
@ -233,6 +243,12 @@ export const habitsStore = {
};
await habitLogTable.add(newLog);
emitDomainEvent('HabitLogged', 'habits', 'habitLogs', logId, {
logId,
habitId,
habitTitle: habit?.title ?? '',
note,
});
return newLog;
},

View file

@ -0,0 +1,55 @@
import type { ModuleTool } from '$lib/data/tools/types';
import { habitsStore } from './stores/habits.svelte';
import { habitTable } from './collections';
export const habitsTools: ModuleTool[] = [
{
name: 'log_habit',
module: 'habits',
description: 'Loggt ein Habit (z.B. Sport, Meditation, Lesen)',
parameters: [
{ name: 'habitId', type: 'string', description: 'ID des Habits', required: true },
{ name: 'note', type: 'string', description: 'Optionale Notiz', required: false },
],
async execute(params) {
const log = await habitsStore.logHabit(
params.habitId as string,
params.note as string | undefined
);
return { success: true, data: log, message: 'Habit geloggt' };
},
},
{
name: 'get_habits',
module: 'habits',
description: 'Gibt alle aktiven Habits zurueck',
parameters: [],
async execute() {
const all = await habitTable.toArray();
const active = all.filter((h) => !h.deletedAt && !h.isArchived);
return {
success: true,
data: active.map((h) => ({ id: h.id, title: h.title, icon: h.icon, color: h.color })),
message: `${active.length} Habits`,
};
},
},
{
name: 'create_habit',
module: 'habits',
description: 'Erstellt ein neues Habit',
parameters: [
{ name: 'title', type: 'string', description: 'Name des Habits', required: true },
{ name: 'icon', type: 'string', description: 'Emoji-Icon', required: false },
{ name: 'color', type: 'string', description: 'Hex-Farbe', required: false },
],
async execute(params) {
const habit = await habitsStore.createHabit({
title: params.title as string,
icon: (params.icon as string) ?? 'star',
color: (params.color as string) ?? '#6366f1',
});
return { success: true, data: habit, message: `Habit "${habit.title}" erstellt` };
},
},
];

View file

@ -8,6 +8,7 @@
import { journalEntryTable } from '../collections';
import { toJournalEntry } from '../queries';
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { transcribeAudio } from '$lib/voice/transcribe';
import type { JournalEntry, JournalMood, LocalJournalEntry } from '../types';
@ -48,6 +49,12 @@ export const journalStore = {
const plaintextSnapshot = toJournalEntry(newLocal);
await encryptRecord('journalEntries', newLocal);
await journalEntryTable.add(newLocal);
emitDomainEvent('JournalEntryCreated', 'journal', 'journalEntries', newLocal.id, {
entryId: newLocal.id,
title: data.title ?? undefined,
mood: data.mood ?? undefined,
hasContent: content.length > 0,
});
return plaintextSnapshot;
},
@ -128,6 +135,7 @@ export const journalStore = {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
emitDomainEvent('JournalEntryDeleted', 'journal', 'journalEntries', id, { entryId: id });
},
async togglePin(id: string) {
@ -153,6 +161,8 @@ export const journalStore = {
mood,
updatedAt: new Date().toISOString(),
});
if (mood)
emitDomainEvent('JournalMoodSet', 'journal', 'journalEntries', id, { entryId: id, mood });
},
async archiveEntry(id: string) {

View file

@ -0,0 +1,54 @@
import type { ModuleTool } from '$lib/data/tools/types';
import { journalStore } from './stores/journal.svelte';
import type { JournalMood } from './types';
const MOOD_ENUM = [
'dankbar',
'glücklich',
'zufrieden',
'neutral',
'nachdenklich',
'traurig',
'gestresst',
'wütend',
];
export const journalTools: ModuleTool[] = [
{
name: 'create_journal_entry',
module: 'journal',
description:
'Erstellt einen neuen Journal-Eintrag mit optionaler Stimmung (dankbar, glücklich, zufrieden, neutral, nachdenklich, traurig, gestresst, wütend)',
parameters: [
{ name: 'content', type: 'string', description: 'Inhalt des Eintrags', required: true },
{ name: 'title', type: 'string', description: 'Optionaler Titel', required: false },
{ name: 'mood', type: 'string', description: 'Stimmung', required: false, enum: MOOD_ENUM },
],
async execute(params) {
const entry = await journalStore.createEntry({
content: params.content as string,
title: params.title as string | undefined,
mood: params.mood as JournalMood | undefined,
});
return {
success: true,
data: entry,
message: `Journal-Eintrag erstellt${params.mood ? ` (Stimmung: ${params.mood})` : ''}`,
};
},
},
{
name: 'set_mood',
module: 'journal',
description: 'Erstellt einen Journal-Eintrag mit Stimmung',
parameters: [
{ name: 'mood', type: 'string', description: 'Stimmung', required: true, enum: MOOD_ENUM },
],
async execute(params) {
const entry = await journalStore.createEntry({
mood: params.mood as JournalMood,
});
return { success: true, data: entry, message: `Stimmung: ${params.mood}` };
},
},
];

View file

@ -18,6 +18,7 @@ import { noteTable } from '../collections';
import { toNote } from '../queries';
import type { LocalNote, Note } from '../types';
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { transcribeAudio } from '$lib/voice/transcribe';
export const notesStore = {
@ -36,6 +37,10 @@ export const notesStore = {
const plaintextSnapshot = toNote(newLocal);
await encryptRecord('notes', newLocal);
await noteTable.add(newLocal);
emitDomainEvent('NoteCreated', 'notes', 'notes', newLocal.id, {
noteId: newLocal.id,
title: data.title,
});
return plaintextSnapshot;
},
@ -103,6 +108,7 @@ export const notesStore = {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
emitDomainEvent('NoteDeleted', 'notes', 'notes', id, { noteId: id });
},
async togglePin(id: string) {

View file

@ -0,0 +1,25 @@
import type { ModuleTool } from '$lib/data/tools/types';
import { notesStore } from './stores/notes.svelte';
export const notesTools: ModuleTool[] = [
{
name: 'create_note',
module: 'notes',
description: 'Erstellt eine neue Notiz',
parameters: [
{ name: 'title', type: 'string', description: 'Titel', required: false },
{ name: 'content', type: 'string', description: 'Inhalt', required: true },
],
async execute(params) {
const note = await notesStore.createNote({
title: params.title as string | undefined,
content: params.content as string,
});
return {
success: true,
data: note,
message: `Notiz "${note.title || 'Unbenannt'}" erstellt`,
};
},
},
];