feat(brain): add Goal system, Pulse Rule Engine, and Feedback Loop

Phase 3 of the Companion Brain architecture.

Goal System (companion/goals/):
- LocalGoal model with metric + target definitions
- Event bus subscriber that auto-tracks progress per period
- 6 goal templates (water, tasks, meals, calories, places, coffee)
- CRUD store with pause/resume/complete/abandon lifecycle

Pulse Rule Engine (companion/rules/):
- 5 deterministic rules: water reminder (90min interval), streak
  warning (18:00), morning summary (08:00), overdue tasks (10+15:00),
  meal reminder (12+19:00)
- ReminderSource adapter for existing reminder scheduler
- Interval + schedule triggers with per-rule last-run tracking
- Dismissal tracking via localStorage

Feedback Loop (companion/feedback/):
- NudgeOutcome model (acted/dismissed/snoozed/expired + latency)
- Persisted to _nudgeOutcomes IndexedDB table
- Stats + action rate queries for future rule optimization

Also adds companionGoals, _memory, _nudgeOutcomes tables (v10 schema).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-13 20:40:42 +02:00
parent a3de6b3d81
commit 9066b6c9ae
12 changed files with 748 additions and 0 deletions

View file

@ -0,0 +1,2 @@
export { recordOutcome, getOutcomeStats, getActionRate } from './tracker';
export type { NudgeOutcome } from './types';

View file

@ -0,0 +1,54 @@
/**
* Feedback Tracker Records nudge outcomes to IndexedDB.
*
* Used by the nudge UI to track whether users act on, dismiss,
* or ignore nudges. Over time, patterns emerge that can adjust
* rule timing and priority.
*/
import { db } from '$lib/data/database';
import type { NudgeType } from '../rules/types';
import type { NudgeOutcome } from './types';
const TABLE = '_nudgeOutcomes';
export async function recordOutcome(
nudgeId: string,
nudgeType: NudgeType,
outcome: NudgeOutcome['outcome'],
latencyMs?: number
): Promise<void> {
await db.table(TABLE).add({
nudgeId,
nudgeType,
outcome,
latencyMs,
timestamp: new Date().toISOString(),
});
}
/** Get outcome stats for a nudge type (last 30 days). */
export async function getOutcomeStats(
nudgeType: NudgeType
): Promise<{ acted: number; dismissed: number; snoozed: number; expired: number; total: number }> {
const cutoff = new Date(Date.now() - 30 * 86400000).toISOString();
const rows: NudgeOutcome[] = await db
.table(TABLE)
.where('[nudgeType+outcome]')
.between([nudgeType, ''], [nudgeType, '\uffff'])
.filter((r: NudgeOutcome) => r.timestamp >= cutoff)
.toArray();
const stats = { acted: 0, dismissed: 0, snoozed: 0, expired: 0, total: rows.length };
for (const r of rows) {
if (r.outcome in stats) stats[r.outcome as keyof typeof stats]++;
}
return stats;
}
/** Action rate for a nudge type (0-1). Returns null if insufficient data. */
export async function getActionRate(nudgeType: NudgeType): Promise<number | null> {
const stats = await getOutcomeStats(nudgeType);
if (stats.total < 5) return null;
return stats.acted / stats.total;
}

View file

@ -0,0 +1,18 @@
/**
* Nudge Feedback Loop types.
*
* Tracks how users respond to nudges so the system can learn
* which nudges are helpful and when to send them.
*/
import type { NudgeType } from '../rules/types';
export interface NudgeOutcome {
id?: number; // auto-increment
nudgeId: string;
nudgeType: NudgeType;
outcome: 'acted' | 'dismissed' | 'snoozed' | 'expired';
/** Milliseconds between nudge shown and user reaction */
latencyMs?: number;
timestamp: string;
}

View file

@ -0,0 +1,4 @@
export { goalStore, startGoalTracker, stopGoalTracker } from './store';
export { useActiveGoals, useAllGoals } from './queries';
export { GOAL_TEMPLATES } from './types';
export type { LocalGoal, GoalTemplate, GoalMetric, GoalTarget } from './types';

