Commit graph

3051 commits

Author SHA1 Message Date
Till JS
5d4bf201fd refactor(shared-ui): migrate PillNav nav items to shared Pill component
PillNavigation rendered three near-identical inline pill blocks (prepended
elements, main nav items, appended elements). Consolidate onto the Pill
component so the visual base stays in lockstep with the bottom-stack bars.

- Extend Pill with size='sm'|'md'. sm = 36px with 18px icons (PillNav
  style); md = 44px with 20px icons (bar pills, default).
- Move the icon-only padding override into Pill itself.
- Extract the Mana-Logo SVG (duplicated inline) to ManaLogoIcon.svelte.
- Replace the three inline pill loops in PillNavigation with <Pill size='sm'>.
  Mana-logo and iconSvg cases ride the `leading` snippet. onClick vs href
  disambiguation is collapsed into a single Pill call per item.
- Remove the now-unreachable .pill-icon scoped CSS that was only meaningful
  for the removed inline SVGs (Phosphor icon sizing comes from the size
  prop).

Net: ~70 lines removed from PillNavigation.svelte without changing the
render output. Bar-mode triggers (sync / ai / theme / user) still render
inline because their logic is too entangled with activeBarId — leave for
a follow-up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:25:18 +02:00
Till JS
a047f6cb7c docs(ai): Revert-per-iteration shipped in Workbench
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:19:16 +02:00
Till JS
ad5f670ec2 feat(ai): revert-per-iteration button in the Workbench timeline
Each iteration bucket now carries a Revert button that undoes every AI
write attributed to that iteration. Closes the last open Workbench
feature in the roadmap.

- `data/ai/revert/inverse-operations.ts` — pluggable registry mapping
  event types to their undo actions. Ships with inverses for the five
  most common proposable outcomes:
  * TaskCreated        → tasksStore.deleteTask
  * TaskCompleted      → tasksStore.toggleComplete (back to incomplete)
  * CalendarEventCreated → eventsStore.deleteEvent
  * PlaceCreated       → placesStore.deletePlace
  * DrinkLogged        → drinkStore.deleteEntry
  Events with no registered inverse are tallied as `skippedUnsupported`
  — user knows to handle those manually rather than the service
  silently doing nothing.
- `data/ai/revert/revert-iteration.ts` — orchestrator. Filters
  `_events` by `actor.iterationId + actor.missionId`, sorts
  newest-first (so a completion unwinds before the underlying task
  deletion), applies each inverse, returns `RevertStats` summary.
- Workbench UI: Revert button with confirm dialog on every bucket.
  Shows "X zurückgenommen · Y nicht unterstützt · Z fehlgeschlagen"
  result alert.
- 5 unit tests cover: happy path, unsupported types, failure isolation,
  user-event skipping, newest-first ordering.

With this, the AI Workbench has full audit + undo semantics: user sees
everything the AI did, can approve/reject at stage time, and can roll
back approved actions after the fact.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:18:14 +02:00
Till JS
99efb93816 refactor(arcade, shared-ui): migrate CommandBar to GlobalSpotlight, delete CommandBar
CommandBar was a near-duplicate of QuickInputBar's InputBar with the UX
of a Cmd+K modal. Only arcade still used it. Migrate arcade onto the
existing GlobalSpotlight (hosted by PillNavigation) so there is a single
Cmd+K modal across all mana apps, then remove CommandBar entirely.

Arcade changes (games/arcade/apps/web/src/routes/(app)/+layout.svelte):
- Merge commandBarQuickActions into spotlightActions (nav-level items)
- Convert handleCommandBarSearch into a ContentSearcher that returns the
  game list grouped under a single 'Spiele' category
- Drop the standalone Cmd+K handler; PillNavigation + GlobalSpotlight
  handle the shortcut
- Remove the <CommandBar> render; add `contentSearcher` +
  spotlightPlaceholder to <PillNavigation>

shared-ui cleanup:
- Delete packages/shared-ui/src/command-bar/ (CommandBar.svelte,
  CommandBar.types.ts, index.ts)
- Drop CommandBar / CommandBarItem from the public @mana/shared-ui export
- Delete docs/central-services/COMMAND-BAR.md (stale)

No more duplication: highlight + debounce live in search-core, and the
only remaining Cmd+K surface is GlobalSpotlight.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:17:55 +02:00
Till JS
9bc77dd3b9 docs(mana-ai): contract test + RLS scoping shipped; narrow remaining work
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:07:10 +02:00
Till JS
24eb8b3b7f refactor(shared-ui): extract search-core for highlight + debounce
Both QuickInputBar (InputBar.svelte), CommandBar.svelte, and GlobalSpotlight
were duplicating syntax highlighting and the 150ms search debounce. Pull
these into a new `packages/shared-ui/src/search-core/` module so the two
input surfaces stay in sync on feel and matching rules.

- search-core/highlight.ts — HighlightPattern type, locale-aware
  getHighlightPatterns(), and the shared highlightText() (HTML-escape +
  span wrap). Patterns were previously in quick-input/highlightPatterns.ts
  + inline in CommandBar.svelte.
- search-core/config.ts — SEARCH_DEBOUNCE_MS = 150. Used from InputBar,
  CommandBar, GlobalSpotlight, and apps/mana web SearchEngine.
- quick-input/highlightPatterns.ts + types.ts become thin back-compat
  re-exports.
- Public surface: @mana/shared-ui now exports getHighlightPatterns,
  highlightText, SEARCH_DEBOUNCE_MS, and the HighlightPattern type.

