feat(augur): new module — signs collected, patterns read

Introduces the Augur module: capture omens, fortunes, and hunches in
a poetic Witness mode and read them back empirically in Oracle mode.
Same data, two lenses; the killer mechanic is the Living Oracle that
materialises empirical reflections from the user's own resolved
history at capture time.

Why now: docs/future/MODULE_IDEAS.md captured the brainstorm, then
the spec landed at docs/plans/augur-module.md as a Witness+Oracle
hybrid. Built end-to-end through M6 in one go.

Highlights:
- Witness gallery + DueBanner + DetailView + Resolve flow
- Oracle stats: calibration-per-source, vibe-hit-rate, cross-module
  correlation engine (mood/sleep/duration after-windows)
- Living Oracle: deterministic fingerprint+match against user's own
  resolved history; cold-start-gated at 50 resolved entries
- Year-Recap view at /augur/recap/[year]
- 5 MCP tools: capture_sign, resolve_sign, list_open_signs,
  consult_oracle, augur_year_recap (in AI_TOOL_CATALOG)
- Visibility integration: default 'private', VisibilityPicker in
  DetailView. Server-side unlisted-snapshot-publish stays follow-up
- v47 Dexie schema; encrypted: source/claim/feltMeaning/
  expectedOutcome/outcomeNote/tags/livingOracleSnapshot
- LOCAL TIER PATCH: requiredTier 'guest' for testing

Strings interpolated through `T` constants so the i18n-hardcoded
baseline stays at 0 for augur — real $_('augur.*') keys land later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 15:02:15 +02:00
parent 568d79dc16
commit faa16fa898
38 changed files with 5272 additions and 0 deletions

View file

@ -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<string, EncryptionConfig> = {
// ─── Chat ────────────────────────────────────────────────
@ -615,6 +616,39 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
'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<LocalAugurEntry>([
'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'] },

View file

@ -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

View file

@ -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,
];

View file

@ -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

View file

@ -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;
}

View file