View file

@ -0,0 +1,23 @@
/**
* Goal Queries Reactive reads for the Goal system.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalGoal } from './types';
const TABLE = 'companionGoals';
export function useActiveGoals() {
return useLiveQueryWithDefault<LocalGoal[]>(async () => {
const all = await db.table<LocalGoal>(TABLE).toArray();
return all.filter((g) => g.status === 'active' && !g.deletedAt);
}, []);
}
export function useAllGoals() {
return useLiveQueryWithDefault<LocalGoal[]>(async () => {
const all = await db.table<LocalGoal>(TABLE).toArray();
return all.filter((g) => !g.deletedAt);
}, []);
}

View file

@ -0,0 +1,177 @@
/**
* Goal Store CRUD + event-driven progress tracking.
*
* Goals are persisted in the companionGoals Dexie table. Progress
* is tracked by subscribing to the domain event bus and incrementing
* currentValue when matching events arrive.
*/
import { db } from '$lib/data/database';
import { eventBus } from '$lib/data/events/event-bus';
import { emitDomainEvent } from '$lib/data/events/emit';
import type { DomainEvent } from '$lib/data/events/types';
import type { LocalGoal, GoalTemplate } from './types';
const TABLE = 'companionGoals';
function periodStart(period: 'day' | 'week' | 'month'): string {
const now = new Date();
if (period === 'day') return now.toISOString().split('T')[0];
if (period === 'week') {
const d = new Date(now);
d.setDate(d.getDate() - d.getDay() + (d.getDay() === 0 ? -6 : 1)); // Monday
return d.toISOString().split('T')[0];
}
// month
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
}
function matchesGoal(goal: LocalGoal, event: DomainEvent): boolean {
if (event.type !== goal.metric.eventType) return false;
if (goal.metric.filterField && goal.metric.filterValue) {
const payload = event.payload as Record<string, unknown>;
if (String(payload[goal.metric.filterField]) !== goal.metric.filterValue) return false;
}
return true;
}
function getIncrement(goal: LocalGoal, event: DomainEvent): number {
if (goal.metric.source === 'event_count') return 1;
if (goal.metric.source === 'event_sum' && goal.metric.sumField) {
const payload = event.payload as Record<string, unknown>;
const val = payload[goal.metric.sumField];
return typeof val === 'number' ? val : 0;
}
return 0;
}
// ── Event subscription ──────────────────────────────
let unsubscribe: (() => void) | null = null;
export function startGoalTracker(): void {
if (unsubscribe) return;
unsubscribe = eventBus.onAny(async (event: DomainEvent) => {
const goals = await db.table<LocalGoal>(TABLE).toArray();
const active = goals.filter((g) => g.status === 'active' && !g.deletedAt);
for (const goal of active) {
if (!matchesGoal(goal, event)) continue;
// Reset if period rolled over
const currentPeriod = periodStart(goal.target.period);
const needsReset = goal.currentPeriodStart !== currentPeriod;
const newValue = (needsReset ? 0 : goal.currentValue) + getIncrement(goal, event);
await db.table(TABLE).update(goal.id, {
currentValue: newValue,
currentPeriodStart: currentPeriod,
updatedAt: new Date().toISOString(),
});
// Check if goal reached
const reached =
goal.target.comparison === 'gte'
? newValue >= goal.target.value
: newValue <= goal.target.value;
if (reached && (needsReset || goal.currentValue < goal.target.value)) {
emitDomainEvent('GoalReached', 'companion', TABLE, goal.id, {
goalId: goal.id,
title: goal.title,
value: newValue,
target: goal.target.value,
period: goal.target.period,
});
}
}
});
}
export function stopGoalTracker(): void {
unsubscribe?.();
unsubscribe = null;
}
// ── CRUD ────────────────────────────────────────────
export const goalStore = {
async createFromTemplate(template: GoalTemplate): Promise<LocalGoal> {
const now = new Date().toISOString();
const goal: LocalGoal = {
id: crypto.randomUUID(),
title: template.title,
description: template.description,
metric: template.metric,
target: template.target,
moduleId: template.moduleId,
status: 'active',
currentValue: 0,
currentPeriodStart: periodStart(template.target.period),
createdAt: now,
updatedAt: now,
};
await db.table(TABLE).add(goal);
return goal;
},
async create(input: {
title: string;
description?: string;
moduleId: string;
metric: LocalGoal['metric'];
target: LocalGoal['target'];
}): Promise<LocalGoal> {
const now = new Date().toISOString();
const goal: LocalGoal = {
id: crypto.randomUUID(),
title: input.title,
description: input.description,
metric: input.metric,
target: input.target,
moduleId: input.moduleId,
status: 'active',
currentValue: 0,
currentPeriodStart: periodStart(input.target.period),
createdAt: now,
updatedAt: now,
};
await db.table(TABLE).add(goal);
return goal;
},
async pause(id: string): Promise<void> {
await db.table(TABLE).update(id, {
status: 'paused',
updatedAt: new Date().toISOString(),
});
},
async resume(id: string): Promise<void> {
await db.table(TABLE).update(id, {
status: 'active',
updatedAt: new Date().toISOString(),
});
},
async complete(id: string): Promise<void> {
await db.table(TABLE).update(id, {
status: 'completed',
updatedAt: new Date().toISOString(),
});
},
async abandon(id: string): Promise<void> {
await db.table(TABLE).update(id, {
status: 'abandoned',
updatedAt: new Date().toISOString(),
});
},
async delete(id: string): Promise<void> {
await db.table(TABLE).update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
};

View file

@ -0,0 +1,121 @@
/**
* Goal System types for the Companion Brain.
*
* Goals connect modules via metrics ("4x Sport/Woche", "8 Glaeser
* Wasser/Tag"). Progress is tracked by subscribing to domain events.
*/
export interface LocalGoal {
id: string;
title: string;
description?: string;
/** How to measure progress */
metric: GoalMetric;
/** What counts as success */
target: GoalTarget;
/** Primary module */
moduleId: string;
status: 'active' | 'paused' | 'completed' | 'abandoned';
/** Current period progress (resets each period) */
currentValue: number;
currentPeriodStart: string; // ISO date
createdAt: string;
updatedAt: string;
deletedAt?: string;
}
export interface GoalMetric {
/** How to aggregate */
source: 'event_count' | 'event_sum';
/** Which domain event to count/sum */
eventType: string;
/** Optional filter: only count events where payload[filterField] === filterValue */
filterField?: string;
filterValue?: string;
/** For event_sum: which payload field to sum */
sumField?: string;
}
export interface GoalTarget {
value: number;
period: 'day' | 'week' | 'month';
/** gte = at least, lte = at most */
comparison: 'gte' | 'lte';
}
// ── Templates ───────────────────────────────────────
export interface GoalTemplate {
id: string;
title: string;
description: string;
moduleId: string;
metric: GoalMetric;
target: GoalTarget;
}
export const GOAL_TEMPLATES: GoalTemplate[] = [
{
id: 'tpl-water-daily',
title: '8 Glaeser Wasser am Tag',
description: 'Taeglich 2000ml Wasser trinken',
moduleId: 'drink',
metric: {
source: 'event_count',
eventType: 'DrinkLogged',
filterField: 'drinkType',
filterValue: 'water',
},
target: { value: 8, period: 'day', comparison: 'gte' },
},
{
id: 'tpl-tasks-daily',
title: '5 Tasks pro Tag',
description: 'Jeden Tag mindestens 5 Tasks erledigen',
moduleId: 'todo',
metric: { source: 'event_count', eventType: 'TaskCompleted' },
target: { value: 5, period: 'day', comparison: 'gte' },
},
{
id: 'tpl-meals-daily',
title: 'Alle Mahlzeiten tracken',
description: 'Mindestens 3 Mahlzeiten pro Tag erfassen',
moduleId: 'nutriphi',
metric: { source: 'event_count', eventType: 'MealLogged' },
target: { value: 3, period: 'day', comparison: 'gte' },
},
{
id: 'tpl-calories-daily',
title: 'Kalorien-Ziel einhalten',
description: 'Maximal 2000 kcal pro Tag',
moduleId: 'nutriphi',
metric: { source: 'event_sum', eventType: 'MealLogged', sumField: 'calories' },
target: { value: 2000, period: 'day', comparison: 'lte' },
},
{
id: 'tpl-places-weekly',
title: 'Neue Orte entdecken',
description: 'Mindestens 3 verschiedene Orte pro Woche besuchen',
moduleId: 'places',
metric: { source: 'event_count', eventType: 'PlaceVisited' },
target: { value: 3, period: 'week', comparison: 'gte' },
},
{
id: 'tpl-coffee-limit',
title: 'Kaffee-Limit',
description: 'Maximal 3 Kaffee pro Tag',
moduleId: 'drink',
metric: {
source: 'event_count',
eventType: 'DrinkLogged',
filterField: 'drinkType',
filterValue: 'coffee',
},
target: { value: 3, period: 'day', comparison: 'lte' },
},
];

View file

@ -0,0 +1,139 @@
/**
* Pulse Rule Engine Evaluates rules against projections and produces nudges.
*
* Integrates as a ReminderSource into the existing reminder scheduler.
* Interval rules run every N minutes; schedule rules run once at specific hours.
*/
import { db } from '$lib/data/database';
import type { DaySnapshot, StreakInfo } from '$lib/data/projections/types';
import type { LocalGoal } from '../goals/types';
import type { PulseRule, RuleContext, Nudge } from './types';
import { DEFAULT_RULES } from './rules';
const DISMISSED_KEY = 'mana:dismissed-nudges';
const LAST_RUN_KEY = 'mana:pulse-last-run';
function getDismissed(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
} catch {
return new Set();
}
}
export function dismissNudge(nudgeId: string): void {
const dismissed = getDismissed();
dismissed.add(nudgeId);
// Keep only last 200 entries
const arr = [...dismissed].slice(-200);
localStorage.setItem(DISMISSED_KEY, JSON.stringify(arr));
}
function getLastRun(): Record<string, number> {
try {
const raw = localStorage.getItem(LAST_RUN_KEY);
return raw ? (JSON.parse(raw) as Record<string, number>) : {};
} catch {
return {};
}
}
function setLastRun(ruleId: string, timestamp: number): void {
const runs = getLastRun();
runs[ruleId] = timestamp;
localStorage.setItem(LAST_RUN_KEY, JSON.stringify(runs));
}
function shouldRun(rule: PulseRule, now: Date): boolean {
const lastRun = getLastRun();
const lastMs = lastRun[rule.id] ?? 0;
const elapsedMs = now.getTime() - lastMs;
if (rule.trigger.kind === 'interval') {
return elapsedMs >= rule.trigger.minutes * 60 * 1000;
}
if (rule.trigger.kind === 'schedule') {
const hour = now.getHours();
if (!rule.trigger.hours.includes(hour)) return false;
// Only run once per hour slot
const lastHour = lastMs > 0 ? new Date(lastMs).getHours() : -1;
const lastDate = lastMs > 0 ? new Date(lastMs).toDateString() : '';
return lastHour !== hour || lastDate !== now.toDateString();
}
return false;
}
/**
* Run all rules and return any nudges that should be shown.
*
* @param day - Current DaySnapshot
* @param streaks - Current streaks
* @param goals - Active goals
* @param rules - Rules to evaluate (defaults to built-in rules)
*/
export function evaluateRules(
day: DaySnapshot,
streaks: StreakInfo[],
goals: LocalGoal[],
rules: PulseRule[] = DEFAULT_RULES
): Nudge[] {
const now = new Date();
const dismissed = getDismissed();
const ctx: RuleContext = {
day,
streaks,
goals,
now,
hour: now.getHours(),
};
const nudges: Nudge[] = [];
for (const rule of rules) {
if (!shouldRun(rule, now)) continue;
const nudge = rule.check(ctx);
setLastRun(rule.id, now.getTime());
if (nudge && !dismissed.has(nudge.id)) {
nudges.push(nudge);
}
}
return nudges;
}
/**
* Create a ReminderSource adapter for the existing reminder scheduler.
*
* This bridges the Pulse Rule Engine into the existing infrastructure
* so nudges appear as OS notifications via the notification service.
*/
export function createPulseReminderSource(
getDay: () => DaySnapshot,
getStreaks: () => StreakInfo[],
getGoals: () => LocalGoal[]
) {
return {
id: 'companion-pulse',
async checkDue() {
const nudges = evaluateRules(getDay(), getStreaks(), getGoals());
return nudges.map((n) => ({
id: n.id,
title: n.title,
body: n.body,
tag: `pulse-${n.type}`,
}));
},
async markSent(id: string) {
// Nudges are one-shot per ID (date+hour encoded)
// No additional tracking needed beyond the lastRun mechanism
},
};
}