No UX change. UIs still live in their own files (per earlier split
recommendation: shared backend, separate surfaces).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:06:37 +02:00
Till JS
ad1659f036 refactor(mana-ai): RLS-scope mission reads via per-user two-phase query
Closes the "cross-user scan" caveat on the mission read path. The
earlier implementation pulled every aiMissions row server-wide and
partitioned by user_id in memory — fine for a pre-launch single-user
deploy, not a cross-user infrastructure.

New flow:

  1. `listMissionUsers(sql)` — one cross-user DISTINCT query. This is
     the ONLY surface that still reads across users; documented as
     requiring BYPASSRLS on the service's DB role (or ownership without
     FORCE).
  2. `listDueMissionsForUser(sql, userId, now)` — RLS-scoped via
     `withUser(sql, userId, tx => ...)` just like the write path in
     `iteration-writer.ts`. Defense-in-depth: even if the SELECT mis-
     filters, RLS drops any row whose user_id doesn't match the session
     setting.
  3. `listDueMissions(sql, now)` — two-phase composition of the above.

The LWW merge + due-filter logic is factored out into a pure
`mergeAndFilter(rows, userId, now)`. Fully unit-tested (6 Bun cases):
active-due happy-path, future nextRunAt, non-active state, delete
tombstone, multi-row LWW merge, userId stamping.

Matches the pattern already in use for writes (`db/connection.ts:withUser`
+ `db/iteration-writer.ts`). Docstring on `listMissionUsers` spells out
the remaining BYPASSRLS dependency so ops knows what role the service
needs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:06:17 +02:00
Till JS
b03bbe132e feat(shared-ui): unify bottom-stack bars with shared Pill component
- Extract Pill.svelte as the single visual primitive (44px, icon+label,
  active/primary/danger variants) used by PillDropdownBar and TagStrip.
  PillNav keeps its own internal .pill class (36px, icon-only-oriented).
- Extract phosphor-icon-map.ts to deduplicate the icon lookup tables
  that previously lived inline in PillDropdownBar.
- Unify bar slot heights in (app)/+layout.svelte: 56px PillNav,
  64px for tags / quickinput / tabbar / dropdown-bar. Remove debug
  outlines. Collapse bottom-stack gap so bars sit flush below PillNav.
- SceneAppBar wrapped in 64px slot, scene-pill/app-tab 40px to match.
- Enforce single-bar policy: opening one bar closes the others.
- QuickInputBar strip-down: remove leading CheckSquare icon and trailing
  nav-toggle snippet; bar is pure search input now.
- Move user-menu (last PillNav pill) to bar-mode with short content:
  Einstellungen, Light/Dark/System segmented, Theme, Logout.
- Swap tabs nav icon from Columns to Cards for better readability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:58:46 +02:00
Till JS
ce646550cd fix(ai): bubble proposal-reject feedback into Mission iteration
The Planner reads `mission.iterations[].userFeedback` — not
`pendingProposals.userFeedback` — so feedback stored only on the
proposal row never reached the next plan. rejectProposal now also
appends to the matching iteration's `userFeedback` (merging with any
existing reasons via "\n· " bullets) when the proposal carries
missionId + iterationId.

Lazy imports the mission store to avoid a proposal↔mission cycle.
Best-effort: proposal rejection still succeeds even if the bubble fails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:57:25 +02:00
Till JS
98547b9e4e feat(ai): freitext feedback on proposal rejection
Rejecting a ghost-card proposal now reveals an inline textarea. Trimmed
feedback lands in `proposal.userFeedback` where the next Planner
iteration already reads it via iteration history — the AI learns from
concrete rejection reasons without needing a settings UI.

- Click "Ablehnen" → textarea + Cancel/Reject buttons replace the
  approve/reject row
- Empty submission still rejects (userFeedback stays undefined so the
  Planner sees "no reason given" rather than an empty string)
- `rejectingId` + `rejectDraft` are per-inbox local state; only one
  reject form is open at a time

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:56:39 +02:00
Till JS
9bc44c075a feat(ai): roll out AiProposalInbox to calendar / places / drink / food
One-line drop-in per module page. Each module's proposals now render as
ghost cards inline wherever the user already expects to see that
module's content — no separate approvals inbox to hunt for.

Covers all four remaining modules with proposable tools in
AI_PROPOSABLE_TOOL_NAMES:
- calendar → create_event
- places → create_place, visit_place
- drink → undo_drink
- food → (auto-tools only today; the inbox is ready for future
  propose-class food tools)

Now the only modules with proposable tools NOT showing the inbox are
those that don't have their own /route (all covered by todo, calendar,
places, drink, food).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:54:59 +02:00
Till JS
4d6e6e61b4 feat(mana-web): keyboard shortcuts for workbench + nav bars
- 1–9 scroll to the Nth open app on the workbench homepage; 0 opens the
  app picker.
- q/w/e toggle the bottom bars (workbench tabs / search / tags); r opens
  the user-menu PillDropdownBar (expanding the PillNav first if needed);
  t toggles the PillNav visibility.

Adds a `data-user-menu-trigger` hook on the user pill so the layout can
drive the menu bar programmatically without duplicating its config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:53:42 +02:00
Till JS
4be5e29bd3 feat(shared-ai): canonical proposable-tool list + drift guard on mana-ai
Makes the webapp's AI policy and the server's tool allow-list physically
impossible to drift. Adds the missing entries the guard caught on first
run: `complete_tasks_by_title`, `visit_place`, `undo_drink` now have
parameter schemas server-side too.

- `packages/shared-ai/src/policy/proposable-tools.ts`
  - `AI_PROPOSABLE_TOOL_NAMES` as `const` array + literal union type
  - `AI_PROPOSABLE_TOOL_SET` for set-membership checks
- Webapp `DEFAULT_AI_POLICY` derives its `propose` entries from the
  shared list via `Object.fromEntries(...)` — adding a tool there is now
  a one-line change in `@mana/shared-ai`