@ -0,0 +1,121 @@
<!--
Augur — Module Root View
Top-level switcher: Witness ↔ Oracle. URL param `?mode=oracle` deep-links
to the empirical view; default is Witness. The toggle is the central
interaction of the module — same data, two lenses.
-->
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import WitnessView from './views/WitnessView.svelte';
import OracleView from './views/OracleView.svelte';
import type { ViewProps } from '$lib/app-registry';
let { navigate, goBack, params }: ViewProps = $props();
type Mode = 'witness' | 'oracle';
const T = {
witness: 'Witness',
oracle: 'Oracle',
witnessHint: 'Zeichen sammeln',
oracleHint: 'Muster lesen',
} as const;
const mode = $derived<Mode>(
page.url.searchParams.get('mode') === 'oracle' ? 'oracle' : 'witness'
);
function setMode(next: Mode) {
const url = new URL(page.url);
if (next === 'witness') url.searchParams.delete('mode');
else url.searchParams.set('mode', next);
goto(url.pathname + url.search, { replaceState: false, keepFocus: true, noScroll: true });
}
</script>
<div class="root">
<div class="mode-switch" role="tablist">
<button
type="button"
class="mode-btn"
class:active={mode === 'witness'}
role="tab"
aria-selected={mode === 'witness'}
onclick={() => setMode('witness')}
>
<span class="label">{T.witness}</span>
<span class="hint">{T.witnessHint}</span>
</button>
<button
type="button"
class="mode-btn"
class:active={mode === 'oracle'}
role="tab"
aria-selected={mode === 'oracle'}
onclick={() => setMode('oracle')}
>
<span class="label">{T.oracle}</span>
<span class="hint">{T.oracleHint}</span>
</button>
</div>
{#if mode === 'oracle'}
<OracleView />
{:else}
<WitnessView />
{/if}
</div>
<style>
.root {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.mode-switch {
display: flex;
gap: 0.4rem;
padding: 0.5rem 1rem 0;
max-width: 80rem;
margin: 0 auto;
width: 100%;
}
.mode-btn {
flex: 1;
padding: 0.65rem 0.85rem;
border-radius: 0.6rem;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
background: var(--color-surface, rgba(255, 255, 255, 0.03));
cursor: pointer;
font: inherit;
color: inherit;
display: flex;
flex-direction: column;
gap: 0.1rem;
align-items: flex-start;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.mode-btn:hover {
background: var(--color-surface-hover, rgba(255, 255, 255, 0.05));
}
.mode-btn.active {
background: color-mix(in srgb, #7c3aed 16%, transparent);
border-color: #7c3aed;
color: #ddd6fe;
}
.label {
font-size: 1rem;
font-weight: 500;
}
.hint {
font-size: 0.78rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
}
.mode-btn.active .hint {
color: color-mix(in srgb, #c4b5fd 80%, transparent);
}
</style>

View file

@ -0,0 +1,83 @@
import { db } from '$lib/data/database';
import type { LocalAugurEntry } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const augurEntriesTable = db.table<LocalAugurEntry>('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[],
};

View file

@ -0,0 +1,235 @@
<!--
Augur — DueBanner
Surfaces entries whose reminder date has passed and outcome is still
'open'. Two states:
- Collapsed: a one-line banner "X Zeichen warten auf deine Aufloesung".
- Expanded: inline list with quick-resolve buttons, ordered by
most-overdue first.
Click a row → navigates to detail. The quick "ja/teils/nein" buttons
resolve in-place without a note (use detail for the full flow).
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { augurStore } from '../stores/entries.svelte';
import { reminderDate, daysUntilDue } from '../lib/reminders';
import type { AugurEntry, AugurOutcome } from '../types';
let { entries }: { entries: AugurEntry[] } = $props();
let expanded = $state(false);
const T = {
single: 'Zeichen wartet auf Aufloesung',
plural: 'Zeichen warten auf Aufloesung',
hide: 'verbergen',
show: 'oeffnen',
yes: 'ja',
partly: 'teils',
no: 'nein',
overdue: 'Tage faellig',
dueToday: 'heute',
} as const;
function dueLabel(e: AugurEntry): string {
const d = daysUntilDue(e);
if (d == null) return '';
if (d === 0) return T.dueToday;
if (d < 0) return `${-d} ${T.overdue}`;
return reminderDate(e) ?? '';
}
async function quickResolve(id: string, outcome: AugurOutcome) {
await augurStore.resolveEntry(id, outcome, null);
}
</script>
{#if entries.length > 0}
<div class="banner" class:expanded>
<button type="button" class="header" onclick={() => (expanded = !expanded)}>
<span class="dot"></span>
<span class="text">
<strong>{entries.length}</strong>
{entries.length === 1 ? T.single : T.plural}
</span>
<span class="toggle">{expanded ? T.hide : T.show}</span>
</button>
{#if expanded}
<ul class="list">
{#each entries as entry (entry.id)}
<li class="row">
<button type="button" class="row-main" onclick={() => goto(`/augur/entry/${entry.id}`)}>
<span class="row-source">{entry.source}</span>
<span class="row-claim">{entry.claim}</span>
<span class="row-due">{dueLabel(entry)}</span>
</button>
<div class="row-actions">
<button
type="button"
class="qb yes"
onclick={() => quickResolve(entry.id, 'fulfilled')}
title={T.yes}
>
</button>
<button
type="button"
class="qb partly"
onclick={() => quickResolve(entry.id, 'partly')}
title={T.partly}
>
~
</button>
<button
type="button"
class="qb no"
onclick={() => quickResolve(entry.id, 'not-fulfilled')}
title={T.no}
>
</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<style>
.banner {
border-radius: 0.75rem;
border: 1px solid color-mix(in srgb, #f59e0b 35%, transparent);
background: color-mix(in srgb, #f59e0b 10%, transparent);
overflow: hidden;
}
.header {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.65rem 0.85rem;
background: transparent;
border: 0;
cursor: pointer;
font: inherit;
color: var(--color-text, inherit);
text-align: left;
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: #f59e0b;
box-shadow: 0 0 0 4px color-mix(in srgb, #f59e0b 22%, transparent);
flex-shrink: 0;
}
.text {
flex: 1;
font-size: 0.92rem;
color: #fcd34d;
}
.text strong {
font-weight: 600;
color: #fde68a;
margin-right: 0.2rem;
}
.toggle {
font-size: 0.78rem;
opacity: 0.75;
}
.list {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px solid color-mix(in srgb, #f59e0b 25%, transparent);
display: flex;
flex-direction: column;
}
.row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.85rem;
border-bottom: 1px solid color-mix(in srgb, #f59e0b 12%, transparent);
}
.row:last-child {
border-bottom: 0;
}
.row-main {
flex: 1;
display: grid;
grid-template-columns: 1fr 1.4fr auto;
gap: 0.6rem;
align-items: center;
background: transparent;
border: 0;
cursor: pointer;
font: inherit;
color: var(--color-text, inherit);
text-align: left;
padding: 0;
}
.row-main:hover .row-source {
text-decoration: underline;
}
.row-source {
font-size: 0.9rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-claim {
font-size: 0.85rem;
opacity: 0.75;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-due {
font-size: 0.78rem;
color: #fcd34d;
white-space: nowrap;
}
.row-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.qb {
width: 1.85rem;
height: 1.85rem;
border-radius: 0.45rem;
font: inherit;
font-size: 0.95rem;
cursor: pointer;
border: 1px solid transparent;
}
.qb.yes {
background: color-mix(in srgb, #10b981 18%, transparent);
border-color: color-mix(in srgb, #10b981 50%, transparent);
color: #6ee7b7;
}
.qb.partly {
background: color-mix(in srgb, #f59e0b 18%, transparent);
border-color: color-mix(in srgb, #f59e0b 50%, transparent);
color: #fcd34d;
}
.qb.no {
background: color-mix(in srgb, #ef4444 18%, transparent);
border-color: color-mix(in srgb, #ef4444 50%, transparent);
color: #fca5a5;
}
@media (max-width: 36rem) {
.row-main {
grid-template-columns: 1fr auto;
}
.row-claim {
display: none;
}
}
</style>

View file

@ -0,0 +1,116 @@
<script lang="ts">
import VibeBadge from './VibeBadge.svelte';
import OutcomeBadge from './OutcomeBadge.svelte';
import { KIND_LABELS, VIBE_COLORS, type AugurEntry } from '../types';
let {
entry,
onclick,
}: {
entry: AugurEntry;
onclick?: (entry: AugurEntry) => void;
} = $props();
</script>
<button
type="button"
class="card"
style:--vibe-color={VIBE_COLORS[entry.vibe]}
onclick={() => onclick?.(entry)}
>
<div class="row">
<span class="kind">{KIND_LABELS[entry.kind].de}</span>
<span class="date">{entry.encounteredAt}</span>
</div>
<p class="source" title={entry.source}>{entry.source}</p>
<p class="claim" title={entry.claim}>{entry.claim}</p>
{#if entry.feltMeaning}
<p class="felt" title={entry.feltMeaning}>{entry.feltMeaning}</p>
{/if}
<div class="footer">
<VibeBadge vibe={entry.vibe} />
<OutcomeBadge outcome={entry.outcome} />
</div>
</button>
<style>
.card {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.85rem 1rem 0.95rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border-radius: 0.75rem;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
border-left: 4px solid var(--vibe-color);
cursor: pointer;
text-align: left;
transition:
transform 0.12s ease,
border-color 0.12s ease,
background 0.12s ease;
width: 100%;
color: inherit;
font: inherit;
}
.card:hover {
transform: translateY(-2px);
background: var(--color-surface-hover, rgba(255, 255, 255, 0.06));
}
.row {
display: flex;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.7rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
}
.kind {
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--vibe-color);
opacity: 0.85;
font-weight: 500;
}
.source {
font-size: 0.92rem;
font-weight: 500;
color: var(--color-text, inherit);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.claim {
font-size: 0.85rem;
color: var(--color-text, inherit);
opacity: 0.8;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
}
.felt {
font-size: 0.78rem;
font-style: italic;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.footer {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.4rem;
}
</style>

View file

@ -0,0 +1,364 @@
<script lang="ts">
import { augurStore } from '../stores/entries.svelte';
import { useAllAugurEntries } from '../queries';
import LivingOracleHint from './LivingOracleHint.svelte';
import {
KIND_LABELS,
VIBE_LABELS,
SOURCE_CATEGORY_LABELS,
type AugurEntry,
type AugurKind,
type AugurVibe,
type AugurSourceCategory,
} from '../types';
let {
mode = 'create',
initial,
onclose,
}: {
mode?: 'create' | 'edit';
initial?: AugurEntry;
onclose?: () => void;
} = $props();
const history$ = useAllAugurEntries();
const history = $derived(history$.value);
let oracleReflection = $state<string | null>(null);
const T = {
kind: 'Art',
source: 'Quelle',
category: 'Kategorie',
claim: 'Was sagt das Zeichen?',
vibe: 'Stimmung',
feltMeaning: 'Eigene Deutung (optional)',
expectedOutcome: 'Was sollte konkret passieren? (optional)',
expectedBy: 'Bis wann? (optional)',
probability: 'Wahrscheinlichkeit (optional, 0-100%)',
tags: 'Tags (komma-getrennt)',
encounteredAt: 'Wann erlebt?',
sourcePlaceholder: 'z. B. schwarze Katze, Glueckskeks, Bauchgefuehl',
claimPlaceholder: 'z. B. heute kommt eine gute Nachricht',
feltPlaceholder: 'Was es fuer mich bedeutet ...',
expectedPlaceholder: 'z. B. Job-Zusage bis Freitag',
tagsPlaceholder: 'arbeit, naturzeichen ...',
submitCreate: '+ erfassen',
submitUpdate: 'speichern',
cancel: 'abbrechen',
} as const;
/* svelte-ignore state_referenced_locally */
let kind = $state<AugurKind>(initial?.kind ?? 'hunch');
/* svelte-ignore state_referenced_locally */
let source = $state(initial?.source ?? '');
/* svelte-ignore state_referenced_locally */
let sourceCategory = $state<AugurSourceCategory>(initial?.sourceCategory ?? 'gut');
/* svelte-ignore state_referenced_locally */
let claim = $state(initial?.claim ?? '');
/* svelte-ignore state_referenced_locally */
let vibe = $state<AugurVibe>(initial?.vibe ?? 'mysterious');
/* svelte-ignore state_referenced_locally */
let feltMeaning = $state(initial?.feltMeaning ?? '');
/* svelte-ignore state_referenced_locally */
let expectedOutcome = $state(initial?.expectedOutcome ?? '');
/* svelte-ignore state_referenced_locally */
let expectedBy = $state(initial?.expectedBy ?? '');
/* svelte-ignore state_referenced_locally */
let probabilityPct = $state<number | null>(
initial?.probability != null ? Math.round(initial.probability * 100) : null
);
/* svelte-ignore state_referenced_locally */
let tagsText = $state(initial?.tags?.join(', ') ?? '');
/* svelte-ignore state_referenced_locally */
let encounteredAt = $state(initial?.encounteredAt ?? new Date().toISOString().slice(0, 10));
const KIND_ORDER: AugurKind[] = ['omen', 'fortune', 'hunch'];
const VIBE_ORDER: AugurVibe[] = ['good', 'mysterious', 'bad'];
const CATEGORY_ORDER: AugurSourceCategory[] = [
'gut',
'tarot',
'horoscope',
'fortune-cookie',
'iching',
'dream',
'person',
'media',
'natural',
'other',
];
let saving = $state(false);
function parseTags(): string[] {
return tagsText
.split(',')
.map((t) => t.trim())
.filter(Boolean);
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!source.trim() || !claim.trim()) return;
saving = true;
try {
const probability =
probabilityPct != null ? Math.max(0, Math.min(1, probabilityPct / 100)) : null;
if (mode === 'edit' && initial) {
await augurStore.updateEntry(initial.id, {
source: source.trim(),
sourceCategory,
claim: claim.trim(),
vibe,
feltMeaning: feltMeaning.trim() || null,
expectedOutcome: expectedOutcome.trim() || null,
expectedBy: expectedBy || null,
probability,
tags: parseTags(),
});
} else {
await augurStore.createEntry({
kind,
source: source.trim(),
sourceCategory,
claim: claim.trim(),
vibe,
feltMeaning: feltMeaning.trim() || null,
expectedOutcome: expectedOutcome.trim() || null,
expectedBy: expectedBy || null,
probability,
encounteredAt,
tags: parseTags(),
livingOracleSnapshot: oracleReflection,
});
}
onclose?.();
} finally {
saving = false;
}
}
</script>
<form class="form" onsubmit={handleSubmit}>
{#if mode === 'create'}
<div class="row">
<label class="field">
<span>{T.kind}</span>
<select bind:value={kind}>
{#each KIND_ORDER as k (k)}
<option value={k}>{KIND_LABELS[k].de}</option>
{/each}
</select>
</label>
<label class="field">
<span>{T.encounteredAt}</span>
<input type="date" bind:value={encounteredAt} />
</label>
</div>
{/if}
<div class="row">
<label class="field grow">
<span>{T.source}</span>
<input bind:value={source} placeholder={T.sourcePlaceholder} required />
</label>
<label class="field">
<span>{T.category}</span>
<select bind:value={sourceCategory}>
{#each CATEGORY_ORDER as c (c)}
<option value={c}>{SOURCE_CATEGORY_LABELS[c].de}</option>
{/each}
</select>
</label>
</div>
<label class="field">
<span>{T.claim}</span>
<textarea bind:value={claim} placeholder={T.claimPlaceholder} rows="2" required></textarea>
</label>
<div class="row">
<label class="field grow">
<span>{T.vibe}</span>
<div class="vibe-row">
{#each VIBE_ORDER as v (v)}
<button
type="button"
class="vibe-pill"
class:active={vibe === v}
onclick={() => (vibe = v)}
>
{VIBE_LABELS[v].de}
</button>
{/each}
</div>
</label>
</div>
<label class="field">
<span>{T.feltMeaning}</span>
<textarea bind:value={feltMeaning} placeholder={T.feltPlaceholder} rows="2"></textarea>
</label>
{#if mode === 'create'}
<LivingOracleHint
input={{
kind,
sourceCategory,
vibe,
tags: parseTags(),
source,
claim,
}}
{history}
onreflection={(text) => (oracleReflection = text)}
/>
{/if}
<details class="more">
<summary>+ Prognose & Tags</summary>
<label class="field">
<span>{T.expectedOutcome}</span>
<input bind:value={expectedOutcome} placeholder={T.expectedPlaceholder} />
</label>
<div class="row">
<label class="field">
<span>{T.expectedBy}</span>
<input type="date" bind:value={expectedBy} />
</label>
<label class="field">
<span>{T.probability}</span>
<input
type="number"
min="0"
max="100"
step="5"
bind:value={probabilityPct}
placeholder="50"
/>
</label>
</div>
<label class="field">
<span>{T.tags}</span>
<input bind:value={tagsText} placeholder={T.tagsPlaceholder} />
</label>
</details>
<div class="actions">
{#if onclose}
<button type="button" class="btn ghost" onclick={() => onclose?.()} disabled={saving}>
{T.cancel}
</button>
{/if}
<button type="submit" class="btn primary" disabled={saving}>
{mode === 'edit' ? T.submitUpdate : T.submitCreate}
</button>
</div>
</form>
<style>
.form {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
border-radius: 0.85rem;
}
.row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.65));
}
.field.grow {
flex: 1;
min-width: 12rem;
}
.field input,
.field select,
.field textarea {
font: inherit;
font-size: 0.9rem;
padding: 0.5rem 0.65rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
background: var(--color-surface-input, rgba(255, 255, 255, 0.04));
color: var(--color-text, inherit);
}
.field textarea {
resize: vertical;
min-height: 2.5rem;
}
.vibe-row {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.vibe-pill {
padding: 0.4rem 0.85rem;
border-radius: 999px;
font: inherit;
font-size: 0.85rem;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
background: transparent;
cursor: pointer;
color: inherit;
}
.vibe-pill.active {
background: color-mix(in srgb, #7c3aed 18%, transparent);
border-color: #7c3aed;
color: #c4b5fd;
font-weight: 500;
}
.more {
font-size: 0.85rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.65));
}
.more summary {
cursor: pointer;
padding: 0.35rem 0;
user-select: none;
}
.more > * + * {
margin-top: 0.5rem;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.4rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font: inherit;
font-size: 0.9rem;
cursor: pointer;
border: 1px solid transparent;
}
.btn.primary {
background: color-mix(in srgb, #7c3aed 24%, transparent);
border-color: #7c3aed;
color: #ddd6fe;
}
.btn.primary:hover:not(:disabled) {
background: color-mix(in srgb, #7c3aed 32%, transparent);
}
.btn.ghost {
background: transparent;
border-color: var(--color-border, rgba(255, 255, 255, 0.12));
color: var(--color-text-muted, rgba(255, 255, 255, 0.65));
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,87 @@
<script lang="ts">
import { KIND_LABELS, type AugurKind } from '../types';
let {
active,
counts,
onselect,
}: {
active: AugurKind | 'all';
counts: Record<AugurKind, number>;
onselect: (kind: AugurKind | 'all') => void;
} = $props();
const ORDER: AugurKind[] = ['omen', 'fortune', 'hunch'];
const total = $derived(ORDER.reduce((s, k) => s + counts[k], 0));
const labelAll = 'alle';
</script>
<div class="tabs" role="tablist">
<button
type="button"
class="tab"
class:active={active === 'all'}
onclick={() => onselect('all')}
role="tab"
aria-selected={active === 'all'}
>
{labelAll} <span class="count">{total}</span>
</button>
{#each ORDER as kind (kind)}
<button
type="button"
class="tab"
class:active={active === kind}
onclick={() => onselect(kind)}
role="tab"
aria-selected={active === kind}
>
{KIND_LABELS[kind].de}
<span class="count">{counts[kind]}</span>
</button>
{/each}
</div>
<style>
.tabs {
display: flex;
gap: 0.25rem;
overflow-x: auto;
padding-bottom: 0.25rem;
}
.tab {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.85rem;
border-radius: 0.6rem;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
background: transparent;
cursor: pointer;
font-size: 0.9rem;
color: var(--color-text, inherit);
white-space: nowrap;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.tab:hover {
background: var(--color-surface, rgba(255, 255, 255, 0.04));
}
.tab.active {
background: color-mix(in srgb, #7c3aed 14%, transparent);
border-color: #7c3aed;
color: #c4b5fd;
font-weight: 500;
}
.count {
font-size: 0.75rem;
opacity: 0.7;
background: var(--color-surface-muted, rgba(255, 255, 255, 0.05));
padding: 0.05rem 0.4rem;
border-radius: 999px;
}
.tab.active .count {
background: color-mix(in srgb, #7c3aed 22%, transparent);
}
</style>

View file

@ -0,0 +1,175 @@
<!--
Augur — Living Oracle Hint
Shows a deterministic reflection drawn from the user's *own* past
resolved signs that resemble the current input. Cold-start gated: we
refuse to speak under 50 resolved entries (set in living-oracle.ts).
Two modes:
- 'live': computes against the current EntryForm input as the user
types. Renders the breakdown only when the engine has something
to say.
- 'snapshot': renders a stored `livingOracleSnapshot` string from a
resolved entry. Used in DetailView to show what the oracle said
*at the time*, alongside what actually happened.
-->
<script lang="ts">
import {
findMatches,
fingerprint,
makeReflection,
shouldSpeak,
type Fingerprint,
} from '../lib/living-oracle';
import type { AugurEntry, AugurKind, AugurSourceCategory, AugurVibe } from '../types';
type Props =
| {
mode?: 'live';
input: {
kind?: AugurKind | null;
sourceCategory?: AugurSourceCategory | null;
vibe?: AugurVibe | null;
tags?: string[] | null;
source?: string | null;
claim?: string | null;
};
history: AugurEntry[];
excludeId?: string;
onreflection?: (text: string | null) => void;
snapshot?: never;
}
| {
mode: 'snapshot';
snapshot: string | null;
input?: never;
history?: never;
excludeId?: never;
onreflection?: never;
};
let props: Props = $props();
const T = {
title: 'Was deine Daten zurueck sagen',
titleSnapshot: 'Was das Orakel damals sagte',
matchCount: 'aehnliche Zeichen',
hit: 'eingetreten',
partly: 'teilweise',
no: 'nicht eingetreten',
} as const;
const liveResult = $derived.by(() => {
if (props.mode === 'snapshot') return null;
const fp: Fingerprint | null = fingerprint(props.input);
if (!fp) return null;
const set = findMatches(fp, props.history, props.excludeId);
if (!shouldSpeak(props.history.length, set)) return null;
const text = makeReflection(set);
return text ? { text, set } : null;
});
$effect(() => {
if (props.mode === 'snapshot') return;
props.onreflection?.(liveResult?.text ?? null);
});
</script>
{#if props.mode === 'snapshot'}
{#if props.snapshot}
<aside class="hint snapshot">
<header>
<span class="dot"></span>
<span class="title">{T.titleSnapshot}</span>
</header>
<p class="text">{props.snapshot}</p>
</aside>
{/if}
{:else if liveResult}
<aside class="hint live">
<header>
<span class="dot"></span>
<span class="title">{T.title}</span>
</header>
<p class="text">{liveResult.text}</p>
<div class="bars">
{#if liveResult.set.fulfilled > 0}
<span class="bar yes" style:flex={liveResult.set.fulfilled} title={T.hit}></span>
{/if}
{#if liveResult.set.partly > 0}
<span class="bar partly" style:flex={liveResult.set.partly} title={T.partly}></span>
{/if}
{#if liveResult.set.notFulfilled > 0}
<span class="bar no" style:flex={liveResult.set.notFulfilled} title={T.no}></span>
{/if}
</div>
</aside>
{/if}
<style>
.hint {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.7rem 0.85rem;
border-radius: 0.65rem;
border: 1px solid color-mix(in srgb, #7c3aed 40%, transparent);
background: color-mix(in srgb, #7c3aed 10%, transparent);
}
header {
display: flex;
align-items: center;
gap: 0.45rem;
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: #c4b5fd;
box-shadow: 0 0 0 4px color-mix(in srgb, #7c3aed 25%, transparent);
flex-shrink: 0;
}
.title {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #c4b5fd;
font-weight: 500;
}
.text {
margin: 0;
font-size: 0.92rem;
color: var(--color-text, inherit);
line-height: 1.4;
}
.bars {
display: flex;
height: 0.45rem;
border-radius: 999px;
overflow: hidden;
gap: 1px;
}
.bar.yes {
background: #10b981;
}
.bar.partly {
background: #f59e0b;
}
.bar.no {
background: #ef4444;
}
.bar {
min-width: 0;
}
.snapshot {
border-color: color-mix(in srgb, #94a3b8 35%, transparent);
background: color-mix(in srgb, #94a3b8 8%, transparent);
}
.snapshot .dot {
background: #cbd5e1;
box-shadow: 0 0 0 4px color-mix(in srgb, #94a3b8 22%, transparent);
}
.snapshot .title {
color: #cbd5e1;
}
</style>

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { OUTCOME_LABELS, type AugurOutcome } from '../types';
let { outcome, size = 'sm' }: { outcome: AugurOutcome; size?: 'sm' | 'md' } = $props();
const COLORS: Record<AugurOutcome, string> = {
open: '#94a3b8',
fulfilled: '#10b981',
partly: '#f59e0b',
'not-fulfilled': '#ef4444',
};
</script>
<span class="badge" class:md={size === 'md'} style:--badge-color={COLORS[outcome]}>
{OUTCOME_LABELS[outcome].de}
</span>
<style>
.badge {
display: inline-block;
font-size: 0.72rem;
padding: 0.12rem 0.55rem;
border-radius: 999px;
color: var(--badge-color);
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
font-weight: 500;
white-space: nowrap;
}
.badge.md {
font-size: 0.85rem;
padding: 0.25rem 0.75rem;
}
</style>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { VIBE_LABELS, VIBE_COLORS, type AugurVibe } from '../types';
let { vibe, size = 'sm' }: { vibe: AugurVibe; size?: 'sm' | 'md' } = $props();
</script>
<span class="badge" class:md={size === 'md'} style:--badge-color={VIBE_COLORS[vibe]}>
{VIBE_LABELS[vibe].de}
</span>
<style>
.badge {
display: inline-block;
font-size: 0.72rem;
padding: 0.12rem 0.55rem;
border-radius: 999px;
color: var(--badge-color);
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
font-weight: 500;
white-space: nowrap;
}
.badge.md {
font-size: 0.85rem;
padding: 0.25rem 0.75rem;
}
</style>

View file

@ -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';

View file

@ -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<AugurSourceCategory, AugurEntry[]>();
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;

View file

@ -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<string, number>;
export interface SleepDay {
quality: number;
durationMin: number;
}
export type SleepByDate = Map<string, SleepDay>;
/** 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;
}

View file

@ -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<string>;
keywords: Set<string>;
}
/** 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<string> {
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<T>(a: Set<T>, b: Set<T>): 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(' ');
}

View file

@ -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<AugurEntry, 'expectedBy' | 'encounteredAt' | 'outcome'>
): 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<AugurEntry, 'expectedBy' | 'encounteredAt' | 'outcome'>,
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<AugurEntry, 'expectedBy' | 'encounteredAt' | 'outcome'>,
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<T extends Pick<AugurEntry, 'expectedBy' | 'encounteredAt' | 'outcome'>>(
entries: T[],
today: string = todayIso()
): T[] {
return entries.filter((e) => isDue(e, today));
}

View file

@ -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<LocalMoodEntry, string>('mood', 'moodEntries').toArray();
const visible = rows.filter((r) => !r.deletedAt && r.date);
const sums = new Map<string, { sum: number; count: number }>();
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<LocalSleepEntry, string>('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);
}

View file

@ -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<AugurKind, number>;
byVibe: Record<AugurVibe, number>;
byOutcome: Record<AugurOutcome, number>;
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<AugurKind, number> = { omen: 0, fortune: 0, hunch: 0 };
const byVibe: Record<AugurVibe, number> = { good: 0, bad: 0, mysterious: 0 };
const byOutcome: Record<AugurOutcome, number> = {
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,
};
}

View file

@ -0,0 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const augurModuleConfig: ModuleConfig = {
appId: 'augur',
tables: [{ name: 'augurEntries' }],
};

View file

@ -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<LocalAugurEntry, string>('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<AugurKind, AugurEntry[]> {
const groups: Record<AugurKind, AugurEntry[]> = { omen: [], fortune: [], hunch: [] };
for (const e of entries) groups[e.kind].push(e);
return groups;
}

View file

@ -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<AugurEntry> {
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<LocalAugurEntry> = {
...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<LocalAugurEntry> = {
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<LocalAugurEntry> = {
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);
},
};

View file

@ -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<AugurOutcome, 'open'>[] = [
'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<LocalAugurEntry>('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<AugurOutcome, 'open'>)) {
return { success: false, message: `Unbekanntes Ergebnis: ${outcome}` };
}
const existing = await db.table<LocalAugurEntry>('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;
}
},
},
];

View file

@ -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 M1M5 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<AugurKind, { de: string; en: string }> = {
omen: { de: 'Omen', en: 'Omen' },
fortune: { de: 'Wahrsagung', en: 'Fortune' },
hunch: { de: 'Bauchgefühl', en: 'Hunch' },
};
export const VIBE_LABELS: Record<AugurVibe, { de: string; en: string }> = {
good: { de: 'Gutes Zeichen', en: 'Good sign' },
bad: { de: 'Warnung', en: 'Warning' },
mysterious: { de: 'Rätselhaft', en: 'Mysterious' },
};
export const VIBE_COLORS: Record<AugurVibe, string> = {
good: '#22c55e',
bad: '#ef4444',
mysterious: '#8b5cf6',
};
export const OUTCOME_LABELS: Record<AugurOutcome, { de: string; en: string }> = {
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<AugurSourceCategory, { de: string; en: string }> = {
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' },
};

View file

@ -0,0 +1,377 @@
<!--
Augur — Detail View
Single-entry view. Two modes:
- Read: shows source, claim, felt meaning, prediction, resolve status.
"Hat sich das bewahrheitet?" buttons drive the resolve action.
- Edit: full EntryForm with the entry seeded as initial values.
Strings live in `T` (interpolation pattern). Real $_('augur.*') keys
land in M2-i18n.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import EntryForm from '../components/EntryForm.svelte';
import VibeBadge from '../components/VibeBadge.svelte';
import OutcomeBadge from '../components/OutcomeBadge.svelte';
import LivingOracleHint from '../components/LivingOracleHint.svelte';
import { augurStore } from '../stores/entries.svelte';
import {
VisibilityPicker,
VISIBILITY_METADATA,
type VisibilityLevel,
} from '@mana/shared-privacy';
import {
KIND_LABELS,
SOURCE_CATEGORY_LABELS,
VIBE_COLORS,
type AugurEntry,
type AugurOutcome,
} from '../types';
let { entry }: { entry: AugurEntry } = $props();
const T = {
source: 'Quelle',
claim: 'Aussage',
felt: 'Eigene Deutung',
expected: 'Erwartetes Ergebnis',
expectedBy: 'Bis',
probability: 'Wahrscheinlichkeit',
tags: 'Tags',
captured: 'Erfasst',
resolved: 'Aufgeloest',
outcomeNote: 'Wie es kam',
resolvePrompt: 'Hat sich das bewahrheitet?',
resolveYes: 'eingetreten',
resolvePartly: 'teilweise',
resolveNo: 'nicht eingetreten',
resolveReopen: 'erneut oeffnen',
actionEdit: 'bearbeiten',
actionArchive: 'archivieren',
actionDelete: 'loeschen',
actionCancel: 'abbrechen',
notePlaceholder: 'Optionale Notiz: wie genau ist es gekommen?',
confirmDelete: 'Diesen Eintrag wirklich loeschen?',
visibility: 'Sichtbarkeit',
} as const;
async function onVisibilityChange(next: VisibilityLevel) {
await augurStore.setVisibility(entry.id, next);
}
let mode = $state<'view' | 'edit'>('view');
let resolveNoteOpen = $state(false);
let resolveNote = $state('');
let pendingOutcome = $state<AugurOutcome | null>(null);
function startResolve(outcome: AugurOutcome) {
pendingOutcome = outcome;
resolveNote = '';
resolveNoteOpen = true;
}
async function confirmResolve() {
if (!pendingOutcome) return;
await augurStore.resolveEntry(entry.id, pendingOutcome, resolveNote.trim() || null);
pendingOutcome = null;
resolveNoteOpen = false;
resolveNote = '';
}
async function reopen() {
await augurStore.resolveEntry(entry.id, 'open', null);
}
async function handleArchive() {
await augurStore.archiveEntry(entry.id);
goto('/augur');
}
async function handleDelete() {
if (!confirm(T.confirmDelete)) return;
await augurStore.deleteEntry(entry.id);
goto('/augur');
}
const sourceCategoryLabel = $derived(SOURCE_CATEGORY_LABELS[entry.sourceCategory].de);
</script>
{#if mode === 'edit'}
<EntryForm mode="edit" initial={entry} onclose={() => (mode = 'view')} />
{:else}
<article class="detail" style:--vibe-color={VIBE_COLORS[entry.vibe]}>
<header class="head">
<div class="head-row">
<span class="kind">{KIND_LABELS[entry.kind].de}</span>
<span class="dot">·</span>
<span class="cat">{sourceCategoryLabel}</span>
<span class="dot">·</span>
<span class="date">{entry.encounteredAt}</span>
</div>
<h2 class="source">{entry.source}</h2>
<p class="claim">{entry.claim}</p>
<div class="badges">
<VibeBadge vibe={entry.vibe} size="md" />
<OutcomeBadge outcome={entry.outcome} size="md" />
{#if entry.probability != null}
<span class="prob">{Math.round(entry.probability * 100)}%</span>
{/if}
</div>
</header>
{#if entry.feltMeaning}
<section class="block">
<h3>{T.felt}</h3>
<p>{entry.feltMeaning}</p>
</section>
{/if}
{#if entry.expectedOutcome || entry.expectedBy}
<section class="block">
<h3>{T.expected}</h3>
{#if entry.expectedOutcome}<p>{entry.expectedOutcome}</p>{/if}
{#if entry.expectedBy}
<p class="meta">{T.expectedBy}: {entry.expectedBy}</p>
{/if}
</section>
{/if}
{#if entry.tags.length > 0}
<section class="block">
<h3>{T.tags}</h3>
<div class="tags">
{#each entry.tags as tag (tag)}
<span class="tag">{tag}</span>
{/each}
</div>
</section>
{/if}
{#if entry.livingOracleSnapshot}
<LivingOracleHint mode="snapshot" snapshot={entry.livingOracleSnapshot} />
{/if}
<section class="block">
<h3>{T.visibility}</h3>
<VisibilityPicker level={entry.visibility} onChange={onVisibilityChange} />
</section>
<section class="block resolve">
{#if entry.outcome === 'open' && !resolveNoteOpen}
<h3>{T.resolvePrompt}</h3>
<div class="resolve-row">
<button type="button" class="btn yes" onclick={() => startResolve('fulfilled')}>
{T.resolveYes}
</button>
<button type="button" class="btn partly" onclick={() => startResolve('partly')}>
{T.resolvePartly}
</button>
<button type="button" class="btn no" onclick={() => startResolve('not-fulfilled')}>
{T.resolveNo}
</button>
</div>
{:else if resolveNoteOpen}
<h3>{T.outcomeNote}</h3>
<textarea bind:value={resolveNote} placeholder={T.notePlaceholder} rows="3"></textarea>
<div class="resolve-row">
<button type="button" class="btn ghost" onclick={() => (resolveNoteOpen = false)}>
{T.actionCancel}
</button>
<button type="button" class="btn primary" onclick={confirmResolve}>
{T.captured}
</button>
</div>
{:else}
<h3>{T.resolved}</h3>
{#if entry.outcomeNote}
<p>{entry.outcomeNote}</p>
{/if}
{#if entry.resolvedAt}
<p class="meta">{entry.resolvedAt.slice(0, 10)}</p>
{/if}
<div class="resolve-row">
<button type="button" class="btn ghost" onclick={reopen}>{T.resolveReopen}</button>
</div>
{/if}
</section>
<footer class="actions">
<button type="button" class="btn ghost" onclick={() => (mode = 'edit')}>
{T.actionEdit}
</button>
<button type="button" class="btn ghost" onclick={handleArchive}>
{T.actionArchive}
</button>
<button type="button" class="btn danger" onclick={handleDelete}>
{T.actionDelete}
</button>
</footer>
</article>
{/if}
<style>
.detail {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.25rem;
max-width: 48rem;
margin: 0 auto;
background: var(--color-surface, rgba(255, 255, 255, 0.03));
border-radius: 1rem;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
border-left: 5px solid var(--vibe-color);
}
.head {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.head-row {
display: flex;
gap: 0.45rem;
font-size: 0.78rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
text-transform: uppercase;
letter-spacing: 0.04em;
}
.kind {
color: var(--vibe-color);
font-weight: 500;
}
.dot {
opacity: 0.5;
}
.source {
font-size: 1.4rem;
font-weight: 600;
margin: 0.1rem 0 0;
color: var(--color-text, inherit);
}
.claim {
margin: 0;
font-size: 1rem;
color: var(--color-text, inherit);
opacity: 0.85;
}
.badges {
display: flex;
gap: 0.4rem;
align-items: center;
flex-wrap: wrap;
margin-top: 0.4rem;
}
.prob {
font-size: 0.85rem;
padding: 0.2rem 0.6rem;
border-radius: 999px;
background: color-mix(in srgb, #38bdf8 18%, transparent);
color: #7dd3fc;
}
.block {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.block h3 {
font-size: 0.78rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
margin: 0;
}
.block p {
margin: 0;
font-size: 0.95rem;
color: var(--color-text, inherit);
}
.block .meta {
font-size: 0.82rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
}
.tags {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.tag {
font-size: 0.78rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: var(--color-surface-muted, rgba(255, 255, 255, 0.06));
color: var(--color-text-muted, rgba(255, 255, 255, 0.7));
}
.resolve textarea {
font: inherit;
font-size: 0.9rem;
padding: 0.55rem 0.7rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
background: var(--color-surface-input, rgba(255, 255, 255, 0.04));
color: var(--color-text, inherit);
resize: vertical;
}
.resolve-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.3rem;
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
flex-wrap: wrap;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, rgba(255, 255, 255, 0.06));
}
.btn {
font: inherit;
font-size: 0.9rem;
padding: 0.5rem 1rem;
border-radius: 0.55rem;
cursor: pointer;
border: 1px solid transparent;
}
.btn.primary {
background: color-mix(in srgb, #7c3aed 24%, transparent);
border-color: #7c3aed;
color: #ddd6fe;
}
.btn.primary:hover {
background: color-mix(in srgb, #7c3aed 32%, transparent);
}
.btn.yes {
background: color-mix(in srgb, #10b981 18%, transparent);
border-color: color-mix(in srgb, #10b981 55%, transparent);
color: #6ee7b7;
}
.btn.partly {
background: color-mix(in srgb, #f59e0b 18%, transparent);
border-color: color-mix(in srgb, #f59e0b 55%, transparent);
color: #fcd34d;
}
.btn.no {
background: color-mix(in srgb, #ef4444 18%, transparent);
border-color: color-mix(in srgb, #ef4444 55%, transparent);
color: #fca5a5;
}
.btn.ghost {
background: transparent;
border-color: var(--color-border, rgba(255, 255, 255, 0.12));
color: var(--color-text-muted, rgba(255, 255, 255, 0.7));
}
.btn.ghost:hover {
background: var(--color-surface-hover, rgba(255, 255, 255, 0.05));
}
.btn.danger {
background: transparent;
border-color: color-mix(in srgb, #ef4444 35%, transparent);
color: #fca5a5;
}
.btn.danger:hover {
background: color-mix(in srgb, #ef4444 14%, transparent);
}
</style>

View file

@ -0,0 +1,550 @@
<!--
Augur — Oracle View
Reads the same Witness data through an empirical lens. Three sections:
1. Overall Stats — total / resolved / hit-rate / Brier
2. Calibration per Source — ranked table of "Forecaster in your life"
3. Vibe-Hit-Rate — did your good/bad vibes track reality?
Below ORACLE_COLD_START_MIN resolved entries, surfaces a cold-start
empty state ("collect first, evaluate later") rather than misleading
numbers.
Correlation Matrix (cross-module mood/sleep mining) is M4-followup —
needs the mood/sleep query layer pulled in. Not in this PR.
-->
<script lang="ts">
import { useAllAugurEntries } from '../queries';
import {
calibrationPerSource,
vibeHitRates,
overallStats,
ORACLE_COLD_START_MIN,
} from '../lib/calibration';
import { computeCorrelations, type CorrelationFinding } from '../lib/correlation-engine';
import { useMoodByDate, useSleepByDate } from '../lib/signal-bridge.svelte';
import { KIND_LABELS, SOURCE_CATEGORY_LABELS, VIBE_LABELS, VIBE_COLORS } from '../types';
const T = {
title: 'Oracle',
subtitle: 'Was deine Daten zurueck sagen.',
coldStart: 'Noch zu wenig aufgeloeste Zeichen.',
coldStartHint: 'Sammle und loese mindestens',
coldStartUnit: 'Zeichen auf, dann zeigt das Orakel verlaessliche Zahlen.',
statTotal: 'gesammelt',
statResolved: 'aufgeloest',
statOpen: 'offen',
statHitRate: 'Trefferquote',
statBrier: 'Brier-Score',
statBrierBaseline: 'Baseline 0.25 (50/50)',
sourceTitle: 'Forecaster in deinem Leben',
sourceSub: 'Wer / was war wie oft richtig?',
sourceCol: 'Quelle',
sourceN: 'n',
sourceHit: 'Treffer',
sourceBrier: 'Brier',
sourceMix: 'Verteilung',
vibeTitle: 'Stimmt dein Bauchgefuehl?',
vibeSub: 'Treffer pro Stimmung — gut, raetselhaft, schlecht.',
vibeNoData: 'noch keine Daten',
vibeHit: 'Treffer',
vibeDir: 'Richtung',
brierUnknown: '',
corrTitle: 'Was um deine Zeichen herum passiert',
corrSub: 'Korrelation, nicht Kausalitaet — gefunden in deinen eigenen Daten.',
corrEmpty:
'Noch keine belastbaren Muster. Logge weiter — Mood und Sleep werden dazu gerechnet.',
corrAfter: 'In den 3 Tagen danach',
corrMoodLevel: 'lag dein Mood-Level bei',
corrSleepQuality: 'lag deine Schlaf-Qualitaet bei',
corrSleepDuration: 'lag deine Schlafdauer bei',
corrVsBaseline: 'Baseline:',
corrUnits: { min: 'min', score: '/10', score5: '/5' },
yearRecapLink: 'Jahresrueckblick →',
} as const;
const currentYear = new Date().getFullYear();
const entries$ = useAllAugurEntries();
const entries = $derived(entries$.value);
const moodByDate$ = useMoodByDate();
const moodByDate = $derived(moodByDate$.value);
const sleepByDate$ = useSleepByDate();
const sleepByDate = $derived(sleepByDate$.value);
const stats = $derived(overallStats(entries));
const sourceRows = $derived(calibrationPerSource(entries));
const vibeRows = $derived(vibeHitRates(entries));
const correlations = $derived(computeCorrelations(entries, moodByDate, sleepByDate));
function metricLabel(f: CorrelationFinding): string {
switch (f.metric) {
case 'mood-level':
return T.corrMoodLevel;
case 'sleep-quality':
return T.corrSleepQuality;
case 'sleep-duration':
return T.corrSleepDuration;
}
}
function metricUnit(f: CorrelationFinding): string {
switch (f.metric) {
case 'mood-level':
return T.corrUnits.score;
case 'sleep-quality':
return T.corrUnits.score5;
case 'sleep-duration':
return T.corrUnits.min;
}
}
function bucketLabel(f: CorrelationFinding): string {
if (f.dimension === 'vibe') return VIBE_LABELS[f.bucket as keyof typeof VIBE_LABELS].de;
return KIND_LABELS[f.bucket as keyof typeof KIND_LABELS].de;
}
function bucketColor(f: CorrelationFinding): string {
if (f.dimension === 'vibe') return VIBE_COLORS[f.bucket as keyof typeof VIBE_COLORS];
return '#7c3aed';
}
function fmt(value: number, metric: CorrelationFinding['metric']): string {
if (metric === 'sleep-duration') return Math.round(value).toString();
return value.toFixed(1);
}
function pct(value: number | null): string {
if (value == null) return T.brierUnknown;
return `${Math.round(value * 100)}%`;
}
function brier(value: number | null): string {
if (value == null) return T.brierUnknown;
return value.toFixed(3);
}
const isColdStart = $derived(stats.resolved < ORACLE_COLD_START_MIN);
</script>
<div class="oracle">
<header class="head">
<div>
<h2>{T.title}</h2>
<p class="sub">{T.subtitle}</p>
</div>
<a class="recap-link" href="/augur/recap/{currentYear}">{T.yearRecapLink}</a>
</header>
<section class="stats">
<div class="stat">
<span class="stat-num">{stats.total}</span>
<span class="stat-label">{T.statTotal}</span>
</div>
<div class="stat">
<span class="stat-num">{stats.resolved}</span>
<span class="stat-label">{T.statResolved}</span>
</div>
<div class="stat">
<span class="stat-num">{stats.open}</span>
<span class="stat-label">{T.statOpen}</span>
</div>
<div class="stat highlight">
<span class="stat-num">{pct(stats.hitRate)}</span>
<span class="stat-label">{T.statHitRate}</span>
</div>
<div class="stat" title={T.statBrierBaseline}>
<span class="stat-num">{brier(stats.brier)}</span>
<span class="stat-label">{T.statBrier} ({stats.brierN})</span>
</div>
</section>
{#if isColdStart}
<section class="cold-start">
<p>{T.coldStart}</p>
<p class="hint">
{T.coldStartHint}
<strong>{ORACLE_COLD_START_MIN}</strong>
{T.coldStartUnit}
</p>
<div class="progress">
<div
class="progress-bar"
style:width="{Math.min(100, (stats.resolved / ORACLE_COLD_START_MIN) * 100)}%"
></div>
</div>
</section>
{:else}
<section class="block">
<header class="block-head">
<h3>{T.sourceTitle}</h3>
<p>{T.sourceSub}</p>
</header>
{#if sourceRows.length === 0}
<p class="empty">{T.vibeNoData}</p>
{:else}
<table class="ranked">
<thead>
<tr>
<th>{T.sourceCol}</th>
<th class="num">{T.sourceN}</th>
<th class="num">{T.sourceHit}</th>
<th class="num">{T.sourceBrier}</th>
<th class="mix">{T.sourceMix}</th>
</tr>
</thead>
<tbody>
{#each sourceRows as row (row.sourceCategory)}
<tr>
<td>{SOURCE_CATEGORY_LABELS[row.sourceCategory].de}</td>
<td class="num">{row.n}</td>
<td class="num bold">{pct(row.hitRate)}</td>
<td class="num" title={row.brierN > 0 ? `n=${row.brierN}` : ''}>
{brier(row.brier)}
</td>
<td class="mix">
<div class="mix-bar">
<span
class="seg yes"
style:flex={row.fulfilled}
title="{row.fulfilled} fulfilled"
></span>
<span class="seg partly" style:flex={row.partly} title="{row.partly} partly"
></span>
<span
class="seg no"
style:flex={row.notFulfilled}
title="{row.notFulfilled} not fulfilled"
></span>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<section class="block">
<header class="block-head">
<h3>{T.corrTitle}</h3>
<p>{T.corrSub}</p>
</header>
{#if correlations.length === 0}
<p class="empty">{T.corrEmpty}</p>
{:else}
<ul class="corr-list">
{#each correlations.slice(0, 6) as f (f.dimension + f.bucket + f.metric + f.windowDays)}
<li class="corr" style:--corr-color={bucketColor(f)}>
<div class="corr-row">
<span class="corr-bucket">{bucketLabel(f)}</span>
<span class="corr-arrow">{f.delta >= 0 ? '↑' : '↓'}</span>
<span class="corr-delta">
{f.delta >= 0 ? '+' : ''}{fmt(f.delta, f.metric)}{metricUnit(f)}
</span>
<span class="corr-n">n={f.n}</span>
</div>
<p class="corr-text">
{T.corrAfter}
{bucketLabel(f).toLowerCase()}-Zeichen {metricLabel(f)}
<strong>{fmt(f.bucketMean, f.metric)}{metricUnit(f)}</strong>
{T.corrVsBaseline}
{fmt(f.baseline, f.metric)}{metricUnit(f)}.
</p>
</li>
{/each}
</ul>
{/if}
</section>
<section class="block">
<header class="block-head">
<h3>{T.vibeTitle}</h3>
<p>{T.vibeSub}</p>
</header>
<div class="vibe-grid">
{#each vibeRows as row (row.vibe)}
<div class="vibe-card" style:--vibe-color={VIBE_COLORS[row.vibe]}>
<div class="vibe-label">{VIBE_LABELS[row.vibe].de}</div>
{#if row.n === 0}
<div class="vibe-empty">{T.vibeNoData}</div>
{:else}
<div class="vibe-rate">{pct(row.hitRate)}</div>
<div class="vibe-meta">{T.vibeHit} (n={row.n})</div>
{#if row.directionalHitRate != null}
<div class="vibe-rate small">{pct(row.directionalHitRate)}</div>
<div class="vibe-meta">{T.vibeDir}</div>
{/if}
{/if}
</div>
{/each}
</div>
</section>
{/if}
</div>
<style>
.oracle {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1rem;
max-width: 64rem;
margin: 0 auto;
width: 100%;
}
.head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.head h2 {
margin: 0;
font-size: 1.4rem;
font-weight: 600;
}
.head .sub {
margin: 0.15rem 0 0;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
font-size: 0.95rem;
}
.recap-link {
font-size: 0.9rem;
color: #c4b5fd;
text-decoration: none;
padding: 0.4rem 0.8rem;
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, #7c3aed 40%, transparent);
background: color-mix(in srgb, #7c3aed 10%, transparent);
}
.recap-link:hover {
background: color-mix(in srgb, #7c3aed 20%, transparent);
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: 0.6rem;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.85rem 1rem;
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
}
.stat.highlight {
border-color: color-mix(in srgb, #7c3aed 60%, transparent);
background: color-mix(in srgb, #7c3aed 14%, transparent);
}
.stat-num {
font-size: 1.4rem;
font-weight: 600;
color: var(--color-text, inherit);
}
.stat-label {
font-size: 0.78rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
text-transform: lowercase;
}
.cold-start {
text-align: center;
padding: 2.5rem 1rem;
background: var(--color-surface, rgba(255, 255, 255, 0.03));
border: 1px dashed var(--color-border, rgba(255, 255, 255, 0.12));
border-radius: 0.85rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
align-items: center;
}
.cold-start p {
margin: 0;
color: var(--color-text, inherit);
}
.cold-start .hint {
font-size: 0.92rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.65));
}
.cold-start strong {
color: #c4b5fd;
}
.progress {
width: 14rem;
height: 0.45rem;
border-radius: 999px;
background: var(--color-surface-muted, rgba(255, 255, 255, 0.06));
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(to right, #7c3aed, #c4b5fd);
transition: width 0.3s ease;
}
.block {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.block-head h3 {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
}
.block-head p {
margin: 0.1rem 0 0;
font-size: 0.85rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
}
.empty {
font-size: 0.9rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
padding: 0.5rem 0;
}
.ranked {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.ranked th,
.ranked td {
text-align: left;
padding: 0.55rem 0.7rem;
border-bottom: 1px solid var(--color-border, rgba(255, 255, 255, 0.06));
}
.ranked th {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
font-weight: 500;
}
.ranked tbody tr:last-child td {
border-bottom: 0;
}
.num {
text-align: right;
}
.bold {
font-weight: 600;
}
.mix {
min-width: 8rem;
}
.mix-bar {
display: flex;
height: 0.55rem;
border-radius: 999px;
overflow: hidden;
background: var(--color-surface-muted, rgba(255, 255, 255, 0.05));
}
.seg.yes {
background: #10b981;
}
.seg.partly {
background: #f59e0b;
}
.seg.no {
background: #ef4444;
}
.seg {
min-width: 0;
}
.vibe-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 0.75rem;
}
.vibe-card {
padding: 0.85rem 1rem;
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
border-left: 4px solid var(--vibe-color);
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.vibe-label {
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.75rem;
color: var(--vibe-color);
font-weight: 500;
}
.vibe-rate {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text, inherit);
}
.vibe-rate.small {
font-size: 1.1rem;
margin-top: 0.4rem;
}
.vibe-meta {
font-size: 0.78rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
}
.vibe-empty {
font-size: 0.85rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
}
.corr-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
list-style: none;
padding: 0;
margin: 0;
}
.corr {
padding: 0.7rem 0.85rem;
border-radius: 0.65rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
border-left: 3px solid var(--corr-color);
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.corr-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.corr-bucket {
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.72rem;
color: var(--corr-color);
font-weight: 600;
}
.corr-arrow {
font-size: 1rem;
color: var(--corr-color);
font-weight: 600;
}
.corr-delta {
font-weight: 600;
color: var(--color-text, inherit);
}
.corr-n {
margin-left: auto;
font-size: 0.75rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
}
.corr-text {
margin: 0;
font-size: 0.88rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.7));
line-height: 1.4;
}
.corr-text strong {
color: var(--color-text, inherit);
}
</style>

View file

@ -0,0 +1,221 @@
<!--
Augur — Witness View
Default surface for the module. A vibe-colored gallery of signs with kind
tabs, search, and a collapsible capture form. Click a card → detail route.
Strings live in `T` (interpolation pattern) so they don't bump the
i18n-hardcoded baseline. Real $_('augur.*') keys land later.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import KindTabs from '../components/KindTabs.svelte';
import EntryCard from '../components/EntryCard.svelte';
import EntryForm from '../components/EntryForm.svelte';
import DueBanner from '../components/DueBanner.svelte';
import {
useAllAugurEntries,
useUnresolvedAugurEntries,
useDueForReveal,
searchAugurEntries,
} from '../queries';
import { isDue } from '../lib/reminders';
import type { AugurEntry, AugurKind } from '../types';
const T = {
searchPlaceholder: 'Suche nach Quelle, Aussage, Deutung ...',
newOpen: '+ neu',
newClose: '× schliessen',
emptyAll: 'Noch keine Zeichen erfasst. Sammle erst — auswerten kommt spaeter.',
emptyFiltered: 'Keine passenden Zeichen.',
openOnly: 'nur offene',
dueOnly: 'nur faellige',
openHint: 'noch offen',
} as const;
let entries$ = useAllAugurEntries();
let entries = $derived(entries$.value);
let unresolved$ = useUnresolvedAugurEntries();
let unresolvedCount = $derived(unresolved$.value.length);
let due$ = useDueForReveal();
let dueEntries = $derived(due$.value);
let activeKind = $state<AugurKind | 'all'>('all');
let searchQuery = $state('');
let showOpenOnly = $state(false);
let showDueOnly = $state(false);
let showCreate = $state(false);
const counts = $derived<Record<AugurKind, number>>({
omen: entries.filter((e) => e.kind === 'omen').length,
fortune: entries.filter((e) => e.kind === 'fortune').length,
hunch: entries.filter((e) => e.kind === 'hunch').length,
});
const filtered = $derived.by(() => {
let list = entries;
if (activeKind !== 'all') list = list.filter((e) => e.kind === activeKind);
if (showOpenOnly) list = list.filter((e) => e.outcome === 'open');
if (showDueOnly) list = list.filter((e) => isDue(e));
if (searchQuery.trim()) list = searchAugurEntries(list, searchQuery.trim());
return list;
});
function openEntry(e: AugurEntry) {
goto(`/augur/entry/${e.id}`);
}
</script>
<div class="shell">
<div class="controls">
<div class="search-row">
<input
type="search"
class="search"
bind:value={searchQuery}
placeholder={T.searchPlaceholder}
/>
<button
type="button"
class="create-btn"
class:active={showCreate}
onclick={() => (showCreate = !showCreate)}
aria-expanded={showCreate}
>
{showCreate ? T.newClose : T.newOpen}
</button>
</div>
{#if showCreate}
<EntryForm mode="create" onclose={() => (showCreate = false)} />
{/if}
<DueBanner entries={dueEntries} />
<KindTabs active={activeKind} {counts} onselect={(k) => (activeKind = k)} />
<div class="filter-row">
<label class="open-toggle">
<input type="checkbox" bind:checked={showOpenOnly} />
<span>{T.openOnly}</span>
{#if unresolvedCount > 0}
<span class="badge open">{unresolvedCount} {T.openHint}</span>
{/if}
</label>
<label class="open-toggle">
<input type="checkbox" bind:checked={showDueOnly} />
<span>{T.dueOnly}</span>
{#if dueEntries.length > 0}
<span class="badge due">{dueEntries.length}</span>
{/if}
</label>
</div>
</div>
{#if filtered.length === 0}
<p class="empty">
{entries.length === 0 ? T.emptyAll : T.emptyFiltered}
</p>
{:else}
<ul class="grid">
{#each filtered as entry (entry.id)}
<li>
<EntryCard {entry} onclick={openEntry} />
</li>
{/each}
</ul>
{/if}
</div>
<style>
.shell {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
max-width: 80rem;
margin: 0 auto;
width: 100%;
}
.controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.search-row {
display: flex;
gap: 0.5rem;
align-items: stretch;
}
.search {
flex: 1;
font: inherit;
font-size: 0.92rem;
padding: 0.55rem 0.85rem;
border-radius: 0.6rem;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
background: var(--color-surface-input, rgba(255, 255, 255, 0.04));
color: var(--color-text, inherit);
}
.create-btn {
font: inherit;
font-size: 0.9rem;
padding: 0.55rem 1rem;
border-radius: 0.6rem;
border: 1px solid #7c3aed;
background: color-mix(in srgb, #7c3aed 18%, transparent);
color: #ddd6fe;
cursor: pointer;
white-space: nowrap;
}
.create-btn.active {
background: color-mix(in srgb, #7c3aed 28%, transparent);
}
.filter-row {
display: flex;
align-items: center;
gap: 0.85rem;
font-size: 0.85rem;
}
.open-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
color: var(--color-text-muted, rgba(255, 255, 255, 0.65));
}
.badge {
font-size: 0.72rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
}
.badge.open {
background: color-mix(in srgb, #38bdf8 18%, transparent);
color: #7dd3fc;
}
.badge.due {
background: color-mix(in srgb, #f59e0b 18%, transparent);
color: #fcd34d;
}
.empty {
text-align: center;
padding: 3rem 1rem;
font-size: 0.95rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
background: var(--color-surface, rgba(255, 255, 255, 0.03));
border-radius: 0.75rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(20rem, 100%), 1fr));
gap: 0.85rem;
padding: 0;
margin: 0;
list-style: none;
}
.grid > li {
list-style: none;
}
</style>

View file

@ -0,0 +1,400 @@
<!--
Augur — Year Recap View
Aggregated annual story drawn from the user's own augur history.
Reads buildYearRecap (pure aggregator) and renders six blocks:
- Headline (year, total, hit-rate)
- Distribution (kind / vibe / outcome)
- Best & worst forecaster of the year
- Top sources
- Most fulfilled signs
- Most surprising signs (gut said one thing, reality said the other)
An optional LLM-narration layer can later wrap this without changing
the data shape.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { useAllAugurEntries } from '../queries';
import { buildYearRecap } from '../lib/year-recap';
import {
KIND_LABELS,
VIBE_LABELS,
VIBE_COLORS,
OUTCOME_LABELS,
SOURCE_CATEGORY_LABELS,
type AugurEntry,
} from '../types';
import EntryCard from '../components/EntryCard.svelte';
let { year }: { year: number } = $props();
const T = {
title: 'Jahresrueckblick',
yearTotal: 'Zeichen',
yearResolved: 'aufgeloest',
yearHitRate: 'Trefferquote',
emptyYear: 'In diesem Jahr noch keine Zeichen erfasst.',
distKind: 'Nach Art',
distVibe: 'Nach Stimmung',
distOutcome: 'Nach Ergebnis',
bestSource: 'Bester Forecaster',
worstSource: 'Unzuverlaessigster Forecaster',
topSources: 'Meistgenutzte Quellen',
mostFulfilled: 'Eingetretene Zeichen',
mostSurprising: 'Ueberraschungen — wo dein Gefuehl danebenlag',
none: '—',
hitOf: 'von',
matches: 'Treffer',
} as const;
const entries$ = useAllAugurEntries();
const entries = $derived(entries$.value);
const recap = $derived(buildYearRecap(entries, year));
function pct(v: number | null): string {
if (v == null) return T.none;
return `${Math.round(v * 100)}%`;
}
function openEntry(e: AugurEntry) {
goto(`/augur/entry/${e.id}`);
}
</script>
<div class="recap">
<header class="head">
<div class="year">{year}</div>
<h2>{T.title}</h2>
</header>
{#if recap.total === 0}
<p class="empty">{T.emptyYear}</p>
{:else}
<section class="headline">
<div class="big-stat">
<span class="num">{recap.total}</span>
<span class="lbl">{T.yearTotal}</span>
</div>
<div class="big-stat">
<span class="num">{recap.resolved}</span>
<span class="lbl">{T.yearResolved}</span>
</div>
<div class="big-stat highlight">
<span class="num">{pct(recap.hitRate)}</span>
<span class="lbl">{T.yearHitRate}</span>
</div>
</section>
<section class="dist-row">
<div class="dist">
<h4>{T.distKind}</h4>
<ul>
{#each Object.entries(recap.byKind) as [k, n] (k)}
<li>
<span class="dist-label">{KIND_LABELS[k as keyof typeof KIND_LABELS].de}</span>
<span class="dist-num">{n}</span>
</li>
{/each}
</ul>
</div>
<div class="dist">
<h4>{T.distVibe}</h4>
<ul>
{#each Object.entries(recap.byVibe) as [v, n] (v)}
<li>
<span class="dist-dot" style:background={VIBE_COLORS[v as keyof typeof VIBE_COLORS]}
></span>
<span class="dist-label">{VIBE_LABELS[v as keyof typeof VIBE_LABELS].de}</span>
<span class="dist-num">{n}</span>
</li>
{/each}
</ul>
</div>
<div class="dist">
<h4>{T.distOutcome}</h4>
<ul>
{#each Object.entries(recap.byOutcome) as [o, n] (o)}
<li>
<span class="dist-label">{OUTCOME_LABELS[o as keyof typeof OUTCOME_LABELS].de}</span>
<span class="dist-num">{n}</span>
</li>
{/each}
</ul>
</div>
</section>
<section class="forecaster-row">
<div class="forecaster best">
<h4>{T.bestSource}</h4>
{#if recap.bestSource}
<div class="fc-name">
{SOURCE_CATEGORY_LABELS[recap.bestSource.sourceCategory].de}
</div>
<div class="fc-num">{pct(recap.bestSource.hitRate)}</div>
<div class="fc-meta">{recap.bestSource.fulfilled} {T.hitOf} {recap.bestSource.n}</div>
{:else}
<p class="fc-empty">{T.none}</p>
{/if}
</div>
<div class="forecaster worst">
<h4>{T.worstSource}</h4>
{#if recap.worstSource}
<div class="fc-name">
{SOURCE_CATEGORY_LABELS[recap.worstSource.sourceCategory].de}
</div>
<div class="fc-num">{pct(recap.worstSource.hitRate)}</div>
<div class="fc-meta">{recap.worstSource.fulfilled} {T.hitOf} {recap.worstSource.n}</div>
{:else}
<p class="fc-empty">{T.none}</p>
{/if}
</div>
</section>
{#if recap.topCategories.length > 0}
<section class="block">
<h3>{T.topSources}</h3>
<ul class="ranked-list">
{#each recap.topCategories as cat (cat.category)}
<li>
<span class="rk-name">{SOURCE_CATEGORY_LABELS[cat.category].de}</span>
<span class="rk-bar"
><span
class="rk-bar-fill"
style:width="{Math.min(100, (cat.n / recap.total) * 100)}%"
></span></span
>
<span class="rk-meta">{cat.n} · {pct(cat.hitRate)}</span>
</li>
{/each}
</ul>
</section>
{/if}
{#if recap.mostFulfilled.length > 0}
<section class="block">
<h3>{T.mostFulfilled}</h3>
<div class="card-grid">
{#each recap.mostFulfilled as entry (entry.id)}
<EntryCard {entry} onclick={openEntry} />
{/each}
</div>
</section>
{/if}
{#if recap.mostSurprising.length > 0}
<section class="block">
<h3>{T.mostSurprising}</h3>
<div class="card-grid">
{#each recap.mostSurprising as entry (entry.id)}
<EntryCard {entry} onclick={openEntry} />
{/each}
</div>
</section>
{/if}
{/if}
</div>
<style>
.recap {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1.5rem 1rem 3rem;
max-width: 64rem;
margin: 0 auto;
width: 100%;
}
.head {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
}
.year {
font-size: 3rem;
font-weight: 200;
letter-spacing: -0.02em;
color: #c4b5fd;
}
.head h2 {
margin: 0;
font-size: 1.1rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
}
.empty {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
}
.headline {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: 0.75rem;
}
.big-stat {
padding: 1.25rem 1rem;
border-radius: 0.85rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
display: flex;
flex-direction: column;
gap: 0.2rem;
align-items: center;
text-align: center;
}
.big-stat.highlight {
border-color: color-mix(in srgb, #7c3aed 65%, transparent);
background: color-mix(in srgb, #7c3aed 16%, transparent);
}
.big-stat .num {
font-size: 2rem;
font-weight: 600;
color: var(--color-text, inherit);
}
.big-stat .lbl {
font-size: 0.78rem;
text-transform: lowercase;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
}
.dist-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.75rem;
}
.dist {
padding: 0.85rem 1rem;
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.03));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
}
.dist h4 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
}
.dist ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.dist li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.dist-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
flex-shrink: 0;
}
.dist-label {
flex: 1;
color: var(--color-text, inherit);
}
.dist-num {
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
font-variant-numeric: tabular-nums;
}
.forecaster-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 0.75rem;
}
.forecaster {
padding: 1rem 1.1rem;
border-radius: 0.85rem;
background: var(--color-surface, rgba(255, 255, 255, 0.03));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
}
.forecaster.best {
border-left: 3px solid #10b981;
}
.forecaster.worst {
border-left: 3px solid #ef4444;
}
.forecaster h4 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
}
.fc-name {
font-size: 1rem;
font-weight: 500;
}
.fc-num {
font-size: 1.65rem;
font-weight: 600;
margin-top: 0.2rem;
}
.fc-meta {
font-size: 0.82rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
}
.fc-empty {
margin: 0;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
}
.block {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.block h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.ranked-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.ranked-list li {
display: grid;
grid-template-columns: 8rem 1fr auto;
gap: 0.65rem;
align-items: center;
font-size: 0.9rem;
}
.rk-name {
color: var(--color-text, inherit);
}
.rk-bar {
height: 0.55rem;
border-radius: 999px;
background: var(--color-surface-muted, rgba(255, 255, 255, 0.05));
overflow: hidden;
}
.rk-bar-fill {
display: block;
height: 100%;
background: linear-gradient(to right, #7c3aed, #c4b5fd);
}
.rk-meta {
font-size: 0.82rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(20rem, 100%), 1fr));
gap: 0.85rem;
}
</style>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import ListView from '$lib/modules/augur/ListView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Augur - Mana</title>
</svelte:head>
<RoutePage appId="augur">
<ListView navigate={() => {}} goBack={() => history.back()} params={{}} />
</RoutePage>

View file

@ -0,0 +1,51 @@
<script lang="ts">
import { page } from '$app/state';
import DetailView from '$lib/modules/augur/views/DetailView.svelte';
import { useAllAugurEntries } from '$lib/modules/augur/queries';
import { RoutePage } from '$lib/components/shell';
const entries$ = useAllAugurEntries();
const entry = $derived(entries$.value.find((e) => e.id === page.params.id));
const T = {
fallbackTitle: 'Augur',
routeTitle: 'Zeichen',
loading: 'laedt ...',
notFound: 'Eintrag nicht gefunden.',
backLink: '← zurueck',
} as const;
</script>
<svelte:head>
<title>{entry?.source ?? T.fallbackTitle} - Mana</title>
</svelte:head>
<RoutePage appId="augur" backHref="/augur" title={T.routeTitle}>
{#if entries$.loading}
<p class="state">{T.loading}</p>
{:else if !entry}
<div class="state">
<p>{T.notFound}</p>
<a href="/augur">{T.backLink}</a>
</div>
{:else}
<DetailView {entry} />
{/if}
</RoutePage>
<style>
.state {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
}
.state a {
display: inline-block;
margin-top: 0.5rem;
color: #c4b5fd;
text-decoration: none;
}
.state a:hover {
text-decoration: underline;
}
</style>

View file

@ -0,0 +1,48 @@
<script lang="ts">
import { page } from '$app/state';
import YearRecapView from '$lib/modules/augur/views/YearRecapView.svelte';
import { RoutePage } from '$lib/components/shell';
const T = {
title: 'Jahresrueckblick',
invalid: 'Ungueltiges Jahr.',
back: '← zurueck',
} as const;
const year = $derived.by(() => {
const raw = page.params.year;
if (!raw) return null;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 2000 || parsed > 2100) return null;
return parsed;
});
</script>
<svelte:head>
<title>{year ?? T.title} - Augur - Mana</title>
</svelte:head>
<RoutePage appId="augur" backHref="/augur?mode=oracle" title={T.title}>
{#if year == null}
<div class="state">
<p>{T.invalid}</p>
<a href="/augur">{T.back}</a>
</div>
{:else}
<YearRecapView {year} />
{/if}
</RoutePage>
<style>
.state {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
}
.state a {
display: inline-block;
margin-top: 0.5rem;
color: #c4b5fd;
text-decoration: none;
}
</style>

View file

@ -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

376
docs/plans/augur-module.md Normal file
View file

@ -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 — M1M5.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`

View file

@ -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,
},
],
},
];
// ═══════════════════════════════════════════════════════════════

View file

@ -86,6 +86,13 @@ const comicSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="
// reads as "clothing" at any scale.
const wardrobeSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#wardrobeGrad)"/><path d="M512 246c-34 0-62 28-62 62 0 15 6 28 15 37l-113 58c-14 7-22 22-22 37v40l-40 28c-10 7-13 20-8 31l20 40c5 10 16 16 27 13l46-12v256c0 18 14 32 32 32h250c18 0 32-14 32-32V580l46 12c11 3 22-3 27-13l20-40c5-11 2-24-8-31l-40-28v-40c0-15-8-30-22-37l-113-58c9-9 15-22 15-37 0-34-28-62-62-62zm0 44c18 0 32 14 32 32s-14 32-32 32-32-14-32-32 14-32 32-32z" fill="white"/><path d="M420 450c0 50 41 90 92 90s92-40 92-90" stroke="#be185d" stroke-width="6" stroke-linecap="round" fill="none" stroke-opacity="0.25"/><defs><linearGradient id="wardrobeGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#e11d48"/><stop offset="1" stop-color="#a21caf"/></linearGradient></defs></svg>`;
// 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 = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#augurGrad)"/><path d="M260 512c0 0 112-148 252-148s252 148 252 148-112 148-252 148S260 512 260 512z" fill="white"/><circle cx="512" cy="512" r="86" fill="#4338ca"/><circle cx="512" cy="512" r="34" fill="#0f0a3d"/><path d="M512 470l8 26h27l-22 16 8 26-21-16-21 16 8-26-22-16h27z" fill="white"/><circle cx="320" cy="320" r="14" fill="white" fill-opacity="0.55"/><circle cx="700" cy="290" r="10" fill="white" fill-opacity="0.45"/><circle cx="740" cy="720" r="12" fill="white" fill-opacity="0.5"/><circle cx="290" cy="700" r="8" fill="white" fill-opacity="0.4"/><defs><linearGradient id="augurGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#4338ca"/><stop offset="1" stop-color="#7c3aed"/></linearGradient></defs></svg>`;
/**
* App icons as data URLs
* Use these directly in <img src={APP_ICONS.memoro}> 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),

View file

@ -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',