diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index bf5e65d4a..5a210da3f 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -98,6 +98,7 @@ import type { LocalWritingStyle, } from '../../modules/writing/types'; import type { LocalComicStory } from '../../modules/comic/types'; +import type { LocalAugurEntry } from '../../modules/augur/types'; export const ENCRYPTION_REGISTRY: Record = { // ─── Chat ──────────────────────────────────────────────── @@ -615,6 +616,39 @@ export const ENCRYPTION_REGISTRY: Record = { 'panelMeta', ]), + // ─── Augur (signs: omens / fortunes / hunches) ─────────── + // docs/plans/augur-module.md M1. Single space-scoped table. + // + // User-typed prose is the sensitive surface — `source` (free-text + // label like "schwarze Katze" or "Mutter"), `claim` (what the sign + // said), `feltMeaning` (the user's interpretation), `expectedOutcome` + // (the prediction), `outcomeNote` (the resolve write-up), + // `livingOracleSnapshot` (the deterministic reflection cached at + // capture time), and free-form `tags`. All travel encrypted. + // + // Plaintext (intentional): + // - kind, vibe, outcome, sourceCategory: enum discriminators that + // drive the kind-tabs filter, vibe-galleries, the resolve-reminder + // list (`outcome === 'open'`), and Calibration-per-Source + // aggregation in OracleView. Encrypting any of them would force a + // full table scan + decrypt loop on every render. + // - encounteredAt, expectedBy, resolvedAt: ISO dates the index layer + // uses for sort + the due-for-reveal range scan. + // - probability: nullable 0..1 forecaster number — used by the Brier + // score computation in `lib/calibration.ts`. No prose value. + // - relatedDreamId / relatedDecisionId: foreign keys (standard + // "IDs are plaintext" rule). + // - isPrivate / isArchived: structural flags. + augurEntries: entry([ + 'source', + 'claim', + 'feltMeaning', + 'expectedOutcome', + 'outcomeNote', + 'tags', + 'livingOracleSnapshot', + ]), + // Per-agent kontext documents — same schema as kontextDoc but keyed // per agent. Content is free-form markdown. agentKontextDocs: { enabled: true, fields: ['content'] }, diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 8b92548f6..79c68051f 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -1079,6 +1079,23 @@ db.version(46).stores({ _scopeCursor: null, }); +// v47 — Augur module (docs/plans/augur-module.md M1). +// Single space-scoped table: each row is a sign — an omen, a fortune, +// or a hunch — with a witness-side capture (source/claim/vibe/feltMeaning) +// and an oracle-side resolution (outcome/outcomeNote/resolvedAt). +// +// Index strategy: +// - kind for the witness gallery's KindTabs filter +// - outcome to find unresolved entries fast (Resolve-Reminder + due-for-reveal) +// - vibe for the vibe-color galleries +// - sourceCategory for Calibration-per-Source aggregation in OracleView +// - encounteredAt for chronological sort (default order) +// - expectedBy for the "fällig" reminder list (M3) +// - isArchived for the standard archive-hide filter +db.version(47).stores({ + augurEntries: 'id, kind, outcome, vibe, sourceCategory, encounteredAt, expectedBy, isArchived', +}); + // v48 — One-shot dedup of duplicate "Home" scenes that the seeding race // in `stores/workbench-scenes.svelte.ts` has been accumulating since the // Spaces-Foundation migration shipped 2026-04-22. The seeder writes new diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index cfb88aa02..fa4a444db 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -107,6 +107,7 @@ import { websiteModuleConfig } from '$lib/modules/website/module.config'; import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config'; import { writingModuleConfig } from '$lib/modules/writing/module.config'; import { comicModuleConfig } from '$lib/modules/comic/module.config'; +import { augurModuleConfig } from '$lib/modules/augur/module.config'; import { aiModuleConfig } from '$lib/data/ai/module.config'; export const MODULE_CONFIGS: readonly ModuleConfig[] = [ @@ -170,6 +171,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ wardrobeModuleConfig, writingModuleConfig, comicModuleConfig, + augurModuleConfig, aiModuleConfig, ]; diff --git a/apps/mana/apps/web/src/lib/data/seed-registry.ts b/apps/mana/apps/web/src/lib/data/seed-registry.ts index 56da92c86..f33ba1da5 100644 --- a/apps/mana/apps/web/src/lib/data/seed-registry.ts +++ b/apps/mana/apps/web/src/lib/data/seed-registry.ts @@ -36,6 +36,7 @@ import { SLEEP_GUEST_SEED } from '$lib/modules/sleep/collections'; import { MOOD_GUEST_SEED } from '$lib/modules/mood/collections'; import { QUIZ_GUEST_SEED } from '$lib/modules/quiz/collections'; import { WISHES_GUEST_SEED } from '$lib/modules/wishes/collections'; +import { AUGUR_GUEST_SEED } from '$lib/modules/augur/collections'; /** * Flat list of { tableName, rows } entries. Only modules with non-empty @@ -76,6 +77,7 @@ register(SLEEP_GUEST_SEED); register(MOOD_GUEST_SEED); register(QUIZ_GUEST_SEED); register(WISHES_GUEST_SEED); +register(AUGUR_GUEST_SEED); /** * Seed all module guest data into empty tables. Idempotent: tables diff --git a/apps/mana/apps/web/src/lib/data/tools/init.ts b/apps/mana/apps/web/src/lib/data/tools/init.ts index 58983e8cd..f86fb3ea7 100644 --- a/apps/mana/apps/web/src/lib/data/tools/init.ts +++ b/apps/mana/apps/web/src/lib/data/tools/init.ts @@ -48,6 +48,7 @@ import { broadcastTools } from '$lib/modules/broadcast/tools'; import { websiteTools } from '$lib/modules/website/tools'; import { writingTools } from '$lib/modules/writing/tools'; import { comicTools } from '$lib/modules/comic/tools'; +import { augurTools } from '$lib/modules/augur/tools'; let initialized = false; @@ -97,5 +98,6 @@ export function initTools(): void { registerTools(websiteTools); registerTools(writingTools); registerTools(comicTools); + registerTools(augurTools); initialized = true; } diff --git a/apps/mana/apps/web/src/lib/modules/augur/ListView.svelte b/apps/mana/apps/web/src/lib/modules/augur/ListView.svelte new file mode 100644 index 000000000..e031d32fd --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/ListView.svelte @@ -0,0 +1,121 @@ + + + +
+
+ + +
+ + {#if mode === 'oracle'} + + {:else} + + {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/augur/collections.ts b/apps/mana/apps/web/src/lib/modules/augur/collections.ts new file mode 100644 index 000000000..779313d17 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/collections.ts @@ -0,0 +1,83 @@ +import { db } from '$lib/data/database'; +import type { LocalAugurEntry } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const augurEntriesTable = db.table('augurEntries'); + +// ─── Guest Seed ──────────────────────────────────────────── + +const today = new Date().toISOString().slice(0, 10); +const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); +const lastWeek = new Date(Date.now() - 7 * 86_400_000).toISOString().slice(0, 10); + +export const AUGUR_GUEST_SEED = { + augurEntries: [ + { + id: 'augur-welcome-omen', + kind: 'omen', + source: 'Doppelter Regenbogen am Morgen', + sourceCategory: 'natural', + claim: 'Ein guter Tag steht bevor.', + vibe: 'good', + feltMeaning: 'Vielleicht das Zeichen, dass das Projekt heute Fortschritt bringt.', + expectedOutcome: 'Heute kommt eine gute Nachricht zum Projekt.', + expectedBy: today, + probability: null, + outcome: 'fulfilled', + outcomeNote: 'Tatsächlich kam die Zusage.', + resolvedAt: today, + encounteredAt: yesterday, + tags: ['arbeit', 'naturzeichen'], + relatedDreamId: null, + relatedDecisionId: null, + livingOracleSnapshot: null, + isPrivate: true, + isArchived: false, + }, + { + id: 'augur-welcome-fortune', + kind: 'fortune', + source: 'Glückskeks gestern Abend', + sourceCategory: 'fortune-cookie', + claim: 'Der nächste Schritt führt dich weiter, als du denkst.', + vibe: 'mysterious', + feltMeaning: null, + expectedOutcome: null, + expectedBy: null, + probability: null, + outcome: 'open', + outcomeNote: null, + resolvedAt: null, + encounteredAt: lastWeek, + tags: ['fortune-cookie'], + relatedDreamId: null, + relatedDecisionId: null, + livingOracleSnapshot: null, + isPrivate: true, + isArchived: false, + }, + { + id: 'augur-welcome-hunch', + kind: 'hunch', + source: 'Bauchgefühl beim Lesen der Mail', + sourceCategory: 'gut', + claim: 'Diese Anfrage bringt mehr Arbeit als sie wert ist.', + vibe: 'bad', + feltMeaning: 'Sollte freundlich, aber bestimmt absagen.', + expectedOutcome: 'Wenn ich annehme, verbringe ich >5h damit.', + expectedBy: null, + probability: 0.7, + outcome: 'open', + outcomeNote: null, + resolvedAt: null, + encounteredAt: today, + tags: ['arbeit'], + relatedDreamId: null, + relatedDecisionId: null, + livingOracleSnapshot: null, + isPrivate: true, + isArchived: false, + }, + ] satisfies LocalAugurEntry[], +}; diff --git a/apps/mana/apps/web/src/lib/modules/augur/components/DueBanner.svelte b/apps/mana/apps/web/src/lib/modules/augur/components/DueBanner.svelte new file mode 100644 index 000000000..27c13227c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/components/DueBanner.svelte @@ -0,0 +1,235 @@ + + + +{#if entries.length > 0} + +{/if} + + diff --git a/apps/mana/apps/web/src/lib/modules/augur/components/EntryCard.svelte b/apps/mana/apps/web/src/lib/modules/augur/components/EntryCard.svelte new file mode 100644 index 000000000..0bf2309e6 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/components/EntryCard.svelte @@ -0,0 +1,116 @@ + + + + + diff --git a/apps/mana/apps/web/src/lib/modules/augur/components/EntryForm.svelte b/apps/mana/apps/web/src/lib/modules/augur/components/EntryForm.svelte new file mode 100644 index 000000000..1e83201a3 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/components/EntryForm.svelte @@ -0,0 +1,364 @@ + + +
+ {#if mode === 'create'} +
+ + +
+ {/if} + +
+ + +
+ + + +
+ +
+ + + + {#if mode === 'create'} + (oracleReflection = text)} + /> + {/if} + +
+ + Prognose & Tags + +
+ + +
+ +
+ +
+ {#if onclose} + + {/if} + +
+ + + diff --git a/apps/mana/apps/web/src/lib/modules/augur/components/KindTabs.svelte b/apps/mana/apps/web/src/lib/modules/augur/components/KindTabs.svelte new file mode 100644 index 000000000..ec8992f83 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/components/KindTabs.svelte @@ -0,0 +1,87 @@ + + +
+ + {#each ORDER as kind (kind)} + + {/each} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/augur/components/LivingOracleHint.svelte b/apps/mana/apps/web/src/lib/modules/augur/components/LivingOracleHint.svelte new file mode 100644 index 000000000..8e32f0a70 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/components/LivingOracleHint.svelte @@ -0,0 +1,175 @@ + + + +{#if props.mode === 'snapshot'} + {#if props.snapshot} + + {/if} +{:else if liveResult} + +{/if} + + diff --git a/apps/mana/apps/web/src/lib/modules/augur/components/OutcomeBadge.svelte b/apps/mana/apps/web/src/lib/modules/augur/components/OutcomeBadge.svelte new file mode 100644 index 000000000..edee25d83 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/components/OutcomeBadge.svelte @@ -0,0 +1,33 @@ + + + + {OUTCOME_LABELS[outcome].de} + + + diff --git a/apps/mana/apps/web/src/lib/modules/augur/components/VibeBadge.svelte b/apps/mana/apps/web/src/lib/modules/augur/components/VibeBadge.svelte new file mode 100644 index 000000000..fe640a98f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/components/VibeBadge.svelte @@ -0,0 +1,26 @@ + + + + {VIBE_LABELS[vibe].de} + + + diff --git a/apps/mana/apps/web/src/lib/modules/augur/index.ts b/apps/mana/apps/web/src/lib/modules/augur/index.ts new file mode 100644 index 000000000..404f59552 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/index.ts @@ -0,0 +1,33 @@ +// ─── Stores ────────────────────────────────────────────── +export { augurStore } from './stores/entries.svelte'; + +// ─── Queries ───────────────────────────────────────────── +export { + useAllAugurEntries, + useAugurEntriesByKind, + useUnresolvedAugurEntries, + useDueForReveal, + toAugurEntry, + searchAugurEntries, + groupByKind, +} from './queries'; + +// ─── Collections ───────────────────────────────────────── +export { augurEntriesTable, AUGUR_GUEST_SEED } from './collections'; + +// ─── Types ─────────────────────────────────────────────── +export { + KIND_LABELS, + VIBE_LABELS, + VIBE_COLORS, + OUTCOME_LABELS, + SOURCE_CATEGORY_LABELS, +} from './types'; +export type { + LocalAugurEntry, + AugurEntry, + AugurKind, + AugurVibe, + AugurOutcome, + AugurSourceCategory, +} from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/augur/lib/calibration.ts b/apps/mana/apps/web/src/lib/modules/augur/lib/calibration.ts new file mode 100644 index 000000000..52dae24cb --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/lib/calibration.ts @@ -0,0 +1,193 @@ +/** + * Augur — Calibration & Hit-Rate Math + * + * Pure deterministic stats. No I/O, no Dexie, no Svelte runes — every + * function takes already-decrypted `AugurEntry[]` and returns plain + * data. The OracleView consumes these results and renders. + * + * Two flavours of "is the user calibrated?": + * + * 1. **Hit-Rate** — what fraction of resolved entries came true? + * Counts `fulfilled` as 1, `partly` as 0.5, `not-fulfilled` as 0. + * `open` entries are excluded — they have no ground truth yet. + * + * 2. **Brier Score** — only meaningful when the user provided a + * `probability` at capture time. Squared error between forecast + * probability and outcome (1 / 0.5 / 0). Lower = better; 0.25 = + * "always 50/50". Surfaces "are your numerical bets calibrated?" + * separately from "did your gut feeling come true?". + * + * Vibe-hit-rate is the same logic per `good`/`bad`/`mysterious`. It + * answers: "when you marked something as a 'good sign', how often was + * it actually good news?" + */ + +import type { AugurEntry, AugurOutcome, AugurSourceCategory, AugurVibe } from '../types'; + +/** Outcome → numeric value for hit-rate / Brier math. */ +export function outcomeValue(outcome: AugurOutcome): number | null { + switch (outcome) { + case 'fulfilled': + return 1; + case 'partly': + return 0.5; + case 'not-fulfilled': + return 0; + case 'open': + return null; + } +} + +/** True if the entry has a resolved outcome we can score. */ +export function isScored(e: AugurEntry): boolean { + return outcomeValue(e.outcome) != null; +} + +export interface SourceCalibration { + sourceCategory: AugurSourceCategory; + n: number; + hitRate: number; // 0..1, weighted (partly = 0.5) + fulfilled: number; + partly: number; + notFulfilled: number; + /** Brier score over entries with `probability` set. null if no such entries. */ + brier: number | null; + brierN: number; +} + +/** One row per `sourceCategory` that has at least one resolved entry. */ +export function calibrationPerSource(entries: AugurEntry[]): SourceCalibration[] { + const buckets = new Map(); + for (const e of entries) { + if (!isScored(e)) continue; + const arr = buckets.get(e.sourceCategory) ?? []; + arr.push(e); + buckets.set(e.sourceCategory, arr); + } + + const rows: SourceCalibration[] = []; + for (const [sourceCategory, group] of buckets) { + let weighted = 0; + let fulfilled = 0; + let partly = 0; + let notFulfilled = 0; + let brierSum = 0; + let brierN = 0; + for (const e of group) { + const v = outcomeValue(e.outcome)!; + weighted += v; + if (e.outcome === 'fulfilled') fulfilled++; + else if (e.outcome === 'partly') partly++; + else if (e.outcome === 'not-fulfilled') notFulfilled++; + if (e.probability != null) { + const diff = e.probability - v; + brierSum += diff * diff; + brierN++; + } + } + rows.push({ + sourceCategory, + n: group.length, + hitRate: weighted / group.length, + fulfilled, + partly, + notFulfilled, + brier: brierN > 0 ? brierSum / brierN : null, + brierN, + }); + } + rows.sort((a, b) => b.n - a.n); + return rows; +} + +export interface VibeHitRate { + vibe: AugurVibe; + n: number; + hitRate: number; // 0..1, weighted + /** + * For 'good' / 'bad' vibes, how often did the directionality match? + * - good vibe + fulfilled → directional hit + * - bad vibe + not-fulfilled → directional hit (your "warning" was right + * that it wouldn't happen) + * - mysterious → no direction expected, returns null. + */ + directionalHitRate: number | null; +} + +export function vibeHitRates(entries: AugurEntry[]): VibeHitRate[] { + const order: AugurVibe[] = ['good', 'mysterious', 'bad']; + const rows: VibeHitRate[] = []; + for (const vibe of order) { + const group = entries.filter((e) => e.vibe === vibe && isScored(e)); + if (group.length === 0) { + rows.push({ vibe, n: 0, hitRate: 0, directionalHitRate: null }); + continue; + } + let weighted = 0; + let directionalHit = 0; + let directionalN = 0; + for (const e of group) { + const v = outcomeValue(e.outcome)!; + weighted += v; + if (vibe === 'good') { + directionalN++; + if (e.outcome === 'fulfilled') directionalHit++; + else if (e.outcome === 'partly') directionalHit += 0.5; + } else if (vibe === 'bad') { + directionalN++; + if (e.outcome === 'not-fulfilled') directionalHit++; + else if (e.outcome === 'partly') directionalHit += 0.5; + } + } + rows.push({ + vibe, + n: group.length, + hitRate: weighted / group.length, + directionalHitRate: directionalN > 0 ? directionalHit / directionalN : null, + }); + } + return rows; +} + +export interface OverallStats { + total: number; + resolved: number; + open: number; + hitRate: number | null; + brier: number | null; + brierN: number; +} + +export function overallStats(entries: AugurEntry[]): OverallStats { + let resolved = 0; + let open = 0; + let weighted = 0; + let brierSum = 0; + let brierN = 0; + for (const e of entries) { + if (e.outcome === 'open') { + open++; + continue; + } + const v = outcomeValue(e.outcome); + if (v == null) continue; + resolved++; + weighted += v; + if (e.probability != null) { + const diff = e.probability - v; + brierSum += diff * diff; + brierN++; + } + } + return { + total: entries.length, + resolved, + open, + hitRate: resolved > 0 ? weighted / resolved : null, + brier: brierN > 0 ? brierSum / brierN : null, + brierN, + }; +} + +/** UI threshold: below this, OracleView shows the cold-start empty state. */ +export const ORACLE_COLD_START_MIN = 20; diff --git a/apps/mana/apps/web/src/lib/modules/augur/lib/correlation-engine.ts b/apps/mana/apps/web/src/lib/modules/augur/lib/correlation-engine.ts new file mode 100644 index 000000000..dbc556c2a --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/lib/correlation-engine.ts @@ -0,0 +1,182 @@ +/** + * Augur — Correlation Engine + * + * Cross-module mining: for every augur entry, look at the user's mood + * level and sleep quality / duration in the days *after* `encounteredAt` + * and compare those windows against the user's overall baseline. + * + * Pure functions: no Dexie, no Svelte runes. Caller (OracleView via + * `signal-bridge.svelte.ts`) supplies pre-aggregated maps. + * + * Honest framing in the UI is non-negotiable: this finds *correlations + * within the user's own data*, not causation. The threshold logic + * below (n ≥ 5, |Δ| ≥ 0.3 baseline-stdev) errs on the side of silence. + */ + +import type { AugurEntry, AugurKind, AugurVibe } from '../types'; + +export const CORRELATION_MIN_N = 5; +/** A finding is shown when the bucket mean differs from baseline by at + * least this many standard-deviations. 0.3σ is a soft signal — well + * below "statistically significant" but enough to be worth noticing. */ +export const CORRELATION_MIN_STDEV_DELTA = 0.3; + +export type CorrelationDimension = 'vibe' | 'kind'; +export type CorrelationMetric = 'mood-level' | 'sleep-quality' | 'sleep-duration'; +export type CorrelationWindow = 1 | 3 | 7; + +export interface CorrelationFinding { + dimension: CorrelationDimension; + bucket: AugurVibe | AugurKind; + metric: CorrelationMetric; + windowDays: CorrelationWindow; + baseline: number; + bucketMean: number; + delta: number; + deltaSigmas: number; + n: number; +} + +/** Map from YYYY-MM-DD to the mean mood level on that day, or undefined. */ +export type MoodByDate = Map; + +export interface SleepDay { + quality: number; + durationMin: number; +} +export type SleepByDate = Map; + +/** Calendar shift on a YYYY-MM-DD date; positive = forward. */ +function addDays(iso: string, delta: number): string { + const d = new Date(iso); + d.setUTCDate(d.getUTCDate() + delta); + return d.toISOString().slice(0, 10); +} + +function mean(xs: number[]): number { + if (xs.length === 0) return 0; + let s = 0; + for (const x of xs) s += x; + return s / xs.length; +} + +function stdev(xs: number[]): number { + if (xs.length < 2) return 0; + const m = mean(xs); + let s = 0; + for (const x of xs) s += (x - m) ** 2; + return Math.sqrt(s / (xs.length - 1)); +} + +function metricValue( + metric: CorrelationMetric, + mood: MoodByDate, + sleep: SleepByDate, + date: string +): number | null { + switch (metric) { + case 'mood-level': + return mood.get(date) ?? null; + case 'sleep-quality': + return sleep.get(date)?.quality ?? null; + case 'sleep-duration': + return sleep.get(date)?.durationMin ?? null; + } +} + +/** Pull every value for the metric in [date+1 .. date+windowDays]. */ +function readWindow( + metric: CorrelationMetric, + mood: MoodByDate, + sleep: SleepByDate, + startDate: string, + windowDays: CorrelationWindow +): number[] { + const xs: number[] = []; + for (let d = 1; d <= windowDays; d++) { + const v = metricValue(metric, mood, sleep, addDays(startDate, d)); + if (v != null) xs.push(v); + } + return xs; +} + +function bucketKey(dim: CorrelationDimension, e: AugurEntry): AugurVibe | AugurKind { + return dim === 'vibe' ? e.vibe : e.kind; +} + +/** All buckets the engine considers, in stable display order. */ +const VIBE_BUCKETS: AugurVibe[] = ['good', 'mysterious', 'bad']; +const KIND_BUCKETS: AugurKind[] = ['omen', 'fortune', 'hunch']; +const WINDOWS: CorrelationWindow[] = [3]; +const METRICS: CorrelationMetric[] = ['mood-level', 'sleep-quality', 'sleep-duration']; + +export function computeCorrelations( + entries: AugurEntry[], + mood: MoodByDate, + sleep: SleepByDate +): CorrelationFinding[] { + if (entries.length === 0) return []; + + const out: CorrelationFinding[] = []; + + for (const metric of METRICS) { + // Build the user's baseline distribution for this metric — every value + // the metric ever took, regardless of augur entries. Used both for the + // baseline mean and the σ that drives the signal threshold. + const baselineValues: number[] = + metric === 'mood-level' + ? Array.from(mood.values()) + : Array.from(sleep.values()).map((s) => + metric === 'sleep-quality' ? s.quality : s.durationMin + ); + if (baselineValues.length < CORRELATION_MIN_N) continue; + + const baseline = mean(baselineValues); + const sigma = stdev(baselineValues); + if (sigma === 0) continue; + + for (const window of WINDOWS) { + const dimensions: { + dim: CorrelationDimension; + buckets: readonly (AugurVibe | AugurKind)[]; + }[] = [ + { dim: 'vibe', buckets: VIBE_BUCKETS }, + { dim: 'kind', buckets: KIND_BUCKETS }, + ]; + + for (const { dim, buckets } of dimensions) { + for (const bucket of buckets) { + const bucketEntries = entries.filter((e) => bucketKey(dim, e) === bucket); + if (bucketEntries.length === 0) continue; + + const vals: number[] = []; + for (const e of bucketEntries) { + vals.push(...readWindow(metric, mood, sleep, e.encounteredAt, window)); + } + if (vals.length < CORRELATION_MIN_N) continue; + + const m = mean(vals); + const delta = m - baseline; + const deltaSigmas = delta / sigma; + + if (Math.abs(deltaSigmas) < CORRELATION_MIN_STDEV_DELTA) continue; + + out.push({ + dimension: dim, + bucket, + metric, + windowDays: window, + baseline, + bucketMean: m, + delta, + deltaSigmas, + n: vals.length, + }); + } + } + } + } + + out.sort((a, b) => Math.abs(b.deltaSigmas) - Math.abs(a.deltaSigmas)); + return out; +} diff --git a/apps/mana/apps/web/src/lib/modules/augur/lib/living-oracle.ts b/apps/mana/apps/web/src/lib/modules/augur/lib/living-oracle.ts new file mode 100644 index 000000000..114a06fcd --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/lib/living-oracle.ts @@ -0,0 +1,210 @@ +/** + * Augur — Living Oracle + * + * The killer mechanic from docs/plans/augur-module.md M4.5: when the + * user captures a new sign, look up the user's *own* past resolved + * signs that resemble it and surface what happened to those. + * + * Empirism wearing the cloak of divination — the magic isn't claimed, + * it materialises from the user's own data. + * + * Pure deterministic stats here. The (optional) LLM-phrasing layer + * lives outside this file; if it's not wired up we still produce a + * usable nuechtern message. + * + * Cold-start gate: under 50 resolved entries the engine refuses to + * speak. Below that, statistics are too noisy and the user would + * trust patterns that aren't there. + */ + +import { isScored, outcomeValue } from './calibration'; +import type { AugurEntry, AugurKind, AugurSourceCategory, AugurVibe } from '../types'; + +export const LIVING_ORACLE_COLD_START_MIN = 50; +export const LIVING_ORACLE_MIN_MATCHES = 3; +export const LIVING_ORACLE_MIN_SCORE = 2; + +/** Components used to compare two entries for "similarity". */ +export interface Fingerprint { + kind: AugurKind; + sourceCategory: AugurSourceCategory; + vibe: AugurVibe; + tags: Set; + keywords: Set; +} + +/** Build a fingerprint from a (possibly partial) entry-shape. */ +export function fingerprint(input: { + kind?: AugurKind | null; + sourceCategory?: AugurSourceCategory | null; + vibe?: AugurVibe | null; + tags?: string[] | null; + source?: string | null; + claim?: string | null; +}): Fingerprint | null { + if (!input.kind || !input.sourceCategory || !input.vibe) return null; + return { + kind: input.kind, + sourceCategory: input.sourceCategory, + vibe: input.vibe, + tags: new Set((input.tags ?? []).map((t) => t.toLowerCase().trim()).filter(Boolean)), + keywords: extractKeywords([input.source, input.claim].filter(Boolean).join(' ')), + }; +} + +/** Tokenize a free-text blob into deduped lowercase keywords ≥4 chars. */ +export function extractKeywords(text: string): Set { + const STOP = new Set([ + 'oder', + 'aber', + 'doch', + 'eine', + 'einer', + 'einen', + 'eines', + 'einem', + 'wenn', + 'dann', + 'noch', + 'sehr', + 'mehr', + 'auch', + 'durch', + 'ueber', + 'unter', + 'gegen', + 'sich', + 'haben', + 'hatte', + 'sein', + 'sind', + 'wird', + 'wurde', + 'kann', + 'koennen', + 'wie', + 'was', + 'warum', + 'wann', + 'wer', + 'this', + 'that', + 'have', + 'with', + 'from', + 'they', + 'will', + 'been', + 'were', + 'when', + 'what', + 'just', + ]); + return new Set( + text + .toLowerCase() + .normalize('NFKD') + .replace(/[^a-z0-9\säöüß]/g, ' ') + .split(/\s+/) + .filter((w) => w.length >= 4 && !STOP.has(w)) + ); +} + +/** + * Component-overlap score (0..5). 1 point per overlapping component: + * + * kind, sourceCategory, vibe → exact match + * tags → at least one shared tag + * keywords → at least one shared keyword + * + * The pragmatic threshold for "this is a similar sign" is `>= 2`. + */ +export function matchScore(a: Fingerprint, b: Fingerprint): number { + let score = 0; + if (a.kind === b.kind) score++; + if (a.sourceCategory === b.sourceCategory) score++; + if (a.vibe === b.vibe) score++; + if (intersects(a.tags, b.tags)) score++; + if (intersects(a.keywords, b.keywords)) score++; + return score; +} + +function intersects(a: Set, b: Set): boolean { + if (a.size === 0 || b.size === 0) return false; + const [small, big] = a.size <= b.size ? [a, b] : [b, a]; + for (const x of small) if (big.has(x)) return true; + return false; +} + +export interface OracleMatchSet { + matches: AugurEntry[]; + n: number; + hitRate: number; + fulfilled: number; + partly: number; + notFulfilled: number; +} + +/** Find the resolved past entries that match `input` strongly enough. */ +export function findMatches( + input: Fingerprint, + history: AugurEntry[], + excludeId?: string +): OracleMatchSet { + const matches: AugurEntry[] = []; + for (const e of history) { + if (e.id === excludeId) continue; + if (!isScored(e)) continue; + const fp = fingerprint(e); + if (!fp) continue; + if (matchScore(input, fp) >= LIVING_ORACLE_MIN_SCORE) matches.push(e); + } + let weighted = 0; + let fulfilled = 0; + let partly = 0; + let notFulfilled = 0; + for (const m of matches) { + const v = outcomeValue(m.outcome) ?? 0; + weighted += v; + if (m.outcome === 'fulfilled') fulfilled++; + else if (m.outcome === 'partly') partly++; + else if (m.outcome === 'not-fulfilled') notFulfilled++; + } + return { + matches, + n: matches.length, + hitRate: matches.length > 0 ? weighted / matches.length : 0, + fulfilled, + partly, + notFulfilled, + }; +} + +/** + * Decide whether the engine should speak at all, given the history size + * and the match-set. Below the cold-start threshold or below the min + * match count → silent. + */ +export function shouldSpeak(historyTotal: number, set: OracleMatchSet): boolean { + if (historyTotal < LIVING_ORACLE_COLD_START_MIN) return false; + return set.n >= LIVING_ORACLE_MIN_MATCHES; +} + +/** + * Build a nuechterner deterministic reflection. No LLM, no hallucinations. + * Returns null when shouldSpeak is false. The string is what gets stored + * into `livingOracleSnapshot` for audit at resolve-time. + */ +export function makeReflection(set: OracleMatchSet): string | null { + if (set.n < LIVING_ORACLE_MIN_MATCHES) return null; + const pct = Math.round(set.hitRate * 100); + const parts: string[] = []; + parts.push(`Du hast ${set.n} aehnliche Zeichen schon einmal protokolliert.`); + const breakdown: string[] = []; + if (set.fulfilled) breakdown.push(`${set.fulfilled} eingetreten`); + if (set.partly) breakdown.push(`${set.partly} teilweise`); + if (set.notFulfilled) breakdown.push(`${set.notFulfilled} nicht eingetreten`); + if (breakdown.length > 0) parts.push(`Davon: ${breakdown.join(', ')}.`); + parts.push(`Trefferquote bei aehnlichen Mustern: ${pct}%.`); + return parts.join(' '); +} diff --git a/apps/mana/apps/web/src/lib/modules/augur/lib/reminders.ts b/apps/mana/apps/web/src/lib/modules/augur/lib/reminders.ts new file mode 100644 index 000000000..78b48bde6 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/lib/reminders.ts @@ -0,0 +1,71 @@ +/** + * Augur — Resolve-Reminder Helpers + * + * Pure date math + due-detection logic. No I/O, no Dexie. Lives under + * `lib/` so it can be reused by both the witness UI and (later) the + * mana-notify pipeline without dragging Svelte runes along. + * + * Strategy (docs/plans/augur-module.md M3): + * - When the user set `expectedBy`, the entry is "due" the day after + * that deadline passed (and outcome === 'open'). + * - When `expectedBy` is null, fall back to encounteredAt + 30 days. + * + * The 30-day fallback only applies *for surfacing*, never as data. + * `expectedBy` itself stays null on the row — the user can still set + * one explicitly later, and we don't want to retroactively claim a + * date the user didn't choose. + */ + +import type { AugurEntry } from '../types'; + +export const DEFAULT_REMINDER_DAYS = 30; + +function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} + +function addDays(isoDate: string, days: number): string { + const d = new Date(isoDate); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +/** ISO date when the entry should first surface as "due", or null if it + * never should (already resolved, or invalid encounteredAt). */ +export function reminderDate( + entry: Pick +): string | null { + if (entry.outcome !== 'open') return null; + if (entry.expectedBy) return entry.expectedBy; + if (!entry.encounteredAt) return null; + return addDays(entry.encounteredAt, DEFAULT_REMINDER_DAYS); +} + +/** True if the entry's reminder date is on or before `today`. */ +export function isDue( + entry: Pick, + today: string = todayIso() +): boolean { + const r = reminderDate(entry); + return r != null && r <= today; +} + +/** Days remaining until the reminder fires. Negative if overdue. */ +export function daysUntilDue( + entry: Pick, + today: string = todayIso() +): number | null { + const r = reminderDate(entry); + if (!r) return null; + const a = new Date(today).getTime(); + const b = new Date(r).getTime(); + return Math.round((b - a) / 86_400_000); +} + +/** Filter helper: only entries that are open AND past their reminder date. */ +export function filterDue>( + entries: T[], + today: string = todayIso() +): T[] { + return entries.filter((e) => isDue(e, today)); +} diff --git a/apps/mana/apps/web/src/lib/modules/augur/lib/signal-bridge.svelte.ts b/apps/mana/apps/web/src/lib/modules/augur/lib/signal-bridge.svelte.ts new file mode 100644 index 000000000..394cac001 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/lib/signal-bridge.svelte.ts @@ -0,0 +1,57 @@ +/** + * Augur — Cross-Module Signal Bridge + * + * Reads the plaintext daily aggregates from `mood` and `sleep` for the + * correlation engine. Both modules keep `level` / `quality` / + * `durationMin` plaintext (only `notes` / `withWhom` are encrypted), so + * we can build per-date maps without touching the vault. + * + * Returns reactive maps inside a Svelte runes wrapper. + */ + +import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; +import { scopedForModule } from '$lib/data/scope'; +import type { LocalMoodEntry } from '$lib/modules/mood/types'; +import type { LocalSleepEntry } from '$lib/modules/sleep/types'; +import type { MoodByDate, SleepByDate, SleepDay } from './correlation-engine'; + +/** Per-date mean mood level. Multiple check-ins on the same day get + * averaged because a single number is the right granularity for the + * correlation engine's "what was the mood window" question. */ +export function useMoodByDate() { + return useScopedLiveQuery(async () => { + const rows = await scopedForModule('mood', 'moodEntries').toArray(); + const visible = rows.filter((r) => !r.deletedAt && r.date); + const sums = new Map(); + for (const r of visible) { + const lvl = Number(r.level); + if (!Number.isFinite(lvl)) continue; + const cur = sums.get(r.date) ?? { sum: 0, count: 0 }; + cur.sum += lvl; + cur.count++; + sums.set(r.date, cur); + } + const map: MoodByDate = new Map(); + for (const [date, { sum, count }] of sums) map.set(date, sum / count); + return map; + }, new Map() as MoodByDate); +} + +/** Per-night sleep — one row per date by the sleep module's contract. */ +export function useSleepByDate() { + return useScopedLiveQuery(async () => { + const rows = await scopedForModule('sleep', 'sleepEntries').toArray(); + const visible = rows.filter((r) => !r.deletedAt && r.date); + const map: SleepByDate = new Map(); + for (const r of visible) { + const quality = Number(r.quality); + const durationMin = Number(r.durationMin); + if (!Number.isFinite(quality) || !Number.isFinite(durationMin)) continue; + // If multiple rows exist for the same date (rare — usually one per + // night), keep the last write — sleep entries are unique per date + // in practice but the contract doesn't enforce it. + map.set(r.date, { quality, durationMin } satisfies SleepDay); + } + return map; + }, new Map() as SleepByDate); +} diff --git a/apps/mana/apps/web/src/lib/modules/augur/lib/year-recap.ts b/apps/mana/apps/web/src/lib/modules/augur/lib/year-recap.ts new file mode 100644 index 000000000..447dbfbc0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/lib/year-recap.ts @@ -0,0 +1,111 @@ +/** + * Augur — Year Recap Aggregator + * + * Pure: takes the full augur entry list + a year and returns a structured + * snapshot suitable for both the YearRecapView and the `augur_year_recap` + * MCP tool. No I/O. + * + * The shape is stable so a future LLM-phrasing layer (M6 stretch) can + * narrate from it without re-implementing the maths. + */ + +import { + calibrationPerSource, + overallStats, + vibeHitRates, + type SourceCalibration, + type VibeHitRate, +} from './calibration'; +import type { AugurEntry, AugurKind, AugurOutcome, AugurSourceCategory, AugurVibe } from '../types'; + +export interface YearRecap { + year: number; + total: number; + resolved: number; + open: number; + hitRate: number | null; + brier: number | null; + brierN: number; + byKind: Record; + byVibe: Record; + byOutcome: Record; + topCategories: { category: AugurSourceCategory; n: number; hitRate: number }[]; + bestSource: SourceCalibration | null; + worstSource: SourceCalibration | null; + vibeRows: VibeHitRate[]; + mostFulfilled: AugurEntry[]; + mostSurprising: AugurEntry[]; +} + +function isInYear(e: AugurEntry, year: number): boolean { + return e.encounteredAt.startsWith(`${year}-`); +} + +export function buildYearRecap(entries: AugurEntry[], year: number): YearRecap { + const inYear = entries.filter((e) => isInYear(e, year)); + + const stats = overallStats(inYear); + const vibeRows = vibeHitRates(inYear); + const sourceRows = calibrationPerSource(inYear); + + const byKind: Record = { omen: 0, fortune: 0, hunch: 0 }; + const byVibe: Record = { good: 0, bad: 0, mysterious: 0 }; + const byOutcome: Record = { + open: 0, + fulfilled: 0, + partly: 0, + 'not-fulfilled': 0, + }; + for (const e of inYear) { + byKind[e.kind]++; + byVibe[e.vibe]++; + byOutcome[e.outcome]++; + } + + const topCategories = sourceRows + .slice() + .sort((a, b) => b.n - a.n) + .slice(0, 5) + .map((r) => ({ category: r.sourceCategory, n: r.n, hitRate: r.hitRate })); + + const eligible = sourceRows.filter((r) => r.n >= 3); + const bestSource = + eligible.length > 0 ? [...eligible].sort((a, b) => b.hitRate - a.hitRate)[0] : null; + const worstSource = + eligible.length > 0 ? [...eligible].sort((a, b) => a.hitRate - b.hitRate)[0] : null; + + const mostFulfilled = inYear + .filter((e) => e.outcome === 'fulfilled') + .sort((a, b) => (b.resolvedAt ?? '').localeCompare(a.resolvedAt ?? '')) + .slice(0, 5); + + // "Surprising" = good vibe → not-fulfilled, OR bad vibe → fulfilled. The + // universe disagreed with the user's gut. These tend to be the most + // learning-worthy moments at year-end. + const mostSurprising = inYear + .filter( + (e) => + (e.vibe === 'good' && e.outcome === 'not-fulfilled') || + (e.vibe === 'bad' && e.outcome === 'fulfilled') + ) + .slice(0, 5); + + return { + year, + total: inYear.length, + resolved: stats.resolved, + open: stats.open, + hitRate: stats.hitRate, + brier: stats.brier, + brierN: stats.brierN, + byKind, + byVibe, + byOutcome, + topCategories, + bestSource, + worstSource, + vibeRows, + mostFulfilled, + mostSurprising, + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/augur/module.config.ts b/apps/mana/apps/web/src/lib/modules/augur/module.config.ts new file mode 100644 index 000000000..2fed97f0f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/module.config.ts @@ -0,0 +1,6 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const augurModuleConfig: ModuleConfig = { + appId: 'augur', + tables: [{ name: 'augurEntries' }], +}; diff --git a/apps/mana/apps/web/src/lib/modules/augur/queries.ts b/apps/mana/apps/web/src/lib/modules/augur/queries.ts new file mode 100644 index 000000000..150abbf16 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/queries.ts @@ -0,0 +1,120 @@ +import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; +import { scopedForModule } from '$lib/data/scope'; +import { decryptRecords } from '$lib/data/crypto'; +import { isDue } from './lib/reminders'; +import type { AugurEntry, AugurKind, LocalAugurEntry } from './types'; + +// ─── Type Converter ──────────────────────────────────────── + +export function toAugurEntry(local: LocalAugurEntry): AugurEntry { + return { + id: local.id, + kind: local.kind, + source: local.source, + sourceCategory: local.sourceCategory, + claim: local.claim, + vibe: local.vibe, + feltMeaning: local.feltMeaning ?? null, + expectedOutcome: local.expectedOutcome ?? null, + expectedBy: local.expectedBy ?? null, + probability: local.probability ?? null, + outcome: local.outcome, + outcomeNote: local.outcomeNote ?? null, + resolvedAt: local.resolvedAt ?? null, + encounteredAt: local.encounteredAt, + tags: local.tags ?? [], + relatedDreamId: local.relatedDreamId ?? null, + relatedDecisionId: local.relatedDecisionId ?? null, + livingOracleSnapshot: local.livingOracleSnapshot ?? null, + isPrivate: local.isPrivate, + isArchived: local.isArchived, + visibility: local.visibility ?? 'private', + unlistedToken: local.unlistedToken ?? '', + unlistedExpiresAt: local.unlistedExpiresAt ?? null, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +function visibleScoped() { + return scopedForModule('augur', 'augurEntries').toArray(); +} + +export function useAllAugurEntries() { + return useScopedLiveQuery(async () => { + const visible = (await visibleScoped()).filter((e) => !e.deletedAt && !e.isArchived); + const decrypted = await decryptRecords('augurEntries', visible); + return decrypted + .map(toAugurEntry) + .sort((a, b) => b.encounteredAt.localeCompare(a.encounteredAt)); + }, [] as AugurEntry[]); +} + +export function useAugurEntriesByKind(kind: AugurKind) { + return useScopedLiveQuery(async () => { + const visible = (await visibleScoped()).filter( + (e) => !e.deletedAt && !e.isArchived && e.kind === kind + ); + const decrypted = await decryptRecords('augurEntries', visible); + return decrypted + .map(toAugurEntry) + .sort((a, b) => b.encounteredAt.localeCompare(a.encounteredAt)); + }, [] as AugurEntry[]); +} + +export function useUnresolvedAugurEntries() { + return useScopedLiveQuery(async () => { + const visible = (await visibleScoped()).filter( + (e) => !e.deletedAt && !e.isArchived && e.outcome === 'open' + ); + const decrypted = await decryptRecords('augurEntries', visible); + return decrypted + .map(toAugurEntry) + .sort((a, b) => b.encounteredAt.localeCompare(a.encounteredAt)); + }, [] as AugurEntry[]); +} + +/** + * Entries whose reminder date has passed but `outcome` is still 'open'. + * Drives the DueBanner (M3) and the inbox card. + * + * Reminder date = `expectedBy` when set, else `encounteredAt + 30 days`. + * Logic centralised in `lib/reminders.ts` so the mana-notify pipeline + * later derives the same set without duplicating the rule. + */ +export function useDueForReveal() { + return useScopedLiveQuery(async () => { + const visible = (await visibleScoped()).filter( + (e) => !e.deletedAt && !e.isArchived && e.outcome === 'open' + ); + const decrypted = await decryptRecords('augurEntries', visible); + return decrypted + .map(toAugurEntry) + .filter((e) => isDue(e)) + .sort((a, b) => + (a.expectedBy ?? a.encounteredAt).localeCompare(b.expectedBy ?? b.encounteredAt) + ); + }, [] as AugurEntry[]); +} + +// ─── Pure Helpers ────────────────────────────────────────── + +export function searchAugurEntries(entries: AugurEntry[], query: string): AugurEntry[] { + if (!query.trim()) return entries; + const q = query.toLowerCase(); + return entries.filter((e) => { + const haystack = [e.source, e.claim, e.feltMeaning, e.expectedOutcome, e.outcomeNote] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return haystack.includes(q); + }); +} + +export function groupByKind(entries: AugurEntry[]): Record { + const groups: Record = { omen: [], fortune: [], hunch: [] }; + for (const e of entries) groups[e.kind].push(e); + return groups; +} diff --git a/apps/mana/apps/web/src/lib/modules/augur/stores/entries.svelte.ts b/apps/mana/apps/web/src/lib/modules/augur/stores/entries.svelte.ts new file mode 100644 index 000000000..6a983290d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/stores/entries.svelte.ts @@ -0,0 +1,151 @@ +import { augurEntriesTable } from '../collections'; +import { toAugurEntry } from '../queries'; +import { encryptRecord } from '$lib/data/crypto'; +import { generateUnlistedToken, type VisibilityLevel } from '@mana/shared-privacy'; +import { getEffectiveUserId } from '$lib/data/current-user'; +import type { + AugurEntry, + AugurKind, + AugurOutcome, + AugurSourceCategory, + AugurVibe, + LocalAugurEntry, +} from '../types'; + +function todayIsoDate(): string { + return new Date().toISOString().slice(0, 10); +} + +export const augurStore = { + async createEntry(data: { + kind: AugurKind; + source: string; + sourceCategory: AugurSourceCategory; + claim: string; + vibe: AugurVibe; + feltMeaning?: string | null; + expectedOutcome?: string | null; + expectedBy?: string | null; + probability?: number | null; + encounteredAt?: string; + tags?: string[]; + relatedDreamId?: string | null; + relatedDecisionId?: string | null; + livingOracleSnapshot?: string | null; + isPrivate?: boolean; + }): Promise { + const id = crypto.randomUUID(); + const newLocal: LocalAugurEntry = { + id, + kind: data.kind, + source: data.source, + sourceCategory: data.sourceCategory, + claim: data.claim, + vibe: data.vibe, + feltMeaning: data.feltMeaning ?? null, + expectedOutcome: data.expectedOutcome ?? null, + expectedBy: data.expectedBy ?? null, + probability: data.probability ?? null, + outcome: 'open', + outcomeNote: null, + resolvedAt: null, + encounteredAt: data.encounteredAt ?? todayIsoDate(), + tags: data.tags ?? [], + relatedDreamId: data.relatedDreamId ?? null, + relatedDecisionId: data.relatedDecisionId ?? null, + livingOracleSnapshot: data.livingOracleSnapshot ?? null, + isPrivate: data.isPrivate ?? true, + isArchived: false, + visibility: 'private', + }; + + const plaintextSnapshot = toAugurEntry(newLocal); + await encryptRecord('augurEntries', newLocal); + await augurEntriesTable.add(newLocal); + return plaintextSnapshot; + }, + + async updateEntry( + id: string, + data: Partial< + Pick< + LocalAugurEntry, + | 'source' + | 'sourceCategory' + | 'claim' + | 'vibe' + | 'feltMeaning' + | 'expectedOutcome' + | 'expectedBy' + | 'probability' + | 'tags' + | 'isPrivate' + | 'relatedDreamId' + | 'relatedDecisionId' + > + > + ) { + const diff: Partial = { + ...data, + updatedAt: new Date().toISOString(), + }; + await encryptRecord('augurEntries', diff); + await augurEntriesTable.update(id, diff); + }, + + async resolveEntry(id: string, outcome: AugurOutcome, note?: string | null) { + const diff: Partial = { + outcome, + outcomeNote: note ?? null, + resolvedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await encryptRecord('augurEntries', diff); + await augurEntriesTable.update(id, diff); + }, + + async archiveEntry(id: string) { + await augurEntriesTable.update(id, { + isArchived: true, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteEntry(id: string) { + await augurEntriesTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + /** + * Flip the visibility level. M6 wires the local field + token-allocation; + * the unlisted-snapshot publish/revoke pipeline (server-side blob store) + * is a follow-up — until then, 'unlisted' just allocates a local token so + * the share URL is stable when we wire the backend. + */ + async setVisibility(id: string, next: VisibilityLevel) { + const existing = await augurEntriesTable.get(id); + if (!existing) return; + + const userId = getEffectiveUserId(); + const now = new Date().toISOString(); + const diff: Partial = { + visibility: next, + visibilityChangedAt: now, + visibilityChangedBy: userId ?? undefined, + updatedAt: now, + }; + + if (next === 'unlisted') { + if (!existing.unlistedToken) diff.unlistedToken = generateUnlistedToken(); + } else { + if (existing.unlistedToken) { + diff.unlistedToken = undefined; + diff.unlistedExpiresAt = null; + } + } + + await augurEntriesTable.update(id, diff); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/augur/tools.ts b/apps/mana/apps/web/src/lib/modules/augur/tools.ts new file mode 100644 index 000000000..dc1f0f1fd --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/tools.ts @@ -0,0 +1,312 @@ +/** + * Augur tools — AI-accessible CRUD + Living-Oracle consultation. + * + * Propose: + * - capture_sign — create a new omen / fortune / hunch + * - resolve_sign — mark an open sign as fulfilled / partly / not-fulfilled + * + * Auto: + * - list_open_signs — what's still waiting on resolution + * - consult_oracle — the Living Oracle (deterministic, cold-start gated) + * - augur_year_recap — structured year-in-review snapshot + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import { augurStore } from './stores/entries.svelte'; +import { db } from '$lib/data/database'; +import { decryptRecords, VaultLockedError } from '$lib/data/crypto'; +import { toAugurEntry } from './queries'; +import { + findMatches, + fingerprint, + makeReflection, + shouldSpeak, + LIVING_ORACLE_COLD_START_MIN, + LIVING_ORACLE_MIN_MATCHES, +} from './lib/living-oracle'; +import { buildYearRecap } from './lib/year-recap'; +import type { + AugurKind, + AugurOutcome, + AugurSourceCategory, + AugurVibe, + LocalAugurEntry, +} from './types'; + +const KINDS: readonly AugurKind[] = ['omen', 'fortune', 'hunch']; +const VIBES: readonly AugurVibe[] = ['good', 'bad', 'mysterious']; +const SOURCE_CATEGORIES: readonly AugurSourceCategory[] = [ + 'gut', + 'tarot', + 'horoscope', + 'fortune-cookie', + 'iching', + 'dream', + 'person', + 'media', + 'natural', + 'other', +]; +const RESOLVE_OUTCOMES: readonly Exclude[] = [ + 'fulfilled', + 'partly', + 'not-fulfilled', +]; + +function splitList(raw: unknown): string[] | undefined { + if (typeof raw !== 'string') return undefined; + const parts = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return parts.length > 0 ? parts : undefined; +} + +async function loadAllEntries() { + const all = await db.table('augurEntries').toArray(); + const visible = all.filter((e) => !e.deletedAt); + const decrypted = await decryptRecords('augurEntries', visible); + return decrypted.map(toAugurEntry); +} + +export const augurTools: ModuleTool[] = [ + { + name: 'capture_sign', + module: 'augur', + description: 'Erfasst ein Zeichen (Omen, Wahrsagung oder Bauchgefuehl)', + parameters: [ + { name: 'kind', type: 'string', description: 'Art', required: true }, + { name: 'source', type: 'string', description: 'Quelle', required: true }, + { name: 'claim', type: 'string', description: 'Aussage', required: true }, + { name: 'sourceCategory', type: 'string', description: 'Quellenkategorie', required: false }, + { name: 'vibe', type: 'string', description: 'Stimmung', required: false }, + { name: 'feltMeaning', type: 'string', description: 'Eigene Deutung', required: false }, + { + name: 'expectedOutcome', + type: 'string', + description: 'Konkrete Prognose', + required: false, + }, + { name: 'expectedBy', type: 'string', description: 'Bis wann (YYYY-MM-DD)', required: false }, + { name: 'probability', type: 'number', description: '0..1', required: false }, + { name: 'tags', type: 'string', description: 'Tags CSV', required: false }, + ], + async execute(params) { + const kind = params.kind as AugurKind; + if (!KINDS.includes(kind)) return { success: false, message: `Unbekannte Art: ${kind}` }; + + const source = String(params.source ?? '').trim(); + if (!source) return { success: false, message: 'source darf nicht leer sein' }; + + const claim = String(params.claim ?? '').trim(); + if (!claim) return { success: false, message: 'claim darf nicht leer sein' }; + + const vibe = (params.vibe as AugurVibe | undefined) ?? 'mysterious'; + if (!VIBES.includes(vibe)) return { success: false, message: `Unbekannte Stimmung: ${vibe}` }; + + const sourceCategory = (params.sourceCategory as AugurSourceCategory | undefined) ?? 'other'; + if (!SOURCE_CATEGORIES.includes(sourceCategory)) { + return { success: false, message: `Unbekannte Quellenkategorie: ${sourceCategory}` }; + } + + const probability = typeof params.probability === 'number' ? params.probability : null; + if (probability !== null && (probability < 0 || probability > 1)) { + return { success: false, message: 'probability muss zwischen 0 und 1 liegen' }; + } + + const entry = await augurStore.createEntry({ + kind, + source, + sourceCategory, + claim, + vibe, + feltMeaning: typeof params.feltMeaning === 'string' ? params.feltMeaning : null, + expectedOutcome: typeof params.expectedOutcome === 'string' ? params.expectedOutcome : null, + expectedBy: typeof params.expectedBy === 'string' ? params.expectedBy : null, + probability, + tags: splitList(params.tags), + }); + + return { + success: true, + data: { entryId: entry.id, kind: entry.kind, source: entry.source }, + message: `${entry.kind} erfasst: "${entry.source}"`, + }; + }, + }, + { + name: 'resolve_sign', + module: 'augur', + description: 'Loest ein offenes Zeichen auf', + parameters: [ + { name: 'entryId', type: 'string', description: 'ID', required: true }, + { name: 'outcome', type: 'string', description: 'Ergebnis', required: true }, + { name: 'note', type: 'string', description: 'Optionale Notiz', required: false }, + ], + async execute(params) { + const entryId = String(params.entryId ?? ''); + const outcome = params.outcome as AugurOutcome; + if (!RESOLVE_OUTCOMES.includes(outcome as Exclude)) { + return { success: false, message: `Unbekanntes Ergebnis: ${outcome}` }; + } + const existing = await db.table('augurEntries').get(entryId); + if (!existing || existing.deletedAt) { + return { success: false, message: `Eintrag ${entryId} nicht gefunden` }; + } + await augurStore.resolveEntry( + entryId, + outcome, + typeof params.note === 'string' ? params.note : null + ); + return { + success: true, + data: { entryId, outcome }, + message: `Zeichen aufgeloest: ${outcome}`, + }; + }, + }, + { + name: 'list_open_signs', + module: 'augur', + description: 'Listet noch offene Zeichen', + parameters: [ + { name: 'kind', type: 'string', description: 'Filter nach Art', required: false }, + { name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false }, + ], + async execute(params) { + const kindFilter = params.kind as AugurKind | undefined; + if (kindFilter !== undefined && !KINDS.includes(kindFilter)) { + return { success: false, message: `Unbekannte Art: ${kindFilter}` }; + } + const limit = Math.min(Math.max(Number(params.limit) || 30, 1), 100); + try { + const entries = await loadAllEntries(); + const rows = entries + .filter((e) => !e.isArchived && e.outcome === 'open') + .filter((e) => (kindFilter ? e.kind === kindFilter : true)) + .sort((a, b) => + (a.expectedBy ?? a.encounteredAt).localeCompare(b.expectedBy ?? b.encounteredAt) + ) + .slice(0, limit) + .map((e) => ({ + id: e.id, + kind: e.kind, + source: e.source, + claim: e.claim, + vibe: e.vibe, + encounteredAt: e.encounteredAt, + expectedBy: e.expectedBy, + })); + return { + success: true, + data: { entries: rows, total: rows.length }, + message: `${rows.length} offene(s) Zeichen`, + }; + } catch (err) { + if (err instanceof VaultLockedError) { + return { + success: false, + message: 'Vault ist gesperrt — Augur kann nicht entschluesselt werden', + }; + } + throw err; + } + }, + }, + { + name: 'consult_oracle', + module: 'augur', + description: 'Befragt das Living Oracle', + parameters: [ + { name: 'kind', type: 'string', description: 'Art', required: true }, + { name: 'sourceCategory', type: 'string', description: 'Kategorie', required: true }, + { name: 'vibe', type: 'string', description: 'Stimmung', required: true }, + { name: 'source', type: 'string', description: 'Quellen-Stichwort', required: false }, + { name: 'claim', type: 'string', description: 'Aussage', required: false }, + { name: 'tags', type: 'string', description: 'Tags CSV', required: false }, + ], + async execute(params) { + const kind = params.kind as AugurKind; + if (!KINDS.includes(kind)) return { success: false, message: `Unbekannte Art: ${kind}` }; + const sourceCategory = params.sourceCategory as AugurSourceCategory; + if (!SOURCE_CATEGORIES.includes(sourceCategory)) { + return { success: false, message: `Unbekannte Quellenkategorie: ${sourceCategory}` }; + } + const vibe = params.vibe as AugurVibe; + if (!VIBES.includes(vibe)) return { success: false, message: `Unbekannte Stimmung: ${vibe}` }; + + try { + const history = await loadAllEntries(); + const fp = fingerprint({ + kind, + sourceCategory, + vibe, + tags: splitList(params.tags), + source: typeof params.source === 'string' ? params.source : null, + claim: typeof params.claim === 'string' ? params.claim : null, + }); + if (!fp) return { success: false, message: 'Fingerprint konnte nicht gebildet werden' }; + + const set = findMatches(fp, history); + const speaks = shouldSpeak(history.length, set); + const reflection = speaks ? makeReflection(set) : null; + + return { + success: true, + data: { + speaks, + reflection, + matches: set.n, + hitRate: set.hitRate, + breakdown: { + fulfilled: set.fulfilled, + partly: set.partly, + notFulfilled: set.notFulfilled, + }, + thresholds: { + coldStart: LIVING_ORACLE_COLD_START_MIN, + minMatches: LIVING_ORACLE_MIN_MATCHES, + historyTotal: history.length, + }, + }, + message: reflection ?? 'Orakel schweigt — noch zu wenig aehnliche Daten.', + }; + } catch (err) { + if (err instanceof VaultLockedError) { + return { success: false, message: 'Vault ist gesperrt' }; + } + throw err; + } + }, + }, + { + name: 'augur_year_recap', + module: 'augur', + description: 'Strukturierter Jahresrueckblick', + parameters: [ + { + name: 'year', + type: 'number', + description: 'YYYY (default: aktuelles Jahr)', + required: false, + }, + ], + async execute(params) { + const year = Number(params.year) || new Date().getFullYear(); + try { + const entries = await loadAllEntries(); + const recap = buildYearRecap(entries, year); + return { + success: true, + data: recap, + message: `Jahresrueckblick ${year}: ${recap.total} Zeichen, ${recap.resolved} aufgeloest`, + }; + } catch (err) { + if (err instanceof VaultLockedError) { + return { success: false, message: 'Vault ist gesperrt' }; + } + throw err; + } + }, + }, +]; diff --git a/apps/mana/apps/web/src/lib/modules/augur/types.ts b/apps/mana/apps/web/src/lib/modules/augur/types.ts new file mode 100644 index 000000000..082c69a5a --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/types.ts @@ -0,0 +1,133 @@ +import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; + +// ─── Enums ──────────────────────────────────────────────── + +export type AugurKind = 'omen' | 'fortune' | 'hunch'; + +export type AugurVibe = 'good' | 'bad' | 'mysterious'; + +export type AugurOutcome = 'open' | 'fulfilled' | 'partly' | 'not-fulfilled'; + +/** + * Coarse category of the source — used for "Calibration per Source" in + * the Oracle view. Free-form `source` (e.g. "Mutter", "schwarze Katze") + * stays encrypted; the category stays plaintext for the aggregation + * query path. + */ +export type AugurSourceCategory = + | 'gut' + | 'tarot' + | 'horoscope' + | 'fortune-cookie' + | 'iching' + | 'dream' + | 'person' + | 'media' + | 'natural' + | 'other'; + +// ─── Local Record Types (Dexie) ─────────────────────────── + +export interface LocalAugurEntry extends BaseRecord { + kind: AugurKind; + source: string; + sourceCategory: AugurSourceCategory; + claim: string; + vibe: AugurVibe; + feltMeaning: string | null; + expectedOutcome: string | null; + expectedBy: string | null; + probability: number | null; + outcome: AugurOutcome; + outcomeNote: string | null; + resolvedAt: string | null; + encounteredAt: string; + tags: string[]; + relatedDreamId: string | null; + relatedDecisionId: string | null; + livingOracleSnapshot: string | null; + isPrivate: boolean; + isArchived: boolean; + /** + * Visibility level — unified privacy system (docs/plans/visibility-system.md). + * Optional on the local record because M1–M5 rows pre-date the field; + * `toAugurEntry` narrows to a non-optional VisibilityLevel. Default is + * `'private'` because divinatory captures can be very personal. + */ + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; + unlistedToken?: string; + unlistedExpiresAt?: string | null; +} + +// ─── Domain Types ───────────────────────────────────────── + +export interface AugurEntry { + id: string; + kind: AugurKind; + source: string; + sourceCategory: AugurSourceCategory; + claim: string; + vibe: AugurVibe; + feltMeaning: string | null; + expectedOutcome: string | null; + expectedBy: string | null; + probability: number | null; + outcome: AugurOutcome; + outcomeNote: string | null; + resolvedAt: string | null; + encounteredAt: string; + tags: string[]; + relatedDreamId: string | null; + relatedDecisionId: string | null; + livingOracleSnapshot: string | null; + isPrivate: boolean; + isArchived: boolean; + visibility: VisibilityLevel; + unlistedToken: string; + unlistedExpiresAt: string | null; + createdAt: string; + updatedAt: string; +} + +// ─── Constants ──────────────────────────────────────────── + +export const KIND_LABELS: Record = { + omen: { de: 'Omen', en: 'Omen' }, + fortune: { de: 'Wahrsagung', en: 'Fortune' }, + hunch: { de: 'Bauchgefühl', en: 'Hunch' }, +}; + +export const VIBE_LABELS: Record = { + good: { de: 'Gutes Zeichen', en: 'Good sign' }, + bad: { de: 'Warnung', en: 'Warning' }, + mysterious: { de: 'Rätselhaft', en: 'Mysterious' }, +}; + +export const VIBE_COLORS: Record = { + good: '#22c55e', + bad: '#ef4444', + mysterious: '#8b5cf6', +}; + +export const OUTCOME_LABELS: Record = { + open: { de: 'Offen', en: 'Open' }, + fulfilled: { de: 'Eingetreten', en: 'Fulfilled' }, + partly: { de: 'Teilweise', en: 'Partly' }, + 'not-fulfilled': { de: 'Nicht eingetreten', en: 'Not fulfilled' }, +}; + +export const SOURCE_CATEGORY_LABELS: Record = { + gut: { de: 'Bauchgefühl', en: 'Gut feeling' }, + tarot: { de: 'Tarot', en: 'Tarot' }, + horoscope: { de: 'Horoskop', en: 'Horoscope' }, + 'fortune-cookie': { de: 'Glückskeks', en: 'Fortune cookie' }, + iching: { de: 'I Ging', en: 'I Ching' }, + dream: { de: 'Traum', en: 'Dream' }, + person: { de: 'Person', en: 'Person' }, + media: { de: 'Medium', en: 'Media' }, + natural: { de: 'Naturzeichen', en: 'Natural sign' }, + other: { de: 'Sonstiges', en: 'Other' }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/augur/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/augur/views/DetailView.svelte new file mode 100644 index 000000000..a0e0e448e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/views/DetailView.svelte @@ -0,0 +1,377 @@ + + + +{#if mode === 'edit'} + (mode = 'view')} /> +{:else} +
+
+
+ {KIND_LABELS[entry.kind].de} + · + {sourceCategoryLabel} + · + {entry.encounteredAt} +
+

{entry.source}

+

{entry.claim}

+
+ + + {#if entry.probability != null} + {Math.round(entry.probability * 100)}% + {/if} +
+
+ + {#if entry.feltMeaning} +
+

{T.felt}

+

{entry.feltMeaning}

+
+ {/if} + + {#if entry.expectedOutcome || entry.expectedBy} +
+

{T.expected}

+ {#if entry.expectedOutcome}

{entry.expectedOutcome}

{/if} + {#if entry.expectedBy} +

{T.expectedBy}: {entry.expectedBy}

+ {/if} +
+ {/if} + + {#if entry.tags.length > 0} +
+

{T.tags}

+
+ {#each entry.tags as tag (tag)} + {tag} + {/each} +
+
+ {/if} + + {#if entry.livingOracleSnapshot} + + {/if} + +
+

{T.visibility}

+ +
+ +
+ {#if entry.outcome === 'open' && !resolveNoteOpen} +

{T.resolvePrompt}

+
+ + + +
+ {:else if resolveNoteOpen} +

{T.outcomeNote}

+ +
+ + +
+ {:else} +

{T.resolved}

+ {#if entry.outcomeNote} +

{entry.outcomeNote}

+ {/if} + {#if entry.resolvedAt} +

{entry.resolvedAt.slice(0, 10)}

+ {/if} +
+ +
+ {/if} +
+ +
+ + + +
+
+{/if} + + diff --git a/apps/mana/apps/web/src/lib/modules/augur/views/OracleView.svelte b/apps/mana/apps/web/src/lib/modules/augur/views/OracleView.svelte new file mode 100644 index 000000000..40253bead --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/views/OracleView.svelte @@ -0,0 +1,550 @@ + + + +
+
+
+

{T.title}

+

{T.subtitle}

+
+ {T.yearRecapLink} +
+ +
+
+ {stats.total} + {T.statTotal} +
+
+ {stats.resolved} + {T.statResolved} +
+
+ {stats.open} + {T.statOpen} +
+
+ {pct(stats.hitRate)} + {T.statHitRate} +
+
+ {brier(stats.brier)} + {T.statBrier} ({stats.brierN}) +
+
+ + {#if isColdStart} +
+

{T.coldStart}

+

+ {T.coldStartHint} + {ORACLE_COLD_START_MIN} + {T.coldStartUnit} +

+
+
+
+
+ {:else} +
+
+

{T.sourceTitle}

+

{T.sourceSub}

+
+ {#if sourceRows.length === 0} +

{T.vibeNoData}

+ {:else} + + + + + + + + + + + + {#each sourceRows as row (row.sourceCategory)} + + + + + + + + {/each} + +
{T.sourceCol}{T.sourceN}{T.sourceHit}{T.sourceBrier}{T.sourceMix}
{SOURCE_CATEGORY_LABELS[row.sourceCategory].de}{row.n}{pct(row.hitRate)} 0 ? `n=${row.brierN}` : ''}> + {brier(row.brier)} + +
+ + + +
+
+ {/if} +
+ +
+
+

{T.corrTitle}

+

{T.corrSub}

+
+ {#if correlations.length === 0} +

{T.corrEmpty}

+ {:else} +
    + {#each correlations.slice(0, 6) as f (f.dimension + f.bucket + f.metric + f.windowDays)} +
  • +
    + {bucketLabel(f)} + {f.delta >= 0 ? '↑' : '↓'} + + {f.delta >= 0 ? '+' : ''}{fmt(f.delta, f.metric)}{metricUnit(f)} + + n={f.n} +
    +

    + {T.corrAfter} + {bucketLabel(f).toLowerCase()}-Zeichen {metricLabel(f)} + {fmt(f.bucketMean, f.metric)}{metricUnit(f)} + — {T.corrVsBaseline} + {fmt(f.baseline, f.metric)}{metricUnit(f)}. +

    +
  • + {/each} +
+ {/if} +
+ +
+
+

{T.vibeTitle}

+

{T.vibeSub}

+
+
+ {#each vibeRows as row (row.vibe)} +
+
{VIBE_LABELS[row.vibe].de}
+ {#if row.n === 0} +
{T.vibeNoData}
+ {:else} +
{pct(row.hitRate)}
+
{T.vibeHit} (n={row.n})
+ {#if row.directionalHitRate != null} +
{pct(row.directionalHitRate)}
+
{T.vibeDir}
+ {/if} + {/if} +
+ {/each} +
+
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/augur/views/WitnessView.svelte b/apps/mana/apps/web/src/lib/modules/augur/views/WitnessView.svelte new file mode 100644 index 000000000..a2c15cecc --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/views/WitnessView.svelte @@ -0,0 +1,221 @@ + + + +
+
+
+ + +
+ + {#if showCreate} + (showCreate = false)} /> + {/if} + + + + (activeKind = k)} /> + +
+ + +
+
+ + {#if filtered.length === 0} +

+ {entries.length === 0 ? T.emptyAll : T.emptyFiltered} +

+ {:else} +
    + {#each filtered as entry (entry.id)} +
  • + +
  • + {/each} +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/augur/views/YearRecapView.svelte b/apps/mana/apps/web/src/lib/modules/augur/views/YearRecapView.svelte new file mode 100644 index 000000000..96dbdd288 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/views/YearRecapView.svelte @@ -0,0 +1,400 @@ + + + +
+
+
{year}
+

{T.title}

+
+ + {#if recap.total === 0} +

{T.emptyYear}

+ {:else} +
+
+ {recap.total} + {T.yearTotal} +
+
+ {recap.resolved} + {T.yearResolved} +
+
+ {pct(recap.hitRate)} + {T.yearHitRate} +
+
+ +
+
+

{T.distKind}

+
    + {#each Object.entries(recap.byKind) as [k, n] (k)} +
  • + {KIND_LABELS[k as keyof typeof KIND_LABELS].de} + {n} +
  • + {/each} +
+
+
+

{T.distVibe}

+
    + {#each Object.entries(recap.byVibe) as [v, n] (v)} +
  • + + {VIBE_LABELS[v as keyof typeof VIBE_LABELS].de} + {n} +
  • + {/each} +
+
+
+

{T.distOutcome}

+
    + {#each Object.entries(recap.byOutcome) as [o, n] (o)} +
  • + {OUTCOME_LABELS[o as keyof typeof OUTCOME_LABELS].de} + {n} +
  • + {/each} +
+
+
+ +
+
+

{T.bestSource}

+ {#if recap.bestSource} +
+ {SOURCE_CATEGORY_LABELS[recap.bestSource.sourceCategory].de} +
+
{pct(recap.bestSource.hitRate)}
+
{recap.bestSource.fulfilled} {T.hitOf} {recap.bestSource.n}
+ {:else} +

{T.none}

+ {/if} +
+
+

{T.worstSource}

+ {#if recap.worstSource} +
+ {SOURCE_CATEGORY_LABELS[recap.worstSource.sourceCategory].de} +
+
{pct(recap.worstSource.hitRate)}
+
{recap.worstSource.fulfilled} {T.hitOf} {recap.worstSource.n}
+ {:else} +

{T.none}

+ {/if} +
+
+ + {#if recap.topCategories.length > 0} +
+

{T.topSources}

+
    + {#each recap.topCategories as cat (cat.category)} +
  • + {SOURCE_CATEGORY_LABELS[cat.category].de} + + {cat.n} · {pct(cat.hitRate)} +
  • + {/each} +
+
+ {/if} + + {#if recap.mostFulfilled.length > 0} +
+

{T.mostFulfilled}

+
+ {#each recap.mostFulfilled as entry (entry.id)} + + {/each} +
+
+ {/if} + + {#if recap.mostSurprising.length > 0} +
+

{T.mostSurprising}

+
+ {#each recap.mostSurprising as entry (entry.id)} + + {/each} +
+
+ {/if} + {/if} +
+ + diff --git a/apps/mana/apps/web/src/routes/(app)/augur/+page.svelte b/apps/mana/apps/web/src/routes/(app)/augur/+page.svelte new file mode 100644 index 000000000..1cc11a864 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/augur/+page.svelte @@ -0,0 +1,12 @@ + + + + Augur - Mana + + + + {}} goBack={() => history.back()} params={{}} /> + diff --git a/apps/mana/apps/web/src/routes/(app)/augur/entry/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/augur/entry/[id]/+page.svelte new file mode 100644 index 000000000..be0a3822e --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/augur/entry/[id]/+page.svelte @@ -0,0 +1,51 @@ + + + + {entry?.source ?? T.fallbackTitle} - Mana + + + + {#if entries$.loading} +

{T.loading}

+ {:else if !entry} +
+

{T.notFound}

+ {T.backLink} +
+ {:else} + + {/if} +
+ + diff --git a/apps/mana/apps/web/src/routes/(app)/augur/recap/[year]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/augur/recap/[year]/+page.svelte new file mode 100644 index 000000000..db5f543ce --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/augur/recap/[year]/+page.svelte @@ -0,0 +1,48 @@ + + + + {year ?? T.title} - Augur - Mana + + + + {#if year == null} +
+

{T.invalid}

+ {T.back} +
+ {:else} + + {/if} +
+ + diff --git a/docs/future/MODULE_IDEAS.md b/docs/future/MODULE_IDEAS.md index 08a8ea1ec..729b9f230 100644 --- a/docs/future/MODULE_IDEAS.md +++ b/docs/future/MODULE_IDEAS.md @@ -15,6 +15,138 @@ recommendation. --- +## 2026-04-25 Brainstorm — Mana-spezifische Ideen + +Ideen, die bewusst auf das einzigartige Stack-Profil von Mana setzen: +Personas, MCP-Tools, Spaces, Visibility/Embed, Local-First, Verschlüsselung. +Keiner dieser Vorschläge existiert in den 82 aktuellen Modulen oder weiter +unten in dieser Datei. + +### KI-native (Personas, Missions, MCP) + +- **debate** — Zwei Personas argumentieren live einen Streitpunkt aus deinem Prompt. Du votest pro Runde; Output = strukturierte Pro/Contra-Liste. +- **rubberduck** — Sprich-laut-denken: Mic an → STT → AI strukturiert "Du hast erst X gesagt, dann Y, Kerngedanke = Z". Verknüpft mit `notes`/`decisions`. +- **dialogues** — Schwierige Gespräche üben (Gehaltsverhandlung, Trennung, Eltern, Bewerbungsinterview). Persona spielt Gegenüber + Feedback zu Tonfall/Struktur. +- **scribe** — Live-Notetaker für In-Person-Meetings: Mic offen, transcribed + strukturiert in Echtzeit (Action-Items → `todo`, Zitate → `quotes`). +- **pitch** — 30-Sek-Pitch aufnehmen, AI bewertet Hook/Klarheit/Lieferung. Versionsverlauf. +- **clones** *(ZK)* — Persona, trainiert auf Gespräche/Texte einer realen Person (du selbst, Freund, verstorbener Großvater). Chat & Briefe — heikel, klar als Roleplay markiert. +- **ai-pets** — Persistenter AI-Charakter (Tamagotchi-Logik): füttern, sprechen, wächst über Wochen. Kinder-Modul-Kandidat. +- **prompts** — Prompt-Bibliothek mit Variablen, Versionen, eingefangenen Outputs. Mana-Twist: gespeicherte Prompts werden automatisch zu MCP-Tools. + +### Zeit, Erinnerung, Identität + +- **eras** — Selbst-betitelte Lebensabschnitte ("Burnout-Jahr 2024", "Berlin-Phase"). Aggregiert *alles* aus dem Zeitraum (Fotos, Journal, Mood, Todos) zu einer Wikipedia-artigen Seite — AI-generiert, kuratierbar. +- **threads** — Mehrjährige Themen-Threads (Beziehung zur Schwester, dieses Side-Projekt, diese Angst). Tagged Einträge, AI fasst Bogen zusammen. +- **lasts** — Gegenstück zu `firsts`: das *letzte* Mal, dass du X getan/gesehen/gefühlt hast. Oft erst rückwirkend erkennbar — push notification "vor X Jahren das letzte Mal …". +- **legacy** *(ZK)* — Was du hinterlassen willst: digitales Testament, Briefe an Hinterbliebene, Memorial-Botschaften (zeitgesperrt freischaltbar — wie `letters` aber outward). +- **sealed** — Vorhersagen verschlossen ablegen, automatisches Reveal am Datum X. Kalibrierungs-Tracking (Brier-Score) — persönliche Tetlock-Statistik. +- **regret / forgive** *(ZK)* — Bedauern / Vergeben; CBT-light Workflow: erfassen → reframen → loslassen-markieren. + +### Sensorik & Welt + +- **sounds** — Field-Recordings-Bibliothek (Regen in Tokyo, Vogelchor auf Wanderung). Geo+Zeit-getaggt; Spotlight für `flashbacks`. +- **scents** — Parfums, Kerzen, Räucherstäbchen — was du wann getragen hast. "Geruchsgedächtnis"-Notizen. +- **tastes** — Verkostungs-Notizen (Wein, Whisky, Spezialitätenkaffee, Tee). Vivino-artig aber generisch und verschlüsselt. +- **palette** — Farben, die du an einem Tag siehst — Foto, AI extrahiert Hauptfarben → Jahres-Farbgeschichte. +- **light** — Tageslicht-Exposition (manuell oder Wetter-API). Korreliert mit `moodlit`/`sleep`. + +### Bewegung & Orte (jenseits von `places`) + +- **routes** — Lauf/Rad/Wander-Routen, die du gemacht hast. Map-View, Wiederholungs-Counter. +- **hikes** — Wander-Log: Distanz, Höhenmeter, Gipfel, Foto-Sammlung. Reused `places` + `photos`. +- **borders** — Länder/Grenzen, die du überschritten hast. Visa, Stempel-Foto, Erinnerungen pro Übergang. +- **landmarks** — *Persönliche* Landmarks: wo du verlobt warst, erstes Date-Café, wo du den Anruf bekamst. Geo-pinned, oft (ZK). + +### Selbsterkenntnis & Muster + +- **triggers** — Was dich getriggert hat (Wut/Angst/Scham). AI-Mustererkennung über Wochen. +- **rules** — Persönliche Operating-Rules ("Kein Handy vor Kaffee", "Sonntags kein Slack"). Adhärenz-Tracking, schlägt Edits vor wenn dauerhaft gebrochen. +- **anti-todos** — Was du *bewusst nicht* tust und warum. Mindestens so wertvoll wie eine Todo-Liste. +- **delegations** — Was du an wen delegiert hast — privat *und* beruflich. Auto-Follow-up. +- **ifsthen** — Implementation Intentions ("Wenn Mittwoch 20h, dann Klettern"). Spätere Auswertung: welche Pläne hielten? +- **superpowers** — Konkrete Stärken mit echten Beispielen. AI hilft Muster zu finden ("du wirst oft als 'klar' beschrieben"). +- **cravings** — Triebmomente erfassen (Junkfood, Scrollen, Rauchen). Pattern + Redirect-Vorschlag. + +### Geld erweitert + +- **donations** — Spenden-Log mit Steuerexport. +- **patrons** — Creator, die du unterstützt (Patreon, Substack, GitHub-Sponsors). Budget-Sicht, Renewal-Daten. +- **negotiations** — Was du verhandelt hast: Ask vs. Result. Übungs-Datenbank für nächste Runde. +- **freebies** — Was du geschenkt/gratis bekommen hast. Erstaunlich motivierend; gut für Steuer wenn beruflich. + +### Kreativ & Werk + +- **drafts** — Universaler Entwurfs-Inbox (Texte, Mails, Posts, Tweets). Kein Modul-Zwang; AI schlägt Ziel-Modul vor. +- **publishings** — Alles, was du veröffentlicht hast (Blog, Tweet, Vortrag, Podcast). Wo, wann, Reaktionen — eine Karriere-Timeline. +- **shows** — Konzerte, Ausstellungen, Filme, Theater die du besucht hast. Tickets-Archiv (Foto), Begleitung, Gedanken danach. +- **tickets** — Stub-Sammlung: Konzert/Sport/Kino. Foto + OCR + Erinnerung. Stark als Embed (Visibility). +- **portfolios** — Public-facing kuratierte Werk-Sammlung (zieht aus `picture`, `writing`, `comic`, `presi`). Visibility-System pur. + +### Affirmation & Mentalmodelle + +- **mantras** — Persönliche Mantras + Frequenz-Tracking ("dieses Mantra benutze ich tatsächlich"). +- **lessons** — Lebenslektionen. Tagged nach Domäne, jährliche Review. +- **wins** — Mikro-Wins täglich (kleiner als `goals`, kein Streak-Druck). +- **fears** — Furcht-Inventar mit Status (aktiv/abgeschlossen/transformiert). +- **losses** *(ZK)* — Trauer-Journal pro Person/Sache. Anniversary-Reminder, AI-Begleitung optional. + +### Bürgerlich / Welt + +- **votes** — Wahl-Historie + Wahlzettel-Recherche-Notizen + lokale Repräsentanten. +- **causes** — Themen, die dir wichtig sind. Aktionen (Demo, Spende, Brief), Updates pro Cause. +- **rights** — Mieter-/Arbeitsrechte als Situations-Checkliste ("Vermieter sagt X — was sind meine Rechte?"). MCP-Tool wäre sinnvoll. + +### Häuslich (Detail) + +- **moves** — Alle Umzüge: was verschwand, was du wegspendetest, was blieb. Ergänzt `inventory`. +- **roomies** — WG-Mitbewohner-Log; Konflikte/Vereinbarungen. +- **handymen** — Handwerker, Ärzte, Service-Provider mit echten Bewertungen. Privater "lokaler Yelp". +- **insurance** — Policies, Schäden, Beitragshistorie. Synergie mit `documents`. + +### Sozial fein granular + +- **handshakes** — Bemerkenswerte Menschen, die du getroffen hast. Eine-Zeile-Erinnerung pro Person. +- **mentors / mentees** — Wer dir half / wem du halfst. Konkrete Momente. +- **rolemodels** — Public Figures, von denen du lernst. Was genau, und warum. +- **names** — Wie spricht/schreibt man Namen? Eselsbrücken pro Person. + +### Verspielt + +- **bets** — Wetten mit Freunden. Multi-Member Space als Wettregister; wer hatte recht? +- **wagers** — Selbst-Wetten an Goals geknüpft ("Wenn ich Marathon nicht laufe → 200€ Spende"). +- **prophecies** — Vorhersagen, die du *öffentlich* gemacht hast (Tweets, Diskussionen). Realitäts-Check Quartal. +- **fortunes** — Glückskekse, Horoskope, Tarot. Realitäts-Abgleich — Pseudo-Weisheit-Inventur. + +### Notfall & Sorge + +- **emergency** *(ZK)* — Notfall-Kontakte, Allergien, Blutgruppe, Ärzte. Schnellzugriff am Sperrbildschirm wäre Killer. +- **caregiving** *(ZK)* — Pflege für Eltern: Medikamente, Termine, Episoden. Mehrere Familienmitglieder via Spaces. +- **proxy** *(ZK)* — Vorsorgevollmacht, Patientenverfügung, digitale Erbschaft. + +### Pro-Tooling + +- **tools** — Werkzeuge-Inventar (Holz, Code, Küche). Was hast du womit gebaut? +- **rigs** — Compute-Setups über Zeit (welche Maschine, welche dotfiles, was hast du damit gebaut). Nostalgie + Migration. +- **commands** — CLI-Commands die du *wirklich* benutzt. Aliase mit Kontext. + +### Phänomenologisch (mutig) + +- **synchronicities** — Zufälle/Synchronizitäten erfassen. AI sucht Muster — wahrscheinlich keine, aber spannend. +- **dejavu** — Déjà-vu-Episoden mit Auslöser. Häufungs-Heatmap. +- **omens** — Was hast du als Zeichen genommen? Was passierte tatsächlich? Aberglaubens-Auditor. + +### Top-7 zum Bauen (höchster Hebel auf bestehende Architektur) + +1. **scribe** — riesiger Wert, perfekter Fit für mana-stt + Personas +2. **eras** — emotional starke Killer-Feature, zieht aus *allen* Modulen +3. **lasts** — billig zu bauen, einzigartiges Gefühl (existiert nirgends) +4. **rubberduck** — STT + AI Reflection, organisch zu `decisions`/`notes` +5. **emergency** *(ZK)* — echtes Lebens-Utility, schwacher Markt +6. **sealed** — eingebaute Kalibrierung, gamified Selbsterkenntnis +7. **portfolios** — testet Visibility-System unter Last, 0 neue Datentabellen + +--- + ## Current modules (for reference) **Productivity:** todo, calendar, contacts, notes, habits, times, timeblocks, events diff --git a/docs/plans/augur-module.md b/docs/plans/augur-module.md new file mode 100644 index 000000000..4dc638cdf --- /dev/null +++ b/docs/plans/augur-module.md @@ -0,0 +1,376 @@ +# Augur — Module Plan + +## Status (2026-04-25) + +**Konzept-Phase.** Modul noch nicht begonnen. Plan beschreibt das Zielprodukt; +M1 ist klar definiert und sofort baubar. + +--- + +## Ziel + +Ein Modul, das **Zeichen** sammelt — und sie sowohl *poetisch erlebbar* als +auch *empirisch auswertbar* macht. Drei vorher als getrennt gedachte +Brainstorm-Module (`omens`, `prophecies`, `fortunes`) verschmelzen zu einer +einzigen Praxis: *du erfasst Zeichen wie ein magischer Realist und liest sie +zurück wie ein Empiriker.* + +Kernfrage des Nutzers: *"Habe ich diesem Bauchgefühl/Traum/Glückskeks zu Recht +geglaubt?"* + +Killer-Feature: Sobald genug Daten gesammelt sind, werden **deine eigenen +empirisch entdeckten Muster** zum Orakel für neue Zeichen ("Living Oracle"). +Die Magie wird nicht behauptet — sie materialisiert sich aus deinen Daten. +Niemand außer Mana kann das, weil niemand sonst alle Module zusammen sieht. + +## Abgrenzung + +- **Kein `dreams`**: Träume bleiben dort. `augur` referenziert höchstens + einen Traum-Eintrag (`relatedDreamId`), wenn der Nutzer ein Traum-Symbol + als Omen werten will. Kein Daten-Duplikat. +- **Kein `journal`**: Journal ist freitext-getrieben. `augur` ist + strukturiert um den Lebenszyklus *Zeichen → Erwartung → Outcome*. +- **Kein `decisions`**: Decisions speichert *Entscheidungen* mit Annahmen + und Reviews; `augur` speichert *Eingebungen, Vorzeichen, Wahrsagungen* — + oft ohne dass eine Entscheidung anhängt. +- **Cross-Link statt Merge**: `augur` liest aktiv aus anderen Modulen + (`mood`, `sleep`, `body`, `decisions`, `dreams`) für die + Korrelations-Engine. Schreibt nirgendwo zurück. + +## Entscheidung: ein Modul, drei Geschmacksrichtungen + +Ein Modul `augur` mit Diskriminator `kind`: + +- `omen` — externes Zeichen (schwarze Katze, doppelte Regenbögen, Vogel im Fenster) +- `fortune` — gelesene/gewürfelte Aussage (Glückskeks, Tarot, Horoskop, I-Ching) +- `hunch` — eigenes Bauchgefühl, eigene Vorhersage + +Geteiltes Kern-Schema; ein Modul, eine Tabelle, zwei UI-Modi +(*Witness* + *Oracle*). + +Begründung wie bei `library`: ein Sync-Endpoint, eine Encryption-Registry-Zeile, +eine Route, ein Settings-Panel. Cross-Auswertungen (Jahresrückblick über +alle Quellen, Calibration-per-Source) fallen gratis ab. + +## Die zwei Modi + +### Witness-Modus (Erfassen + Erleben) +Niedrigschwellige, poetische UI. Vibe-getrieben, kein +Wahrscheinlichkeit-eintragen-Zwang. Einträge erscheinen als +vibe-colored Karten in einer Galerie. Year-Recap als AI-erzählte Geschichte. + +Default-Surface des Moduls — was der Nutzer sieht, wenn er auf +`/augur` landet. + +### Oracle-Modus (Auswerten + Lernen) +Tab/Toggle "Oracle". Zeigt drei Auswertungs-Layer auf demselben Datenset: + +1. **Calibration per Source** — Brier-Score / Hit-Rate pro Quelle + ("Bauchgefühl: 67%. Tarot: 51%. Mutter: 73%.") +2. **Correlation Matrix** — Cross-Module Mining: gegebenenfalls signifikante + Korrelationen zwischen `kind`/Vibe/Tags eines Eintrags und Folgewerten + in `mood`, `sleep`, `body`. Disclaimer "Korrelation, nicht Kausalität" + prominent. +3. **Vibe-Hit-Rate** — Stimmen Vibes (good/bad) mit objektiven Folge-Daten + überein? + +### Living Oracle (das Killer-Feature) +Bei der Erfassung eines neuen Zeichens prüft Mana **deine eigene Historie** +und gibt — wo statistisch belastbar — eine Living-Oracle-Reflektion aus: + +> *Du hast 4× zuvor einen Wassertraum protokolliert. Danach im Schnitt: +> 38min weniger Schlaf, leicht angespannte Stimmung am Folgetag. Vielleicht +> heute keine schweren Termine?* + +Das ist Empirismus mit dem Mantel der Wahrsagerei. Implementation: +deterministisch, kein LLM-Halluzinations-Risiko (LLM nur für Phrasing, +nicht für Inferenz). + +## Modul-Struktur + +``` +apps/mana/apps/web/src/lib/modules/augur/ +├── types.ts # LocalAugurEntry, AugurKind, Vibe, Outcome +├── collections.ts # augurEntries-Table + Guest-Seed (1 Omen, 1 Fortune, 1 Hunch) +├── queries.ts # useAllEntries, useEntriesByKind, useUnresolved, useDueForReveal, useStats +├── stores/ +│ └── entries.svelte.ts # createEntry, updateEntry, resolveEntry, archiveEntry +├── components/ +│ ├── EntryCard.svelte # vibe-colored Karte (Galerie-Item) +│ ├── EntryForm.svelte # Capture-UI (eine Form, Felder ändern sich pro kind) +│ ├── VibeBadge.svelte # good/bad/mysterious +│ ├── OutcomeBadge.svelte # fulfilled / partly / not / open +│ ├── ResolveDialog.svelte # "Hat sich das bewahrheitet?" – kommt per PN/Inbox +│ ├── LivingOracleHint.svelte # die deterministische Reflektion bei Capture +│ └── KindTabs.svelte # Alle | Omen | Fortune | Hunch +├── views/ +│ ├── WitnessView.svelte # Default-Surface — vibe-Galerie +│ ├── OracleView.svelte # Calibration + Correlation + Vibe-Hit-Rate +│ ├── DetailView.svelte # Einzelansicht inkl. Reflektion + Resolve-Action +│ └── YearRecapView.svelte # AI-erzählter Jahresrückblick +├── lib/ +│ ├── correlation-engine.ts # Cross-Module-Korrelations-Berechnung +│ ├── calibration.ts # Brier-Score, Hit-Rate pro Source +│ └── living-oracle.ts # Match neue Eingabe gegen Historie + Folge-Daten +├── tools.ts # MCP-Tools (M5) +├── constants.ts # KIND_LABELS, VIBE_LABELS, DEFAULT_SOURCES +├── ListView.svelte # Modul-Root — switched zwischen WitnessView und OracleView +├── module.config.ts # { appId: 'augur', tables: [{ name: 'augurEntries' }] } +└── index.ts # Re-Exports +``` + +## Daten-Schema + +### `LocalAugurEntry` (Dexie) + +```typescript +export type AugurKind = 'omen' | 'fortune' | 'hunch'; + +export type AugurVibe = 'good' | 'bad' | 'mysterious'; + +export type AugurOutcome = 'fulfilled' | 'partly' | 'not-fulfilled' | 'open'; + +export interface LocalAugurEntry extends BaseRecord { + kind: AugurKind; // plaintext — Discriminator, filterbar + source: string; // encrypted — "schwarze Katze", "Glückskeks", "Bauchgefühl", "Mutter" + sourceCategory?: string | null; // plaintext — "tarot" | "horoscope" | "fortune-cookie" | "gut" | "person" | "media" | "natural" | ... — für Calibration-per-Source + claim: string; // encrypted — was das Zeichen zu sagen schien + vibe: AugurVibe; // plaintext — primärer Filter in WitnessView + feltMeaning?: string | null; // encrypted — "soll ich den Job nicht annehmen" + expectedOutcome?: string | null; // encrypted — konkrete Prognose, falls erfasst + expectedBy?: string | null; // plaintext ISO-Datum — triggert Resolve-Reminder + probability?: number | null; // plaintext — 0..1, optional (nur power-user) + outcome: AugurOutcome; // plaintext — startet 'open', kommt bei Resolve + outcomeNote?: string | null; // encrypted — wie genau ist es gekommen + resolvedAt?: string | null; // plaintext + encounteredAt: string; // plaintext ISO-Datum — wann das Zeichen kam + tags: string[]; // encrypted + relatedDreamId?: string | null; // plaintext — Cross-Link in dreams-Modul + relatedDecisionId?: string | null; // plaintext — Cross-Link in decisions (falls existiert) + livingOracleSnapshot?: string | null; // encrypted — die Reflektion zur Erfass-Zeit (für Audit) + isPrivate: boolean; // plaintext — von ZK-Default abweichen erlaubt +} +``` + +### Verschlüsselung + +Standardmäßig **encrypted** (siehe `apps/mana/apps/web/src/lib/data/crypto/registry.ts`): +`source`, `claim`, `feltMeaning`, `expectedOutcome`, `outcomeNote`, `tags`, +`livingOracleSnapshot`. Plaintext bleibt nur, was für Filter / Korrelation / +Reminder-Scheduling nötig ist (`kind`, `vibe`, `outcome`, `sourceCategory`, +Daten, IDs). + +Visibility-Default (vgl. `@mana/shared-privacy`): **`private`**. Embed-fähig +nur über explizite Hochsetzung pro Eintrag. Begründung: Auch ein "harmloses" +Bauchgefühl kann sehr persönlich sein; keine Default-Sichtbarkeit nach außen. + +## Living Oracle — Algorithmus + +Beim `createEntry` (oder synchron im Background-Tick mit Render-Hint): + +1. Fingerprint des neuen Eintrags bilden: + `{ kind, sourceCategory, vibe, tagSet, derived: keywords-from-claim }` +2. Suche in eigener Historie (`augurEntries`) nach Einträgen mit + ≥2 übereinstimmenden Fingerprint-Komponenten **und** `outcome != 'open'`. +3. Für jeden Treffer: lies aus `mood`, `sleep`, `body` die Werte des + Folge-Tags (`encounteredAt + 1d` bis `+3d`). +4. Mittelwert + Stichprobengröße + einseitiger Vorzeichentest (oder + Bootstrap-CI). Schwelle: `n ≥ 3` und Effekt klar (Δmood ≥ 0.5σ ODER + Δsleep ≥ 20min) → Living-Oracle-Hint zeigen. +5. LLM (lokal über `@mana/local-llm` oder mana-llm) übernimmt **nur** + das Phrasing — Eingabe ist die strukturierte Statistik, nicht die + Rohdaten. Kein Halluzinations-Risiko bei numerischen Werten. + +Auditierbarkeit: `livingOracleSnapshot` speichert die Reflektion zum +Erfass-Zeitpunkt (verschlüsselt). Bei Resolve sieht der Nutzer +"das Orakel sagte damals X — und es trat Y ein". + +Cold-Start: vor 50 Einträgen wird kein Living-Oracle-Hint gezeigt. +Vorher Empty-State im OracleView: *"Noch zu früh — sammle erst Zeichen."* + +## Routing + +``` +/augur → WitnessView (Default) +/augur?mode=oracle → OracleView +/augur/entry/[id] → DetailView +/augur/recap/[year] → YearRecapView +``` + +Keine separaten Routen pro `kind` — Filter via `KindTabs` in der View. + +## Inbox-Integration + +Reminder-Strategie für Resolve: + +- Bei `expectedBy` gesetzt → Reminder am Folgetag der Deadline +- Bei `expectedBy` nicht gesetzt → Default-Reminder nach 30 Tagen +- Reminder läuft über `myday`/Inbox-Mechanik (nicht eigener Pusher), + Action öffnet `ResolveDialog` + +## Cross-Modul-Hooks + +- **`dreams`**: in DreamDetailView ein "→ als Omen markieren"-Button, der + `augurEntries` mit `relatedDreamId` befüllt +- **`decisions`** (falls gebaut): "Hattest du ein Bauchgefühl?"-Quick-Add + öffnet `augur` Capture mit `kind=hunch` + `relatedDecisionId` +- **`flashbacks`** (falls gebaut): augur-Einträge erscheinen im "Vor X + Jahren"-Stream wie andere Module + +## AI-Integration (M5) + +MCP-Tools in `tools.ts` (Pattern wie in `comic`/`writing`): + +- `augur.captureSign({ kind, source, claim, vibe, ... })` — schneller + Capture für Personas / Voice-Bot +- `augur.consultOracle({ contextHint? })` — gibt eine empirisch fundierte + Reflektion (Living-Oracle-Logik) zurück, optional mit Kontext-Hint +- `augur.resolveEntry({ entryId, outcome, note })` +- `augur.yearRecap({ year })` — strukturierter Year-Recap (Quelle für die + AI-erzählte View) + +Persona-Kandidat: **"Die Augurin"** als Mana-Persona — neutral-skeptische +Beraterin, die Living-Oracle-Hints in poetische Sprache übersetzt. Optional +in `M5`. + +## Visibility & Embed + +Visibility-System voll integriert (vgl. `@mana/shared-privacy`): + +- Default `private` +- Pro Eintrag hochsetzbar bis `unlisted` +- Embed-Quelle "Augur" für Website-Builder: Galerie der "good"-vibe Omen + mit Outcome `fulfilled`, anonymisiert auf Wunsch + +Erst in **M6** — Visibility-System ist Modul-für-Modul Rollout (siehe +Memory-Eintrag *Visibility-System — M1–M5.c shipped*). + +## Encryption-Registry + +Eintrag in `apps/mana/apps/web/src/lib/data/crypto/registry.ts`: + +```typescript +augurEntries: { + encryptedFields: ['source', 'claim', 'feltMeaning', 'expectedOutcome', + 'outcomeNote', 'tags', 'livingOracleSnapshot'], + plaintextFields: ['kind', 'vibe', 'outcome', 'sourceCategory', + 'encounteredAt', 'expectedBy', 'resolvedAt', + 'probability', 'relatedDreamId', 'relatedDecisionId', + 'isPrivate'], +} +``` + +## Meilensteine + +### M1 — Skelett (1 Tag) +- `module.config.ts` + Registry-Eintrag in `module-registry.ts` +- Dexie-Schema-Bump in `database.ts` (neue Tabelle `augurEntries`) +- Encryption-Registry: Felder eintragen (verpflichtend bei Sensible-Defaults) +- Route `apps/mana/apps/web/src/routes/(app)/augur/` mit Platzhalter +- App-Eintrag in `packages/shared-branding/src/mana-apps.ts` (Icon, Tier, + Branding) +- Guest-Seed: 3 Beispiel-Einträge (1 Omen, 1 Fortune, 1 Hunch — alle mit + Outcome um sofort etwas in der Galerie zu zeigen) +- soft-first Migration (vgl. Feedback-Memory): erst lesen-tolerant, dann + Hard-Pass + +### M2 — Capture + Galerie (Witness-Modus, 2 Tage) +- `EntryForm.svelte` mit kind-Tabs in der Form (Felder ändern sich) +- `EntryCard.svelte` + `WitnessView.svelte` als Galerie +- `KindTabs.svelte` für Filter +- `DetailView.svelte` mit Resolve-Action +- `ResolveDialog.svelte` (kann auch direkt im Detail laufen) +- Stores: `createEntry`, `updateEntry`, `resolveEntry`, `archiveEntry` + +### M3 — Resolve-Reminder (1 Tag) +- Inbox-Integration: bei `expectedBy` Resolve-Reminder erzeugen, Default + 30-Tage-Fallback +- Query `useDueForReveal` für eine "fällig"-Liste in der View +- Push-Notification optional (über bestehende mana-notify-Pipeline) + +### M4 — Oracle-Modus (3 Tage) +- `OracleView.svelte` mit drei Sektionen: + - **Calibration per Source** — Brier-Score / Hit-Rate Tabelle + - **Correlation Matrix** — Cross-Module-Engine in `correlation-engine.ts` + - **Vibe-Hit-Rate** — Mood/Sleep-Folge-Daten vs. Vibe +- `lib/calibration.ts` mit deterministischer Brier-Berechnung +- Cold-Start-Empty-State unter 20 resolvten Einträgen + +### M4.5 — Living Oracle (2 Tage) +- `lib/living-oracle.ts` — Fingerprint + Historien-Match + Stat-Test +- `LivingOracleHint.svelte` — UI-Block in `EntryForm` after-create +- `livingOracleSnapshot` befüllen + bei Resolve neben Outcome anzeigen +- Cold-Start unter 50 Einträgen → Hint deaktiviert +- LLM-Phrasing optional: ohne LLM nüchterner Fakten-Block, mit LLM + poetischere Formulierung; beides funktioniert + +### M5 — MCP-Tools + Persona (1 Tag) +- `tools.ts` mit `captureSign`, `consultOracle`, `resolveEntry`, + `yearRecap` +- AI-Tool-Catalog Bridge (vgl. comic-Modul-Pattern) +- Optional: "Die Augurin" Persona-Definition als Seed + +### M6 — Year-Recap + Visibility (2 Tage) +- `YearRecapView.svelte` — AI-erzählter Jahresrückblick mit + Living-Oracle-Highlights +- Visibility-Felder in Form + Galerie-Filter +- Embed-Quelle für Website-Builder + +### M7 (optional) — Cross-Modul-Hooks +- "→ als Omen markieren" in `dreams` +- Bauchgefühl-Quick-Add in `decisions` +- Aufnahme in `flashbacks`-Stream + +### M8 (optional) — Voice-First Capture +- Voice-Bot-Integration: "Hey Mana, ich hab ein Bauchgefühl, dass…" → + STT → `augur.captureSign` über MCP +- Niedrige Capture-Hürde wäre exakt das, was für Hunches fehlt + +## Testing + +- Unit-Tests für `calibration.ts` (Brier-Score-Korrektheit) +- Unit-Tests für `correlation-engine.ts` mit Fixtures aus + `mood`/`sleep`-Mock-Daten +- Unit-Tests für `living-oracle.ts` Fingerprint-Match-Logik +- Vitest mit Mock-Factories aus `.claude/guidelines/testing.md` +- Snapshot-Test für Year-Recap-JSON-Struktur (vor LLM-Phrasing) + +## Risiken & offene Fragen + +- **Kalibrierungs-Datenmenge**: bis 50 Einträge ist OracleView fast leer. + Onboarding muss klar machen *"erst sammeln, dann auswerten"*. +- **Subjektivität von Outcome**: "ist das Bauchgefühl eingetreten?" ist + selten klar Ja/Nein. `partly` als first-class citizen ist Pflicht; + `outcomeNote` für Nuance. +- **LLM-Halluzinations-Risiko bei Living Oracle**: durch deterministische + Stat-Computation + LLM-only-for-phrasing entschärft, aber muss in + Tests verteidigt werden (LLM darf keine Zahlen ändern). +- **Esoterik-Wahrnehmung**: das Modul ist *empirisch*, aber das Branding + schreit "Wahrsagerei". Tonalität in Texten muss klar "wir messen, wir + spekulieren nicht" kommunizieren. UI darf trotzdem schön sein. +- **Cross-Modul-Korrelationen brauchen Plaintext-Felder dort**: `mood` + speichert Mood-Werte plaintext (verifizieren), `sleep` Schlafdauer + plaintext. Falls verschlüsselt, muss korrelation-engine durch + decryption-Layer — Aufwand verdoppelt sich. + +## Naming-Begründung + +`augur` (lat. *augurium*, "Vogel-Beobachtung") ist der römische Priester, +der Zeichen liest. Ein-Wort-Name, neutraler Ton (weder zu woo noch zu +trocken), passt zur Modul-Konvention (`firsts`, `dreams`, `quotes`), +und der Nutzer wird zum Augur seines eigenen Lebens — was die +Self-Empowerment-Botschaft ist. + +Alternativen erwogen: `signs` (zu generisch, Konflikt mit UI-Konzepten), +`omens` (deckt nur 1/3 der Inhalte ab), `oracle` (klingt zu sehr nach +externer Wahrsagerei). + +## Nicht im Scope + +- Externe Tarot-Decks / Horoskop-APIs für Auto-Captures (kann später als + `mana-research` Provider hinzukommen) +- Aggregierte/anonymisierte Auswertungen über Nutzer hinweg ("im Schnitt + haben unsere Nutzer X% Bauchgefühl-Trefferquote") — Datenschutz-Risiko, + Mana ist private-by-default +- Spirituelle/religiöse Inhalte (Gebet, Meditation): bleiben in `meditate` +- Predictions-Markets / monetäre Wetten: bleiben im (geplanten) `bets` diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts index 328d9edfe..2338218df 100644 --- a/packages/shared-ai/src/tools/schemas.ts +++ b/packages/shared-ai/src/tools/schemas.ts @@ -1987,6 +1987,210 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ }, ], }, + + // ── Augur (signs / fortunes / hunches) ────────────────────── + { + name: 'capture_sign', + module: 'augur', + description: + 'Erfasst ein Zeichen (Omen, Wahrsagung oder Bauchgefuehl) im Augur-Modul. Standardmaessig Stimmung "mysterious" wenn nicht angegeben. Gibt die ID zurueck.', + defaultPolicy: 'propose', + parameters: [ + { + name: 'kind', + type: 'string', + description: 'Art des Zeichens', + required: true, + enum: ['omen', 'fortune', 'hunch'], + }, + { + name: 'source', + type: 'string', + description: 'Quelle (z.B. "schwarze Katze", "Glueckskeks", "Bauchgefuehl")', + required: true, + }, + { + name: 'claim', + type: 'string', + description: 'Was das Zeichen aussagt', + required: true, + }, + { + name: 'sourceCategory', + type: 'string', + description: 'Quellenkategorie', + required: false, + enum: [ + 'gut', + 'tarot', + 'horoscope', + 'fortune-cookie', + 'iching', + 'dream', + 'person', + 'media', + 'natural', + 'other', + ], + }, + { + name: 'vibe', + type: 'string', + description: 'Grundstimmung des Zeichens', + required: false, + enum: ['good', 'bad', 'mysterious'], + }, + { + name: 'feltMeaning', + type: 'string', + description: 'Eigene Deutung (optional)', + required: false, + }, + { + name: 'expectedOutcome', + type: 'string', + description: 'Konkrete Prognose (optional)', + required: false, + }, + { + name: 'expectedBy', + type: 'string', + description: 'Bis wann sollte sich zeigen ob es eintritt (YYYY-MM-DD)', + required: false, + }, + { + name: 'probability', + type: 'number', + description: 'Wahrscheinlichkeit 0..1 (optional)', + required: false, + }, + { + name: 'tags', + type: 'string', + description: 'Tags durch Komma getrennt', + required: false, + }, + ], + }, + { + name: 'resolve_sign', + module: 'augur', + description: + 'Loest ein offenes Zeichen auf — markiert ob es eingetreten ist (fulfilled / partly / not-fulfilled) und kann eine Notiz speichern.', + defaultPolicy: 'propose', + parameters: [ + { name: 'entryId', type: 'string', description: 'ID des Zeichens', required: true }, + { + name: 'outcome', + type: 'string', + description: 'Ergebnis', + required: true, + enum: ['fulfilled', 'partly', 'not-fulfilled'], + }, + { + name: 'note', + type: 'string', + description: 'Optionale Notiz wie es kam', + required: false, + }, + ], + }, + { + name: 'list_open_signs', + module: 'augur', + description: + 'Listet noch offene Zeichen — id, kind, source, claim, encounteredAt, expectedBy. Optional gefiltert nach kind.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'kind', + type: 'string', + description: 'Nur eine Art zeigen', + required: false, + enum: ['omen', 'fortune', 'hunch'], + }, + { + name: 'limit', + type: 'number', + description: 'Maximale Anzahl (Standard 30)', + required: false, + }, + ], + }, + { + name: 'consult_oracle', + module: 'augur', + description: + 'Befragt das Living Oracle: nimmt eine Sign-Beschreibung und gibt zurueck was bei aehnlichen Zeichen in der Vergangenheit geschah (n, hit-rate, breakdown). Schweigt unter 50 aufgeloesten Eintraegen oder unter 3 Treffern (cold-start).', + defaultPolicy: 'auto', + parameters: [ + { + name: 'kind', + type: 'string', + description: 'Art des hypothetischen Zeichens', + required: true, + enum: ['omen', 'fortune', 'hunch'], + }, + { + name: 'sourceCategory', + type: 'string', + description: 'Quellenkategorie', + required: true, + enum: [ + 'gut', + 'tarot', + 'horoscope', + 'fortune-cookie', + 'iching', + 'dream', + 'person', + 'media', + 'natural', + 'other', + ], + }, + { + name: 'vibe', + type: 'string', + description: 'Grundstimmung', + required: true, + enum: ['good', 'bad', 'mysterious'], + }, + { + name: 'source', + type: 'string', + description: 'Quellen-Stichwort fuer Keyword-Matching', + required: false, + }, + { + name: 'claim', + type: 'string', + description: 'Aussage fuer Keyword-Matching', + required: false, + }, + { + name: 'tags', + type: 'string', + description: 'Tags durch Komma getrennt', + required: false, + }, + ], + }, + { + name: 'augur_year_recap', + module: 'augur', + description: + 'Strukturierter Jahresrueckblick: total / aufgeloest / hit-rate / vibe-breakdown / top-source-categories. Year als YYYY (Standard: aktuelles Jahr).', + defaultPolicy: 'auto', + parameters: [ + { + name: 'year', + type: 'number', + description: 'Jahr (z.B. 2026). Standard: aktuelles Jahr.', + required: false, + }, + ], + }, ]; // ═══════════════════════════════════════════════════════════════ diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index c280083ea..002bc62c8 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -86,6 +86,13 @@ const comicSvg = ``; +// Augur icon — open eye with a small star in the iris and three drifting +// dots ("signs in the air") on indigo→violet gradient. Sits in the cosmic +// family next to Dreams (indigo) and Cards (violet) so the launcher reads +// as "the seeing/oracular cluster". The eye is symmetric and abstract on +// purpose: not a religious or zodiac symbol, just "watch". +const augurSvg = ``; + /** * App icons as data URLs * Use these directly in or CSS background-image @@ -115,6 +122,7 @@ export const APP_ICONS = { inventory: svgToDataUrl(inventorySvg), wardrobe: svgToDataUrl(wardrobeSvg), comic: svgToDataUrl(comicSvg), + augur: svgToDataUrl(augurSvg), questions: svgToDataUrl(questionsSvg), context: svgToDataUrl(contextSvg), citycorners: svgToDataUrl(citycornersSvg), diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index e5a2f6655..cd86337be 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -1173,6 +1173,23 @@ export const MANA_APPS: ManaApp[] = [ status: 'development', requiredTier: 'public', }, + { + id: 'augur', + name: 'Augur', + description: { + de: 'Zeichen sammeln, Muster lesen', + en: 'Collect signs, read patterns', + }, + longDescription: { + de: 'Halte Omen, Wahrsagungen und Bauchgefühle fest — und lass Mana mit der Zeit zeigen, welche deiner inneren Stimmen wirklich Recht behalten. Witness-Modus für poetisches Erfassen, Oracle-Modus für ehrliche Auswertung.', + en: 'Capture omens, fortunes, and hunches — and over time let Mana show which of your inner voices actually get it right. Witness mode for poetic capture, Oracle mode for honest evaluation.', + }, + icon: APP_ICONS.augur, + color: '#7c3aed', + comingSoon: false, + status: 'development', + requiredTier: 'guest', + }, { id: 'spaces', name: 'Spaces',