- mana-ai `AI_AVAILABLE_TOOLS`: module-load assertion compares its
  hardcoded names against `AI_PROPOSABLE_TOOL_SET` and throws with a
  pointed error on drift (extras in one direction, missing in the
  other). Service refuses to start on mismatch — better than silent
  degradation.
- Bun test (`tools.test.ts`) runs the same contract plus sanity checks
  (non-empty description, required params carry docs). Vitest policy
  test adds the symmetric check on the webapp side.

All three runtimes now green: webapp 66/66, shared-ai 2/2,
mana-ai 9/9 Bun tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:52:38 +02:00
Till JS
dccd9c5c4e docs(mana-ai): server-side resolvers shipped; document plaintext-only scope
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:45:39 +02:00
Till JS
4b67316343 feat(ai): add tasks + calendar events as Mission inputs (webapp side)
Mission-Input-Picker now surfaces open tasks + upcoming calendar events
alongside notes / kontext / goals. When the foreground runner runs, the
corresponding resolvers decrypt the records client-side and hand real
content (title + due date / event time + description) into the Planner
prompt.

- `tasksResolver` + `tasksIndexer` — reads unencrypted subset + decrypts
  via `decryptRecords('tasks', …)`. Picker shows only OPEN tasks (not
  completed ones) to keep the list relevant. Resolver output is
  `[status]{ · fällig date}{ \n description}` — terse by design.
- `calendarResolver` + `calendarIndexer` — similarly decrypts events;
  picker prioritizes upcoming events (sorted by startIso), shows
  near-term times as "bald: YYYY-MM-DDTHH:MM" to make recency obvious
- Both tables are encrypted client-side — server-side mana-ai resolvers
  remain intentionally absent (per the privacy contract in
  `services/mana-ai/src/db/resolvers/types.ts`)

With this, a user can create a Mission like "plan my week" and link a
few tasks + calendar events as context; the in-browser planner sees the
full picture (decrypted), while the off-tab runner still plans from
objective + concept only for those missions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:44:59 +02:00
Till JS
a8425941fb feat(mana-ai): server-side input resolvers (goals for now)
Plugs plaintext-safe Mission context into the Planner prompt per tick.
Before this, `resolvedInputs: []` was always passed — the LLM only saw
the mission's concept + objective. Now goals (the only plaintext
category of linked inputs today) resolve and land in the prompt.

Privacy constraint is explicit and documented: tables in the webapp's
encryption registry (notes, kontext, journal, dreams, …) arrive at
`sync_changes.data` as ciphertext — the master key lives in mana-auth
KEK-wrapped and never reaches this service. Resolvers for encrypted
modules therefore don't exist server-side; missions referencing them
should use the foreground runner which decrypts client-side.

- `db/resolvers/types.ts` — ServerInputResolver contract
- `db/resolvers/record-replay.ts` — single-record LWW replay
  (tighter WHERE than `missions-projection.ts`, used by all resolvers)
- `db/resolvers/goals.ts` — reads `companionGoals` via replayRecord,
  mirrors the webapp's default goalsResolver output shape
- `db/resolvers/index.ts` — registry with `registerServerResolver` /
  `unregisterServerResolver` / `resolveServerInputs`. Seeds `goals`.
  Drift-tolerant: missions pointing at unregistered modules silently
  skip those inputs.
- `cron/tick.ts` — wires `resolveServerInputs(sql, m.inputs, m.userId)`
  into the planner input; updates the outdated "stubbed" comment

5 Bun tests over the registry (handled + unhandled + thrown +
mixed cases + seeded default).

Future: expand to plaintext tables if/when more land (habits without
free-text, dashboard configs, tags), or introduce a decrypt-via-auth
sidecar if users opt into server-side access to encrypted content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:42:45 +02:00
Till JS
39b24b2c68 docs(ai): mark Step 9 complete — close-the-loop shipped in v0.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:30:31 +02:00
Till JS
5e01763caa feat(ai): close the loop — server write-back + webapp staging effect
Completes the off-tab AI pipeline. mana-ai now writes produced plans
back to `sync_changes` as a server-sourced Mission iteration; the webapp
picks it up on next sync and translates each PlanStep into a local
Proposal via the existing createProposal flow. User sees the resulting
ghost cards in the matching module's AiProposalInbox with full mission
attribution.

Server (mana-ai v0.3):
- `db/connection.ts` — `withUser(sql, userId, fn)` RLS-scoped tx helper
  mirroring the Go `withUser` pattern (SET LOCAL app.current_user_id)
- `db/iteration-writer.ts`
  - `planToIteration(plan, id, now)` — shared-ai AiPlanOutput → inline
    MissionIteration with `source: 'server'` + status='awaiting-review'
  - `appendServerIteration(sql, input)` — INSERT sync_changes row with
    op=update, data={iterations: [...]} + field_timestamps + actor
    JSONB={kind:'system', source:'mission-runner'}
- `cron/tick.ts` — after parse success: build iteration, append to
  mission.iterations, persist via appendServerIteration. Stats now
  include `plansWrittenBack`.

Actor union:
- `packages/shared-ai/src/actor.ts` + webapp actor: `system.source` gains
  `'mission-runner'` so the server's own writes are attributed correctly
  and distinguishable from projection/rule writes

Webapp:
- `data/ai/missions/server-iteration-staging.ts`
  - `startServerIterationStaging()` subscribes to aiMissions via Dexie
    liveQuery; on each Mission update, walks iterations looking for
    `source='server'` entries that haven't been staged yet
  - For each such iteration: creates a Proposal per PlanStep under
    `{kind:'ai', missionId, iterationId, rationale}` so policy + hooks
    fire correctly
  - Writes proposalIds back into plan[].proposalId + status='staged' so
    other tabs and app restarts skip re-staging
  - Idempotent: in-memory `processedIterations` Set + durable
    proposalId marker