View file

@ -0,0 +1,3 @@
export { evaluateRules, createPulseReminderSource, dismissNudge } from './engine';
export { DEFAULT_RULES } from './rules';
export type { PulseRule, Nudge, NudgeType, RuleContext } from './types';

View file

@ -0,0 +1,153 @@
/**
* Built-in Pulse Rules for the 5 pilot modules.
*/
import type { PulseRule } from './types';
export const waterReminderRule: PulseRule = {
id: 'water-reminder',
name: 'Wasser-Erinnerung',
trigger: { kind: 'interval', minutes: 90 },
check(ctx) {
const { water } = ctx.day.drinks;
if (water.percent >= 100) return null;
if (ctx.hour < 8 || ctx.hour > 21) return null;
const remaining = water.goal - water.ml;
const hoursLeft = Math.max(21 - ctx.hour, 1);
const mlPerHour = Math.ceil(remaining / hoursLeft);
return {
id: `water-${ctx.day.date}-${ctx.hour}`,
type: 'water_reminder',
title: 'Wasser trinken',
body: `Noch ${remaining}ml bis zum Ziel (~${mlPerHour}ml/Stunde).`,
priority: water.percent < 50 ? 'medium' : 'low',
actionLabel: 'Glas loggen',
actionRoute: '/drink',
};
},
};
export const streakWarningRule: PulseRule = {
id: 'streak-warning',
name: 'Streak-Warnung',
trigger: { kind: 'schedule', hours: [18] },
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 verlieren.`,
priority: best.currentStreak > 7 ? 'high' : 'medium',
};
},
};
export const morningSummaryRule: PulseRule = {
id: 'morning-summary',
name: 'Morgen-Zusammenfassung',
trigger: { kind: 'schedule', hours: [8] },
check(ctx) {
const { tasks, events } = ctx.day;
const parts: string[] = [];
if (tasks.dueToday.length > 0) {
parts.push(`${tasks.dueToday.length} Tasks faellig`);
}
if (tasks.overdue > 0) {
parts.push(`${tasks.overdue} ueberfaellig`);
}
if (events.total > 0) {
parts.push(`${events.total} Termine`);
}
if (parts.length === 0) {
return {
id: `morning-${ctx.day.date}`,
type: 'morning_summary',
title: 'Guten Morgen!',
body: 'Keine Tasks oder Termine heute — freier Tag.',
priority: 'low',
};
}
return {
id: `morning-${ctx.day.date}`,
type: 'morning_summary',
title: 'Guten Morgen!',
body: `Heute: ${parts.join(', ')}.`,
priority: tasks.overdue > 0 ? 'medium' : 'low',
};
},
};
export const overdueTasksRule: PulseRule = {
id: 'overdue-tasks',
name: 'Ueberfaellige Tasks',
trigger: { kind: 'schedule', hours: [10, 15] },
check(ctx) {
if (ctx.day.tasks.overdue === 0) return null;
return {
id: `overdue-${ctx.day.date}-${ctx.hour}`,
type: 'overdue_tasks',
title: `${ctx.day.tasks.overdue} ueberfaellige Tasks`,
body: 'Erledigen oder verschieben?',
priority: ctx.day.tasks.overdue > 3 ? 'high' : 'medium',
actionLabel: 'Tasks anzeigen',
actionRoute: '/todo',
};
},
};
export const mealReminderRule: PulseRule = {
id: 'meal-reminder',
name: 'Mahlzeit-Erinnerung',
trigger: { kind: 'schedule', hours: [12, 19] },
check(ctx) {
const { meals, calories } = ctx.day.nutrition;
// Lunch check at 12
if (ctx.hour === 12 && meals < 1) {
return {
id: `meal-lunch-${ctx.day.date}`,
type: 'meal_reminder',
title: 'Mittagessen tracken',
body: 'Noch keine Mahlzeit heute erfasst.',
priority: 'low',
actionLabel: 'Mahlzeit loggen',
actionRoute: '/nutriphi',
};
}
// Dinner check at 19
if (ctx.hour === 19 && meals < 2) {
return {
id: `meal-dinner-${ctx.day.date}`,
type: 'meal_reminder',
title: 'Abendessen tracken',
body: `Erst ${meals} Mahlzeit(en) heute (${calories.actual} kcal).`,
priority: 'low',
actionLabel: 'Mahlzeit loggen',
actionRoute: '/nutriphi',
};
}
return null;
},
};
/** All built-in rules */
export const DEFAULT_RULES: PulseRule[] = [
waterReminderRule,
streakWarningRule,
morningSummaryRule,
overdueTasksRule,
mealReminderRule,
];

View file

@ -0,0 +1,50 @@
/**
* Pulse Rule Engine types.
*
* Rules are deterministic (no LLM) and produce Nudges from projections.
*/
import type { DaySnapshot, StreakInfo } from '$lib/data/projections/types';
import type { LocalGoal } from '../goals/types';
export interface PulseRule {
id: string;
name: string;
/** When to check */
trigger: { kind: 'interval'; minutes: number } | { kind: 'schedule'; hours: number[] }; // e.g. [8, 18] = 08:00 and 18:00
/** Returns a Nudge if action needed, null otherwise */
check: (ctx: RuleContext) => Nudge | null;
}
export interface RuleContext {
day: DaySnapshot;
streaks: StreakInfo[];
goals: LocalGoal[];
now: Date;
hour: number; // 0-23
}
export interface Nudge {
id: string;
type: NudgeType;
title: string;
body: string;
priority: 'low' | 'medium' | 'high';
/** Button label */
actionLabel?: string;
/** Route to navigate to */
actionRoute?: string;
/** Tool name for Companion to execute */
actionTool?: string;
/** When this nudge becomes irrelevant */
expiresAt?: string;
}
export type NudgeType =
| 'streak_warning'
| 'goal_progress'
| 'goal_reached'
| 'morning_summary'
| 'overdue_tasks'
| 'water_reminder'
| 'meal_reminder';

View file

@ -426,6 +426,10 @@ db.version(9).stores({
db.version(10).stores({
_events:
'++seq, type, meta.appId, meta.timestamp, meta.recordId, [meta.appId+meta.timestamp], [type+meta.timestamp]',
// Companion Brain: Goals, Memory, Feedback
companionGoals: 'id, moduleId, status, [moduleId+status]',
_memory: 'id, category, confidence, lastConfirmed, [category+confidence]',
_nudgeOutcomes: '++id, nudgeId, nudgeType, outcome, timestamp, [nudgeType+outcome]',
});
// Schema version 11 — adds the Mail module (local draft cache).