- Wired into (app)/+layout.svelte alongside startMissionTick
- 3 unit tests: translate server iteration → proposal, skip
  already-staged, ignore browser iterations

Full pipeline now: user creates Mission in /companion/missions →
mana-ai tick picks it up → calls mana-llm → parses plan →
writes iteration → synced to webapp → staging effect creates
proposals → user approves in /todo (or any module) → task lands with
`{actor: ai, missionId, iterationId, rationale}` attribution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:29:30 +02:00
Till JS
7e17142bb3 docs(mana-ai): bump status to v0.2 — plans end-to-end, write-back open
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:07:01 +02:00
Till JS
203fe3ef05 feat(mana-ai): wire shared-ai planner + real mana-llm calls (v0.2)
Service now produces plans end-to-end for due missions. Takes the
shared prompt/parser from @mana/shared-ai, calls mana-llm's
OpenAI-compatible endpoint, parses + validates the response against a
server-side tool allow-list.

- `src/planner/tools.ts` — hardcoded subset of webapp tools where
  policy === 'propose'. Mirror of `DEFAULT_AI_POLICY` in the webapp;
  drift just means the server doesn't suggest newly-added tools
  (graceful degradation). Contract test between the two lists is a
  sensible follow-up.
- `src/cron/tick.ts`
  - Iterates due missions, builds the shared Planner prompt per mission,
    parses the LLM response, logs the resulting plan
  - Per-mission try/catch so one flaky LLM response doesn't abort the
    queue; stats now track `plansProduced` + `parseFailures`
  - `serverMissionToSharedMission()` converts the projection shape to
    the shared-ai Mission type at the boundary
- `resolvedInputs: []` today — the Planner sees concept + objective +
  iteration history only. Full resolvers (notes/kontext/goals via
  Postgres replay) land alongside write-back in the next PR.
- No write-back yet: the plan is logged but not persisted to
  `sync_changes`. Write-back needs an RLS-scoped helper mirroring
  mana-sync's `withUser` pattern — tracked explicitly as the remaining
  open piece in CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:06:22 +02:00
Till JS
0d90b12d1c feat(shared-ai): extract planner + mission types to @mana/shared-ai
Single source of truth for AI Workbench types shared between the webapp
(Vite/SvelteKit) and the server-side mana-ai Bun service. Prevents the
two runtimes from drifting on prompt shape or mission structure.

- `@mana/shared-ai` package:
  - `actor.ts` — Actor union (user | ai | system) + helpers, mirrors the
    webapp's runtime type so server-side consumers parse incoming actors
    without re-declaring
  - `missions/types.ts` — Mission, MissionCadence, MissionInputRef,
    MissionIteration, PlanStep, MissionState. Adds optional
    `iteration.source: 'browser' | 'server'` to distinguish foreground
    vs server-produced iterations (groundwork for proposal write-back)
  - `planner/prompt.ts` — `buildPlannerPrompt` pure function
  - `planner/parser.ts` — `parsePlannerResponse` strict JSON validator
  - Vitest smoke tests (2) cover prompt → parse round-trip + unknown-
    tool rejection
- Webapp:
  - `missions/types.ts` re-exports from shared-ai, keeps webapp-local
    `MISSIONS_TABLE` constant + `planStepStatusFromProposal` bridge
  - `missions/planner/{types,prompt,parser}.ts` become re-export stubs
    so existing imports keep working unchanged
  - Existing webapp tests (60) continue to pass — the wire code didn't
    move, just its home

Next: mana-ai service imports buildPlannerPrompt/parsePlannerResponse
from shared-ai + wires mana-llm + writes iteration back as a
'source=server' row (tracked in services/mana-ai/CLAUDE.md).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:01:57 +02:00
Till JS
1191952d0f docs(ai): mark Step 9 scaffolded; register mana-ai in root services list
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:49:22 +02:00
Till JS
b9710e6c11 feat(mana-ai): scaffold server-side Mission Runner (v0.1)
Background Hono/Bun service that scans mana_sync for due Missions and
will plan them via mana-llm without requiring an open browser tab.
Complements the foreground `startMissionTick` in the webapp.

v0.1 scope — scaffold that's deployable, boots cleanly, and reads real
data. Execution write-back is tracked as the next PR so we don't commit
a half-baked proposal-sync design.

Shipped:
- Hono app on :3066 with `/health` + service-key-gated `/internal/tick`
- `src/db/missions-projection.ts` — field-level LWW replay of
  `sync_changes` for appId='ai' / table='aiMissions' → live Mission
  records. Mirrors the webapp's `applyServerChanges` semantics against
  Postgres instead of Dexie.
- `src/db/connection.ts` — bounded `postgres.js` pool (max 4, idle 30s)
- `src/cron/tick.ts` — overlap-guarded scheduler, `runTickOnce()` also
  reachable via HTTP for CI/ops triggering
- `src/planner/client.ts` — mana-llm HTTP client shape
  (OpenAI-compatible `/v1/chat/completions`)
- `src/middleware/service-auth.ts` — X-Service-Key gate, no end-user JWTs
  reach this service
- Dockerfile + graceful SIGTERM shutdown (stops timer + releases pool)

Not yet implemented (documented in CLAUDE.md with design trade-offs):
- Prompt/parser server-side copies — today they live in the webapp.
  Recommended next step: extract `@mana/shared-ai` package.
- Input resolvers for notes / kontext / goals — need projections or a
  mana-sync internal endpoint
- Plan → Mission-iteration write-back + how proposals get back to the
  user's device (leaning option (a): server writes iterations, the
  webapp's sync effect translates them into local Proposals)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:48:30 +02:00
Till JS
5c53c6d02e docs(ai): mark Step 8 (mana-sync actor field) done; document sync_changes.actor
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:37:55 +02:00
Till JS
5922abbbd8 feat(sync): stamp __lastActor + __fieldActors on incoming server changes
Closes the cross-device attribution loop. When another device pushes a
change with `actor: { kind: 'ai', missionId, … }`, the receiving device
now persists that attribution onto the record so the Workbench timeline
and per-module ghost badges render the same way they would on the
originating device.

- `readFieldActors()` sibling helper next to `readFieldTimestamps` for
  reading the per-field actor map off a record
- `applyServerChanges`:
  - Insert-new-record: stamp every field with `change.actor`, set
    `__lastActor` on the whole record
  - Insert-as-upsert: stamp only the winning fields (same LWW condition
    as the timestamp merge), update `__lastActor` to the change actor
  - Field-level update: same per-field + whole-record stamping
  - Pre-actor clients (change.actor undefined) fall back to USER_ACTOR so
    legacy rows still have a valid stamp
- All three paths also add the new hidden keys to their "skip" lists so
  incoming payloads can't smuggle old bookkeeping fields through

With this, the full pipeline is cross-device:
  Device A (AI writes)  → meta.actor + __lastActor + pendingChange.actor
  mana-sync (Go)        → persists actor JSONB on sync_changes row
  Device B (sync pull)  → applyServerChanges re-stamps __lastActor +
                          __fieldActors from the incoming change
  Device B (Workbench)  → renders the AI's activity from `_events` with
                          correct rationale + mission context

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:37:19 +02:00
Till JS
615b1c23c3 feat(sync): thread actor through webapp sync client
Matches the wire contract the Go server just learned to persist. Every
PendingChange now carries the actor through the Dexie row into the POST
payload; SyncChange on the receiving side accepts an opaque actor blob.

- `sync.ts`
  - `SyncChange.actor?: Actor` on the wire type; documented as opaque +
    back-compat with pre-actor clients
  - `PendingChange.actor?: Actor` — the pending-changes row already gets
    an actor stamped by the Dexie hook, the type now reflects it
  - `isValidSyncChange` accepts actor as an object or undefined, never
    asserts internal shape (the payload is opaque by design)
  - Push payload includes `actor: p.actor` alongside the other fields

- `module-registry.test.ts` — `pendingProposals` added to INTERNAL_TABLES.
  It's a local-only staging table that intentionally does NOT sync
  (approved writes run the underlying tool, which syncs normally).

Follow-up still open: when applyServerChanges writes a record from an
incoming change, stamp `__lastActor` + `__fieldActors` from the incoming
actor so the Workbench timeline attributes cross-device writes
correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:35:08 +02:00
Till JS
bfa1c0260f feat(mana-sync): persist actor JSON on every sync_changes row
Adds an opaque JSON `actor` column alongside the existing field_timestamps
so cross-device consumers can distinguish user / ai / system writes. The
server never parses the shape — it just stores and re-emits the blob the
webapp stamped in its Dexie hook.

- `sync/types.go` — Change.Actor as json.RawMessage with omitempty; nil
  for pre-actor clients so wire remains backward-compatible
- `store/postgres.go`
  - Migrate: CREATE TABLE includes `actor JSONB` for fresh DBs;
    ALTER TABLE ADD COLUMN IF NOT EXISTS actor JSONB for existing ones
    (idempotent, safe to re-run)
  - RecordChange signature takes json.RawMessage; pgx writes nil as NULL
  - All three SELECT paths (GetChangesSince, GetAllChangesSince,
    StreamAllUserChanges) return actor, Scan into ChangeRow.Actor
  - ChangeRow.Actor added with doc noting "missing = user" consumer rule
- `sync/handler.go` — Change.Actor threaded through HandleSync →
  RecordChange, and populated on both changeFromRow (pull/POST replies)
  and convertChanges (SSE stream)
- Tests: roundtrip of an AI-actor payload + omitempty verification for
  pre-actor clients. All existing tests still pass.

Webapp types still need `actor?: Actor` on SyncChange + PendingChange to
match the wire, and applyServerChanges needs to stamp __lastActor /
__fieldActors from incoming changes for Workbench attribution on other
devices — both tracked as separate follow-ups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:31:01 +02:00
Till JS
6425135612 docs(ai): mark Workbench + projection system-actor done; renumber roadmap
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:23:41 +02:00
Till JS
90e6d4dcc6 refactor(projections): wrap streak-tracker writes in system actor
Derived-state writes should be attributed to the projection subsystem,
not to whoever triggered the upstream event. `_streakState` is local-
only today so no cross-device user-visible effect, but once any derived
table joins sync this is the only correct model.

- `markActive` and `ensureSeeded` now run under
  `runAsAsync({ kind: 'system', source: 'projection' }, …)`
- Sets the pattern for future projections (DaySnapshot, correlations, …)
  to follow verbatim when they start writing persistently

Closes one of the Step-1 follow-ups tracked in
COMPANION_BRAIN_ARCHITECTURE §20. Remaining:
- mana-sync Go + Postgres migration for the `actor` field
- rule-engine to wrap its future writes the same way (no writes today)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:23:16 +02:00
Till JS
2fe9522953 feat(ai): Workbench timeline — cross-module AI activity lens
Single-page view of everything the AI has done, grouped by mission
iteration. Closes the "what did the assistant actually touch today?"
question that used to require raw event-log spelunking.

- `data/ai/timeline/queries.ts`
  - `useAiTimeline({ missionId?, module?, limit? })` — reactive live
    query over `_events`, filtered to `actor.kind === 'ai'`. Over-fetches
    by 3x and client-filters because `actor.kind` isn't indexed; cap at
    500 entries keeps it cheap.
  - `bucketByIteration(events)` — groups events sharing
    `actor.missionId + actor.iterationId` into a single visual unit so
    the rationale reads once per iteration rather than once per event.
    Pure function, fully unit-tested.
- `routes/(app)/companion/workbench/+page.svelte`
  - Buckets rendered chronologically with mission-link header + rationale
  - Per-event row shows module + event type + payload title + deep-link
    back into the module
  - Module dropdown filter + `?mission=…` query-string for mission-scoped
    views (linked from /companion/missions detail header)
- `/companion` sidebar + missions detail header now link to the Workbench

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:21:04 +02:00
Till JS
bf6b9cdd4b docs(ai): mark Missions UI + input picker (Step 6) done; document resolver/indexer symmetry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:30:20 +02:00
Till JS
41052f769e feat(ai): input picker — link notes / kontext / goals to a Mission
Closes the "blind Planner" gap: users can now attach context records to
a Mission, and the Runner resolves them through the existing resolver
registry before calling the Planner. The LLM sees the actual linked
note content, not just the mission objective.

- `data/ai/missions/input-index.ts` — sibling registry to input-resolvers.
  Resolvers turn a ref into prompt text (Runner path); indexers list
  candidates for the picker UI (create-form path). Same shape, different
  direction; keeps modules decoupled from the AI layer on both ends.
- `data/ai/missions/default-resolvers.ts` — registers indexers for
  notes (title + content preview, capped at 200), kontext (the singleton
  doc), goals (title + progress). Co-located with the resolvers so the
  two halves stay in sync.
- `components/ai/MissionInputPicker.svelte` — drop-in picker: module
  selector → candidate list → chip-style selected display. Binds
  directly to `MissionInputRef[]` so forms use it as a single control.
- `/companion/missions` — picker wired into the create form between
  Konzept and Cadence; detail view's meta block now lists the linked
  inputs so users can see what context the Planner will see.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:25:07 +02:00
Till JS
3a8c019ab0 feat(ai): Missions UI under /companion/missions
Create, review, and control AI Missions from the app. Closes the last
UX gap in the end-to-end pipeline — users no longer need the Dexie
console to drive the Runner.

- `data/ai/missions/queries.ts` — `useMissions({ state? })` live query
  + single-mission `useMission(id)`. Decryption-ready via `decryptRecords`
  wrapper (no-op today, future-proof when/if Missions get added to the
  crypto registry).
- `routes/(app)/companion/missions/+page.svelte`
  - Inline create form: title + objective + markdown concept + cadence
    picker (manual / interval-minutes / daily-hour). Weekly + cron are
    wired in state but not exposed until a richer picker is worth it.
  - List / detail layout, responsive to narrow viewports.
  - Detail view: run-now button (invokes runMission with productionDeps),
    pause/resume/complete/delete lifecycle actions, iteration history
    with per-iteration feedback form.
  - Uses shared-icons + scoped CSS with theme-token fallbacks.
- `routes/(app)/companion/+page.svelte` — footer nav links to
  /companion/missions and /companion/rituals from the chat sidebar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:34:11 +02:00
Till JS
b7a76ca24f docs(ai): mark Planner + Runner (Steps 4+5) as done in Workbench roadmap
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:24:51 +02:00
Till JS
7535480007 feat(ai): production wiring for Mission Runner + default input resolvers
Connects the dependency-injected Runner to the real LlmOrchestrator and
drives it on a foreground tick in the app shell. Registers sensible
default input resolvers so Missions linked to notes / kontext / goals
work without per-module opt-in.

- `data/ai/missions/setup.ts`
  - `productionDeps` wires `aiPlanTask` through `llmOrchestrator.run`
  - `startMissionTick(intervalMs = 60_000)` kicks an immediate run then
    schedules `runDueMissions` on interval. Idempotent + overlap-guarded
    so a slow LLM run can't pile up ticks.
  - `stopMissionTick` clears the interval for teardown / HMR.
- `data/ai/missions/default-resolvers.ts` — resolvers for notes (title +
  decrypted content), kontext (singleton markdown), goals (progress
  projection). Registered once when the tick starts.
- `(app)/+layout.svelte` wires startMissionTick into the idle-phase init
  block alongside startEventStore / startStreakTracker / etc., and
  stopMissionTick into the teardown path.

System is now end-to-end runnable in the browser: create a Mission with
cadence 'interval', wait for the tick, see proposals appear in
/todo's AiProposalInbox. Missions UI (create/edit form) still open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:24:13 +02:00
Till JS
1c6201be50 feat(ai): MissionRunner — orchestrates Planner + proposal staging
Executes one iteration end-to-end: resolve Mission inputs → build the
policy-filtered tool allowlist → invoke the Planner → stage each
PlannedStep as a Proposal (or auto-run if policy says so) → finalize
the iteration with summary + status.

- `data/ai/missions/runner.ts`
  - `runMission(id, deps)` runs a single iteration. Planner + stageStep
    are injected so the Runner is unit-testable without a live LLM.
  - `runDueMissions(now, deps)` scans for active missions past their
    nextRunAt and runs each once. Safe to call on a foreground tick.
  - Reuses the iteration id returned by `startIteration` so
    `finishIteration` updates the same row (fixed a dup-id bug the
    tests caught).
- `data/ai/missions/input-resolvers.ts` — registry: modules register a
  resolver at init, Runner looks up by module name. Missing resolvers
  degrade gracefully to "fewer inputs", never crash a run.
- `data/ai/missions/available-tools.ts` — exposes only tools the AI
  policy rates non-`deny`. Defence-in-depth with the executor + parser.

overallStatus derivation:
  0 steps                         → 'approved'  (no-op run is valid)
  all steps failed                → 'failed'
  any step staged (proposal id)   → 'awaiting-review'
  all steps ran auto              → 'approved'

Planner throw is caught and recorded as a failed iteration — one bad
mission can't stall the queue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:20:29 +02:00
Till JS
72d5c708c4 feat(ai): Mission Planner LLM task + prompt/parser
Turns a Mission (concept + objective + linked inputs + iteration history)
into a structured plan of tool-call proposals via the shared
LlmOrchestrator.

- `data/ai/missions/planner/types.ts` — AiPlanInput, AiPlanOutput,
  PlannedStep, ResolvedInput, AvailableTool
- `data/ai/missions/planner/prompt.ts` — pure builder producing system +
  user messages. System prompt enforces a strict fenced-JSON contract and
  lists available tools with parameter schema. User prompt injects the
  mission content, resolved input records, and the last 3 iterations
  (especially any userFeedback so the planner can course-correct).
- `data/ai/missions/planner/parser.ts` — strict parser with a
  discriminated ParseResult union. Rejects unknown tools, missing
  rationale, malformed shape. Tolerates missing optional fields.
- `llm-tasks/ai-plan.ts` — aiPlanTask LlmTask, minTier 'browser',
  contentClass 'personal'. On parse failure returns an empty plan with
  an explanatory summary rather than throwing, so the Runner can record
  a failed iteration without killing the queue.

No Runner yet — the planner is pure (input in, plan out). Runner (next
commit) will resolve inputs from modules, invoke the task, stage each
PlannedStep as a Proposal under the AI actor, and update the Mission
iteration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:16:10 +02:00
Till JS
d7bf8a2fd4 feat(ai): Mission datamodel — long-lived autonomous work items
Foundational entity for the AI Workbench Runner. A Mission carries the
user's standing instruction (concept + objective), references to the
modules it should draw context from, a cadence, and an append-only
history of iterations. Each iteration records the plan the AI generated
for that run plus the resulting proposal statuses and user feedback.

- `data/ai/missions/types.ts` — Mission, PlanStep, MissionIteration,
  MissionCadence union (manual / interval / daily / weekly / cron)
- `data/ai/missions/cadence.ts` — pure `nextRunForCadence(cadence, from)`
  used by the store on create / update / finishIteration
- `data/ai/missions/store.ts` — CRUD + lifecycle
  (pause / resume / complete / archive / delete) + iteration helpers
  (start / finish / addFeedback)
- `data/ai/module.config.ts` — new `ai` sync app; Missions sync
  cross-device (unlike the local-only `pendingProposals`)
- `db.version(18)` adds the `aiMissions` Dexie store with indexes on
  state, createdAt, nextRunAt, and [state+nextRunAt] for the Runner's
  "due now" query

Iterations live inline on the Mission record — append-only, small N,
always loaded together by the Planner. No separate child table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:10:19 +02:00
Till JS
0f3fd4eebd docs(ai): document Actor attribution + AI Workbench pilot
- COMPANION_BRAIN_ARCHITECTURE §20: Actor model, policy layer,
  pendingProposals lifecycle, ghost-UI pilot, roadmap, open follow-ups,
  manual test snippet
- DATA_LAYER_AUDIT §9: new Actor columns on records
  (`__lastActor`, `__fieldActors`), `pendingProposals` table, write-path
  diagrams for user / AI / approval, open mana-sync Go + Postgres work
- apps/mana/CLAUDE.md: short AI Workbench section with pointers + Dexie
  hook now lists actor stamping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:05:30 +02:00
Till JS
851a281e5a refactor: rename zitare -> quotes (Zitate)
Zitare was opaque Latin/Italian-flavored branding. Renamed to clear
English "quotes" (DE: Zitate) matching short-concrete-noun cluster.

- Module, routes, API, i18n, standalone landing app, plans dirs
- Dexie tables: quotesFavorites, quotesLists, quotesListTags,
  customQuotes (dropped redundant "quotes" prefix on the last)
- Logo QuotesLogo, theme quotes.css, search provider, dashboard
  widget QuoteWidget
- German user-facing label "Zitate" (English brand stays Quotes)

Pre-launch, no data migration needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:59:16 +02:00
Till JS
7a1f11c971 feat(ai): inline proposal inbox in the todo module
First pilot of the AI Workbench ghost-state pattern. A reusable
`<AiProposalInbox module="todo" />` component renders pending proposals
for a given module as dashed-outline ghost cards above the real content —
zero UI when the AI is idle, approve / reject inline when it's not.

- `data/ai/proposals/queries.ts` — reactive `useAiProposals` live query
  with module / status / missionId filters. Module filter resolves via
  the tool registry so each proposal auto-routes to the right page.
- `components/ai/AiProposalInbox.svelte` — the drop-in inbox component.
  Shows tool description + params + AI rationale; approve runs the
  original intent under the AI actor context (preserving attribution),
  reject stores the row with status=rejected for the next planner pass.
- Wired into /todo for the pilot. Other modules opt in by adding one
  line once their tools land in DEFAULT_AI_POLICY.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:58:46 +02:00
Till JS
513e3c7496 fix(types): enable allowImportingTsExtensions, restore .ts on shared-types
The prior dance (93bb94a12 drop .ts, bb278fb3c switch to .js) kept
breaking one consumer or the other:

- bare specifiers (no extension) satisfied svelte-check but broke the
  Node ESM loader invoked via @tailwindcss/node during SSR — SSR of
  every (app) route 500'd with ERR_MODULE_NOT_FOUND on 'theme'.

- .js extensions satisfied svelte-check and Vite but still broke the
  Tailwind loader, because the files on disk are .ts — Node ESM walks
  the actual filesystem and can't rewrite .js → .ts the way tsc does
  at type-check time.

Flip the web app's tsconfig to "allowImportingTsExtensions": true and
put the .ts extensions back. tsc now accepts the imports, and Node's
loader finds the real file on disk. No build step, no emit, and the
shared-types package stays a pure source-only TS workspace.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:54:35 +02:00
Till JS
e38257b93d feat(ai): policy-gated tool executor with pendingProposals lifecycle
Adds the staging layer that turns AI-attributed tool calls into user-reviewed
proposals instead of direct writes.

- `data/ai/policy.ts` — per-tool AiPolicy (`auto` | `propose` | `deny`) with
  module-level defaults and a global fallback. `user` and `system` actors
  always bypass (they ARE the decision / are trusted subsystems).
- `data/ai/proposals/` — Proposal + Intent types, store with
  create/list/approve/reject/expire. Proposals are local-only (do NOT
  sync); the approved write syncs through the normal module path.
- `tools/executor.ts` routes by actor+policy: `auto` runs directly under
  `runAsAsync(actor, ...)`, `propose` stages a Proposal carrying rationale
  + mission metadata, `deny` refuses. `executeToolRaw` bypasses the policy
  gate — used only on the approval path where consent already exists.

Default policy is conservative: read-only and append-only self-state
(log_drink, log_meal) auto-execute; everything that mutates user-visible
records defaults to propose.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:48:53 +02:00
Till JS
d1a0d09692 feat(data): stamp actor on records and pending changes via Dexie hooks
Extends the creating/updating hooks to capture the ambient actor
synchronously and freeze it onto every write:

- `__lastActor` on each record (whole-record attribution for Workbench badges)
- `__fieldActors` parallel to `__fieldTimestamps` (field-level attribution
  for inline diff rendering — e.g. "AI changed due date, user changed title")
- `actor` on `_pendingChanges` rows so mana-sync + cross-device views can
  distinguish AI- vs user-initiated writes

Also adds `kontextDoc` to v17 (missing from schema while module was live)
alongside the new `pendingProposals` table for staged AI intents.

Actor is captured in-hook rather than at emit time because
`trackPendingChange` is deferred via setTimeout and would otherwise lose
ambient context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:48:30 +02:00
Till JS
a18506caf6 feat(events): Actor attribution on every DomainEvent
Introduces a discriminated Actor union (user | ai | system) threaded through
the event pipeline so downstream consumers can distinguish human writes from
AI-initiated ones and derived subsystem writes.

- `EventMeta.actor: Actor` is required (no legacy fallback — pre-launch)
- `emitDomainEvent` takes an options bag `{ actor?, causedBy? }`; falls back
  to the ambient actor set by `runAs` / `runAsAsync`
- `runAs` / `runAsAsync` pin the actor at defined boundaries (tool executor,
  mission runner, projection dispatcher) — primitives capture synchronously
  so ambient context is never SoT past the write moment

Foundation for the AI Workbench. Follow-up: mana-sync server must accept
and persist `actor` in pending-change payloads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:48:03 +02:00
Till JS
2fb2bb60fb feat(kontext): singleton markdown doc with inline editor
New Mana module "Kontext" — one editable markdown document per user,
displayed as a workbench page. Toggle between rendered preview and a
raw textarea editor (Cmd/Ctrl+E); debounced autosave 500 ms.

Content is encrypted at rest (new `kontextDoc` table, singleton row).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:46:04 +02:00
Till JS
bb278fb3cf fix(types): use .js extensions for shared-types re-exports
Commit 93bb94a12 dropped the extensions on shared-types re-exports
to make the web app's svelte-check pass (its tsconfig has no
allowImportingTsExtensions). That satisfied tsc but broke SSR: the
dev server tripped with ERR_MODULE_NOT_FOUND on every (app) route
because Node's native ESM loader (used by downstream tooling like
@tailwindcss/node) cannot resolve bare relative specifiers without
an extension, and only Vite-owned paths got the bundler-style
resolution the fix relied on.

Switch to the TypeScript-ESM idiomatic `.js` extension. tsc with
moduleResolution: "bundler" still type-checks against the actual
.ts source, and at runtime both Vite and Node resolve `.js` the
same way — no tsconfig flag flip required.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:44:18 +02:00
Till JS
66f8e86d59 fix(web): add missing pako dep for backup import
The .mana backup parser in src/lib/data/backup/format.ts imports
inflateRaw from pako but the package was never declared. The
production Vite build fails to resolve it — pnpm let it through
locally only because some other workspace dep hoists pako into
node_modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:29:31 +02:00
Till JS
9a6ccf5076 fix(a11y): clear pre-push svelte-check warnings
- mail/ListView: add a11y_click_events_have_key_events ignore to match
  the existing a11y_no_static_element_interactions suppression
- sleep/MorningLog + companion/CompanionChat: mark intentional
  initial-value state reads with state_referenced_locally ignore
- goals/GoalEditor: add tabindex="-1" to the dialog role element

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:12:14 +02:00
Till JS
93bb94a121 fix(types): drop .ts extensions + narrow Uint8Array buffer type
- shared-types/index.ts: drop .ts import extensions (web-app tsconfig
  has no allowImportingTsExtensions; bundler resolution handles it)
- backup/format.test.ts: narrow buildZip return to Uint8Array<ArrayBuffer>
  so the Blob() constructor accepts it under strict lib.dom

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:07:22 +02:00