Commit graph

1976 commits

Author SHA1 Message Date
Till JS
968e08059f chore(workbench): delete dead SceneTabs component
SceneTabs.svelte had no remaining importers — the homepage
switched to SceneAppBar long ago, and nothing else in the repo
referenced it (grep for imports comes back empty). It still
carried its own rendering logic, context menu, and rename-dialog
wiring, which kept drifting from the real bar (e.g. the scene
icon field removal yesterday had to touch it even though the
file is never mounted). Remove it outright; any future compact
scene tab ever needed can be re-added fresh against the current
store shape.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:34:54 +02:00
Till JS
32f4c0d10d feat(page-carousel): empty-state message when scene has no apps
When the scene has zero apps the carousel used to show just a
small "+ Hinzufügen" button centered in 60vh of whitespace —
visually underpowered and easy to miss on mobile. Replace with a
proper empty state:

- Heading: "Diese Szene ist leer"
- Hint line: "Füge eine App hinzu, um loszulegen — oder drücke [0]"
- The existing add-card button sits underneath

The kbd chip styles match the layout's typical keyboard hint
pattern and quietly teach the '0' shortcut that already opens
the picker (registered at +page.svelte keybind handler).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:34:39 +02:00
Till JS
db959b6f8f feat(workbench): auto-scroll on scene switch, unify rename to inline
Two tightly coupled UX fixes on the homepage:

1. Switching scenes via SceneAppBar used to leave the carousel
   parked wherever it was before — the new scene's SceneHeader
   appeared off-screen to the left, and the user saw a seemingly
   stale row of cards until they manually scrolled. Now an
   $effect watches activeSceneId, tracks the last seen value, and
   smooth-scrolls the .fokus-track to left=0 on every real change
   (ignoring the initial hydration tick so we don't fight the
   carousel's own centering).

2. Scene rename had two concurrent paths: the SceneHeader
   contenteditable <h1> (live DOM) and a SceneRenameDialog modal
   opened from the scene-pill context menu (reads from the store).
   If a user was mid-edit inline and right-clicked Umbenennen,
   the dialog opened with the pre-edit value and on save clobbered
   the inline change. The modal is gone. The context-menu
   "Umbenennen" entry now switches to the target scene, scrolls
   the carousel to the header, and focuses the contenteditable
   after a 120ms tick so the scroll has time to start. Single
   source of truth, single code path.

SceneRenameDialog.svelte + the sceneDialog state machine in
+page.svelte are removed. ConfirmDialog (used for delete) is
untouched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:34:22 +02:00
Till JS
f06ca2c7c3 feat(ai-missions): inline AiProposalInbox in mission detail (cross-module)
Mission detail now renders all pending proposals for that mission,
across every module, directly above the iteration list. No more jumping
between /todo, /news, /calendar to approve what the agent staged.

AiProposalInbox.module is now optional; when omitted, every card grows
a small lowercase module badge (e.g. "news", "todo") so the user knows
where each proposal will land on approve. The existing per-module
inboxes on /todo, /news, /calendar still work — proposals show in both
places via the same live query, so approving in one auto-clears the
other.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:33:06 +02:00
Till JS
988c17a678 feat(quiz): edit existing questions + wire up guest seed
- Pencil button on each question opens the bottom form pre-filled;
  submit updates in place instead of appending.
- Guest users now see the demo quiz on first visit (QUIZ_GUEST_SEED
  registered with seedAllGuestData).
- Silence state_referenced_locally warnings with svelte-ignore to
  match the pattern used in cards / landing / rsvp views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:24:53 +02:00
Till JS
3b99356464 feat(quiz): new Quiz module — build & play private quizzes (Phase 1)
Four question types (single/multi/truefalse/text), inline editor,
play view with per-question feedback + final score review. Attempts
are persisted per quiz. Encrypted at rest: title/description/tags on
the container, questionText/explanation/options on questions.
Attempts stay plaintext. Dexie v21, appId 'quiz', tier 'guest'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:54:07 +02:00
Till JS
8b6b73627c feat(page-carousel): five-preset width picker replaces drag handle
PageShell drops the bottom-right drag handle and its pointer/touch
tracking entirely, and adds a small header button (ArrowsOutLineHorizontal)
that opens a menu with five discrete widths: XS 340 · S 440 · M 540 ·
L 720 · XL 960. Each entry shows its label and pixel value and
highlights whichever one is currently closest to the persisted
widthPx — so legacy freehand values (e.g. 823) still light up the
nearest entry when the menu first opens.

New module width-presets.ts holds the preset array and a
nearestPresetIndex() helper so the same snapping logic isn't
duplicated between PageShell and the store.

Also drops the .page-shell.resizing style (and the `resizing`
state, the shellEl ref, and the MIN/MAX width/height constants)
that only existed to colour the shell while a drag was active.

The five presets cover the useful width range without wasting
space on tiny viewport-sized differences; anyone wanting an
uncommon width can adjust widthPx directly in Dexie for now.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:37:19 +02:00
Till JS
681136266b refactor(workbench): drop per-card height and free-form resize
Height was practically unused — most WorkbenchSceneApp rows had
heightPx: undefined and scrolled internally, and the few explicit
values nobody ever revisited were just noise in the sync ledger.
Same goes for pixel-precise drag: users would produce widths like
823px and then never touch them again. Both paths come out in
favour of a fixed set of five width presets (next commit) that
guarantee cards sit on sensible sizes without a decision per card.

- types/workbench-scenes: heightPx removed from WorkbenchSceneApp
- page-carousel/types: heightPx removed from CarouselPage
- stores/workbench-scenes: resizeApp(appId, widthPx) loses its
  height param; patchScene allowlist no longer lists heightPx.
  Existing Dexie rows with heightPx set simply stop being read
  — the field ages out the next time the row is written.
- PageCarousel: placeholder renders width-only; comment updated
- AppPage: heightPx prop + onResize height param gone
- +page.svelte: carouselPages no longer carries heightPx, handler
  signature narrowed to (id, widthPx)

This commit removes the data; the next commit rewires PageShell's
UI so the drag handle is replaced by the five-preset picker.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:37:03 +02:00
Till JS
bc77b36234 feat(agents): Agent CRUD + default bootstrap + Mission.agentId (Phase 2)
Second phase of the Multi-Agent Workbench rollout (docs/plans/
multi-agent-workbench.md). Builds on Phase 1's identity-aware Actor.

Adds the Agent primitive — a named AI persona that owns Missions,
carries its own policy + memory, and (from Phase 3 on) drives the
Workbench lens. Everything is wired; a single user currently has one
"Mana" default agent until the UI (Phase 5) lets them create more.

Shared types (@mana/shared-ai):
- agents/types.ts: Agent, AgentState, DEFAULT_AGENT_ID/NAME constants
- policy/types.ts: AiPolicy + PolicyDecision (moved from webapp so
  Agent.policy can reference it without a runtime dep on the web app)
- missions/types.ts: new optional Mission.agentId field

Webapp data layer:
- data/ai/agents/{types,store,queries,bootstrap}.ts
- Dexie schema v19 adds `agents` table (indexes on state, name,
  [state+name]); sync registered under the existing ai app-id
- Encryption registry: agents.systemPrompt + agents.memory encrypted;
  name/role/avatar/policy stay plaintext for search + UI rendering
- DuplicateAgentNameError thrown at write time (not a Dexie unique
  index — bootstrap races between tabs would otherwise hit
  ConstraintError; store now resolves via getOrCreateAgent)
- bootstrap.ts: ensureDefaultAgent + backfillMissionsAgentId. The
  backfill runs once per device (localStorage sentinel) so missions
  that pre-date the rollout get stamped with the default agent's id.
  Called fire-and-forget from startMissionTick() during layout init.

Runner threading (already merged into d5c351d63 via Till's debug-log
commit that picked up my uncommitted edits):
- runner.ts + server-iteration-staging.ts now resolve mission.agentId
  to the real Agent and build makeAgentActor with agent.name as
  displayName. Missing-agent fallback keeps using LEGACY_AI_PRINCIPAL
  so historical writes still attribute cleanly.

Tests: shared-ai 26/26, mana-ai 35/35, svelte-check 0 errors.
Agent store vitest suite is present but blocked by a pre-existing
\$lib alias resolution issue in the webapp vitest config that
predates this phase (proposals/store.test.ts is broken the same way
on HEAD). Will address separately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:35:49 +02:00
Till JS
d5c351d63e feat(ai): per-iteration debug log — capture prompt + response + inputs
New local-only Dexie table _aiDebugLog (v20, never synced) holds one
row per mission iteration with the full system+user prompt, raw LLM
response, latency, every ResolvedInput the planner saw, and pre-step
state (kontext-injected? web-research-ok-or-error?). Capped at 50
newest rows.

aiPlanTask always returns the captured prompt/response on AiPlanOutput.
debug; the runner persists it only when isAiDebugEnabled() — toggled
via a checkbox in the Mission detail header (defaults to on in DEV
builds, off in prod, override via localStorage 'mana.ai.debug').

New <AiDebugBlock> component renders below each iteration card:
expandable sections for Pre-Step, Resolved Inputs (each input
individually collapsible), System Prompt, User Prompt, Raw Response,
plus a "📋 JSON" copy-to-clipboard button for bug reports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:33:17 +02:00
Till JS
6e842a83c9 fix(workbench): set SceneHeader text via refs instead of inline mustache
Prettier kept reformatting the <h1>/<p> bodies onto their own
indented lines, which contenteditable renders verbatim — the user
saw leading/trailing whitespace inside the edit buffer.
prettier-ignore only applies to the immediately next node, and the
svelte-ignore comment was taking that slot, so the directive never
reached the element.

Rewire with bind:this + two $effects that set textContent whenever
the scene prop changes and the element isn't currently focused.
Side benefit: a scene name synced from another device now updates
the header live without interrupting an in-progress edit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:16:21 +02:00
Till JS
e2ea0cd3b8 fix(workbench): tighten SceneHeader spacing and keep contenteditable clean
Two small follow-ups to the inline-edit header:

- Reduce the right padding from 2.5rem to 0.25rem so the scene name
  sits visually adjacent to the first card. With the carousel's own
  1rem gap between flex children the total breathing room is now
  ~20px instead of ~56px, which matches how hero titles usually
  relate to the cards below / beside them.
- prettier-ignore on the <h1> and <p> element blocks. Prettier's
  default Svelte formatting moved {scene.name} onto its own line
  with indentation whitespace, which contenteditable renders
  verbatim as literal leading / trailing spaces in the editor
  content. Keep the mustache on the same line as the tag so the
  text value is what the store actually holds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:14:31 +02:00
Till JS
1771063df4 refactor(actor): identity-aware Actor for Multi-Agent Workbench (Phase 1)
Foundation for the Multi-Agent Workbench roadmap
(docs/plans/multi-agent-workbench.md). Every event, record, and
sync_changes row now carries a principal identity + cached display
name in addition to the three-kind discriminator.

Shape change (source of truth in @mana/shared-ai):
  Before: { kind: 'user' | 'ai' | 'system', ...kind-specific fields }
  After:  discriminated union on kind, with
            - common:  principalId, displayName
            - 'user':  principalId = userId
            - 'ai':    principalId = agentId + missionId/iterationId/rationale
            - 'system': principalId = one of SYSTEM_* sentinel strings
                        ('system:projection', 'system:mission-runner', etc.)

Key design calls (from the plan's Q&A):
- System sub-sources get distinct principalIds (not a shared 'system'
  bucket) — lets Workbench filter + revert distinguish projection
  writes from migration writes from server-iteration writes
- displayName cached on the record so renaming an agent doesn't
  rewrite history
- normalizeActor() compat shim fills principalId/displayName on
  legacy rows with 'legacy:*' sentinels so historical events never
  crash the timeline

New exports:
- BaseActor / UserActor / AiActor / SystemActor (narrowed types)
- makeUserActor, makeAgentActor, makeSystemActor (factories with
  typed return)
- SYSTEM_PROJECTION, SYSTEM_RULE, SYSTEM_MIGRATION, SYSTEM_STREAM,
  SYSTEM_MISSION_RUNNER (principalId constants)
- LEGACY_USER_PRINCIPAL, LEGACY_AI_PRINCIPAL, LEGACY_SYSTEM_PRINCIPAL
- isUserActor / isFromMissionRunner predicates

Webapp:
- data/events/actor.ts now re-exports from shared-ai, keeps runtime
  ambient-context (runAs, getCurrentActor) local
- bindDefaultUser(userId, displayName) lets the auth layer replace
  the legacy placeholder with the real logged-in user actor at login
- Mission runner + server-iteration-staging stamp LEGACY_AI_PRINCIPAL
  as the agentId placeholder — Phase 2 will thread the real agent
- Streaks projection uses makeSystemActor(SYSTEM_PROJECTION)
- All test fixtures migrated to factories

Service:
- mana-ai/db/iteration-writer.ts stamps makeSystemActor(
  SYSTEM_MISSION_RUNNER) instead of the old { kind:'system',
  source:'mission-runner' } shape. Phase 3 will switch this to an
  agent actor per mission.

Tests: 26 shared-ai + 21 webapp vitest + 35 mana-ai — all green.
svelte-check: 0 errors, 0 warnings.

No behavior change; purely a type + shape upgrade. Old sync_changes
rows parse via the normalizeActor compat shim at read time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:13:57 +02:00
Till JS
f7b5c9b3a4 feat(workbench): inline-edit the scene header, no modal
SceneHeader switches from a button-that-opens-a-dialog to inline
contenteditable fields. Click the title → caret appears, type →
Enter commits, Escape reverts, blur commits. Same for the
description except Enter is allowed (multi-line).

- contenteditable="plaintext-only" keeps pasted rich text from
  leaking styles into the store
- onfocus selects the existing text so the first keystroke
  replaces rather than appends (matches the expected rename feel)
- :empty-based ::before placeholder shows "Beschreibung
  hinzufügen…" on blank descriptions, including while focused
  (slightly dimmer) so the edit target is still visible before
  the first character
- Empty name on blur reverts to the previous value (empty scene
  names aren't a valid state the rest of the UI handles)
- Empty description commits as null, matching the store's
  setSceneDescription contract

handleEditActiveScene and the SceneHeader.onEdit prop are gone —
the dialog is no longer reachable from the header. It stays
hooked up to SceneTabs' right-click → Umbenennen path for users
who prefer the modal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:13:41 +02:00
Till JS
db8e681120 feat(workbench): render scene header left of the first page
New SceneHeader component: big scene name (clamp 2.75rem–4.5rem
responsive) plus a muted description underneath, or an italic
"Beschreibung hinzufügen…" placeholder when empty. The whole block
is a button — clicking it opens the existing scene edit dialog,
now pulling double duty for both name and description.

Wired through PageCarousel's new leading snippet from the previous
commit, so the header scrolls with the track and stays anchored to
the visual start of the carousel without needing a second scroll
container.

SceneRenameDialog grows a description textarea (maxlength 240,
3 rows, vertically resizable) and onSubmit now passes (name,
description). The caller translates an empty description to null
so the DB column reflects "no description set" rather than an
empty string — keeps WorkbenchScene.description truthy checks
honest.

handleEditActiveScene resolves the currently-active scene and
opens the dialog pre-filled; used by the SceneHeader click.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:42:20 +02:00
Till JS
8f3ffefdf1 feat(page-carousel): optional leading snippet before first page
Consumers of PageCarousel can now pass a \`leading\` Snippet that
renders as the first flex child inside .fokus-track, ahead of the
page wrappers. Used on the workbench homepage for the scene header
(name + description). Scrolls with the track rather than sticking
in place — reads as an intro block, not app chrome, and doesn't
steal viewport from the cards on narrow screens.

Styled as flex-aligned, align-self:stretch so its intrinsic layout
decides the height and it centres vertically against the cards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:42:04 +02:00
Till JS
714c235798 feat(workbench): scene description field, drop scene icon
Schema + store preparation for the new scene header on the homepage
(next commit). Scenes get an optional free-text description stamped
as its own field — LWW through the existing mana-sync pipeline, no
new sync contract. The unused icon field is removed everywhere:

- types/workbench-scenes.ts: description?: string | null replaces icon
- stores/workbench-scenes.svelte.ts: createScene, renameScene,
  duplicateScene, toScene, patchScene all updated. New method
  setSceneDescription(id, value) mirrors renameScene so the caller
  can change just the description without re-submitting the name.
- components/workbench/scenes/SceneTabs.svelte: the tab-bar rendered
  {#if scene.icon} before the name — scene.name is unique enough to
  identify a scene, and the UI direction is away from emoji chrome.

Existing scene rows in Dexie simply omit description (undefined → null
on read); the icon field on old rows is ignored and will age out of
the schema the next time the row is written.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:41:50 +02:00
Till JS
0da74587ce fix(ai): surface web-research failures to the planner instead of hallucinating
When the pre-step research call throws (mana-search down, missing tier,
402 credits, etc.), the runner used to swallow the error and feed an
empty input to the planner — which then made up a story about a "failed
web search" and fell back to create_task. Now we inject an explicit
"research failed" ResolvedInput with the actual error message, plus
write the truncated message into phaseDetail so it's visible in the
mission card without DevTools.

Bundles an in-flight actor refactor merge in runner.ts (makeAgentActor
+ LEGACY_AI_PRINCIPAL) — those lines came from the parallel Phase-1
identity work, not this fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:23:59 +02:00
Till JS
fdb8e60d07 feat(ai): web-research pre-step + auto-kontext + save_news_article tool
Mission objectives matching /recherch|research|news|finde|suche|aktuelle|neueste/i
trigger a synchronous deep-research call (mana-search + mana-llm via the
existing /api/v1/research/start-sync pipeline) before the planner runs;
the summary plus top-8 source URLs are injected as a synthetic ResolvedInput
so the planner can stage save_news_article proposals against real URLs.

The kontext singleton is auto-attached to every mission's planner input
(decrypted client-side, gated on non-empty content + not already linked).

save_news_article is a new proposable tool routed through articlesStore
.saveFromUrl (Readability via /api/v1/news/extract/save). AiProposalInbox
mounted on /news so the user can approve/reject inline. mana-ai planner
tool list mirrors the new tool to keep the boot-time drift guard happy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:10:13 +02:00
Till JS
eaf97aeebf fix(api): unblock tsc by dropping rootDir and allowing .ts imports
Running pnpm type-check inside apps/api failed before any real
error could run, blocked by two structural errors: drizzle.presi.config.ts
and scripts/generate-who-dossiers.ts are deliberately outside src/
but are matched by the include pattern, tripping TS6059 against
rootDir=src. And @mana/shared-types imports peer files with explicit
.ts extensions, which needs allowImportingTsExtensions under
moduleResolution=bundler.

Remove rootDir (we're noEmit anyway — Bun runs src/index.ts
directly, tsc is only a lint pass), drop the unused outDir, add
noEmit explicitly, and enable allowImportingTsExtensions. Type-check
now completes cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:51:26 +02:00
Till JS
6b1e8e878e fix(web): guard PwaUpdatePrompt against SSR navigator access
useRegisterSW() reaches for navigator.serviceWorker at call time, which
crashes SvelteKit SSR with "ReferenceError: navigator is not defined"
(Node has no navigator). The prod mana-web container crash-looped on
every request after the rebuild because the layout mounts this
component unconditionally.

Fix: branch on \$app/environment's \`browser\` flag. On the server,
hand back a noop writable + async-noop updater so the downstream
template code stays unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:33:35 +02:00
Till JS
98347cfd89 fix(web): add workbox-window as explicit devDependency
vite-plugin-pwa's \`virtual:pwa-register/svelte\` imports workbox-window
at build time. The package was resolved transitively via @vite-pwa/
sveltekit but not installed in the webapp's own node_modules when
pnpm fetches only the workspace deps in a restricted Docker context.
Result: Rollup 'failed to resolve import "workbox-window"' at build.

Pinning the direct dep so the Docker build picks it up in the
filtered pnpm install.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:12:33 +02:00
Till JS
eb30d49501 fix(web-docker): copy packages/shared-ai into build context
Webapp package.json gained a @mana/shared-ai workspace dep (Mission
Grant types + canonical HKDF derivation). Without the package in the
Dockerfile COPY list, pnpm install aborts with
ERR_PNPM_WORKSPACE_PKG_NOT_FOUND. Caught during the first mana-web
rebuild after the Mission Grant rollout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:07:41 +02:00
Till JS
2497a65937 feat(ai-missions): richer error surfacing + retry button on failed runs
Replaces the single-line summary ("Planner failed: fetch …") with
full diagnostic detail: error name + message + last-active phase +
stack trace, all persisted onto the iteration itself. UI expands a
collapsed details block next to each failed iteration, so the user
can see *where* it broke ("TypeError in calling-llm") without opening
DevTools.

Paired with a one-click Retry button that re-runs the mission under
the same config — useful while debugging a flaky backend (GPU server
down, Gemini quota, etc.).

- `packages/shared-ai/src/missions/types.ts` — new
  `MissionIteration.errorDetails: { name, message, phase?, stack? }`
- `finishIteration` accepts the field, deep-clones it, and also now
  clears the transient phase markers (currentPhase/phaseStartedAt/
  phaseDetail/cancelRequested) whenever an iteration finalises — keeps
  the schema honest (phases are sub-state of \`running\` only).
- `runMission` tracks \`lastPhase\` via a new \`enterPhase\` helper that
  wraps setIterationPhase. The catch handler populates errorDetails
  with lastPhase + message + stack.
- ListView: \`<details>\` block under each failed iteration + Retry
  button (disabled while another run is in-flight).

77/77 webapp tests still green; svelte-check clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:37:15 +02:00
Till JS
f0f5b7dcf6 fix(infra): relocate mana-ai from 3066 to 3067 — port clash with news-ingester
news-ingester already owns 3066 (see docker-compose.macmini.yml:1620).
Moving mana-ai to 3067 — the next free slot in the 306x services block
(credits 3061, user 3062, subscriptions 3063, analytics 3064,
events 3065, news-ingester 3066, mana-ai 3067).

Updated: Dockerfile EXPOSE + HEALTHCHECK, config.ts default,
compose service/healthcheck/port mapping, webapp getManaAiUrl()
fallback, root CLAUDE.md service list, mana-ai/CLAUDE.md, and
COMPANION_BRAIN_ARCHITECTURE.md §20 file map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:32:07 +02:00
Till JS
6acb044230 feat(kontext,notes): cross-module handoff — save Kontext as a Note
Wires the "Als Notiz speichern" action at the bottom of the Kontext
widget (UI itself landed in 003f75f7e) to actually open Notes next
to Kontext and focus the new note:

- workbench-scenes: new addAppAfter(appId, anchorAppId). addApp()
  always appended, which pushed Notes to the far end of the
  carousel; addAppAfter inserts directly after the anchor (Kontext)
  and no-ops if the target is already open so the user's current
  position isn't yanked around.
- notes/stores/selection: new transient in-memory focus signal
  (focusedNoteId) that cross-module callers populate. Kept
  non-persistent intentionally — surviving a remount would re-open
  random notes after page loads.
- notes/ListView: $effect reads focusedNoteId, waits for the
  Dexie liveQuery to surface the just-created row, opens it in
  the inline editor, clears the focus signal, then scrolls the
  matching data-note-id element into view via queueMicrotask so
  the DOM has rendered the editor variant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:24:56 +02:00
Till JS
12072c6b6c feat(kontext): URL import helpers — API client + appendContent
Frontend plumbing for the Kontext "Aus URL" inline panel (the UI
itself was committed earlier as part of 003f75f7e):

- kontext/api.ts: new module-scoped fetch wrapper that talks to
  /api/v1/context/import-url with a Bearer token. Kept in kontext/
  rather than a shared helper because it's the only caller; moving
  it to `context/` would mix two unrelated modules again.
- kontext/stores/kontext.svelte.ts: new appendContent(chunk) method.
  The singleton row is encrypted at rest, so we decrypt the current
  content before concatenating with a '\n\n---\n\n' separator and
  writing back — going through setContent() to keep encryption +
  Dexie hook behaviour consistent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:24:38 +02:00
Till JS
121a0c0a6f feat(api): POST /api/v1/context/import-url — crawler + optional LLM summary
New backend endpoint that wraps mana-crawler + mana-llm in a single
call so the Kontext "Aus URL" UI can hit one route:

- Starts a crawl job (single page or up-to-20-page deep crawl) via
  mana-crawler's /api/v1/crawl, polls status up to 90s, then fetches
  paginated results.
- When multiple pages are returned, joins them into one markdown
  document with H1-per-page section headers separated by ---.
- When summarize=true, routes the collected markdown through
  mana-llm/chat/completions with a system prompt that asks for
  "Überblick / Kernaussagen / Details" H2 structure in the source
  language. sanitizeSummary() strips the common local-LLM artefacts
  (```markdown fences, "Hier ist …:" preamble, stray leading H1)
  so the output drops cleanly into the Kontext doc. On summary
  failure the endpoint returns 502 rather than silently falling
  back to the raw crawl.
- Credits are validated + consumed via @mana/shared-hono/credits
  (1 credit crawl-only, 5 crawl+summary) under the new
  AI_CONTEXT_IMPORT_URL action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:24:19 +02:00
Till JS
003f75f7ef chore(web): unblock pre-push hook
Pre-push runs svelte-check --fail-on-warnings. Two items were blocking:

- +layout.svelte: drop four .pill-nav-toggle* rules that have no
  matching markup anywhere in the app (dead CSS from the pill-nav
  rework that removed the collapse toggle).
- kontext KontextView.svelte: drop the explicit `: Phase[]` annotation
  on `let importPhases = \$derived.by(...)`. Svelte 5's runes return a
  wrapped type that TypeScript reads as a function when the annotation
  is present, which is what produced the "not callable" + "state
  invalid placement" chain. Inferred type is the same Phase[] shape.

No runtime behaviour change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:21:35 +02:00
Till JS
ef47adb7d7 feat(ai-missions): live phase + elapsed + cancel for running iterations
Closes the "iteration is running, no feedback" black hole. The user now
sees, per running iteration:

    Frage Planner · frage Planner an              ⏱ 23s
                                              [Abbrechen]

Phases (\`IterationPhase\`):
  resolving-inputs → calling-llm → parsing-response →
  staging-proposals → finalizing

The runner advances through these via \`setIterationPhase\` between each
await, writing currentPhase + phaseDetail + phaseStartedAt onto the
iteration. UI reads them via Dexie liveQuery — no polling.

Cancel:
- \`requestIterationCancel\` writes cancelRequested=true on the iteration
- runner polls \`isCancelRequested\` between every phase + per stage step
- cancellation finalises as \`failed\` with summary \`'cancelled by user'\`
- UI button is disabled + relabelled "Wird abgebrochen…" until the next
  poll picks it up

Hard timeout: 90 s wall-clock per iteration via Promise.race against a
CancelledError. Wedged backends (e.g. flaky mana-llm) fail fast with
"timeout after 90s" instead of sitting in \`running\` forever.

Elapsed counter is a \$state variable ticking once a second, scoped to
the ListView component — Dexie isn't touched. Auto-cleaned on
component destroy.

shared-ai re-exports \`IterationPhase\` so server-side mana-ai can
inspect the same phase enum (no consumer there yet, but the type is
ready for the run-status endpoint planned in HEALTH page).

77/77 webapp tests still green; svelte-check clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:15:48 +02:00
Till JS
bb3da78d5c feat(ai): Mission Grant rollout gating — flag, alerts, runbook, user docs
Phase 4 — everything needed to flip the Mission Key-Grant feature on
safely per deployment. No new behaviour; purely operational plumbing.

- PUBLIC_AI_MISSION_GRANTS feature flag (default off). hooks.server.ts
  injects window.__PUBLIC_AI_MISSION_GRANTS__, api/config.ts exposes
  isMissionGrantsEnabled(). Grant UI (dialog + status box) and the
  Workbench "Datenzugriff" tab both hide when the flag is off.
- PUBLIC_MANA_AI_URL added to the injection set so the webapp can reach
  the new audit endpoint from production.
- Prometheus alerts (new mana_ai_alerts group):
  - ManaAIServiceDown (warning, 2m)
  - ManaAIGrantScopeViolation (critical, 0m) — MUST stay at 0; any
    increment pages immediately
  - ManaAIGrantSkipsHigh (warning, 15m) — flags keypair drift
  - ManaAIPlannerParseFailures (warning, 10m) — prompt/LLM drift
- Runbook in docs/plans/ai-mission-key-grant.md: initial keypair gen,
  leak-response procedure (rotate + invalidate all grants + audit),
  scope-violation triage.
- User-facing doc in apps/docs security.mdx: new "AI Mission Grants"
  section with the three hard constraints (ZK users blocked, scope
  changes invalidate cryptographically, revocation is one click) plus
  an honest threat-model comparison column showing where grants shift
  the tradeoff.

Rollout remaining (not code): generate keypair on Mac Mini, provision
MANA_AI_PRIVATE_KEY_PEM + MANA_AI_PUBLIC_KEY_PEM via Docker secrets,
flip PUBLIC_AI_MISSION_GRANTS=true starting with till-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:02:47 +02:00
Till JS
74bbfda212 feat(ai): Mission Grant consent UI + Workbench audit tab
Phase 3 — user-facing side of the Mission Key-Grant rollout. Users
can now opt into server-side execution, revoke it, and inspect every
decrypt the runner has performed.

Webapp:
- MissionGrantDialog explains the scope (record count, tables, TTL,
  audit visibility, revocation) and calls requestMissionGrant. Error
  paths render distinctly for ZK, not-configured, missing vault.
- Mission detail shows a Server-Zugriff box with status pill
  (aktiv/abgelaufen/nicht erteilt), Neu-erteilen + Zurückziehen
  buttons. Only renders for missions with at least one encrypted-
  table input.
- store.ts: setMissionGrant / revokeMissionGrant helpers, Proxy-
  stripped like the rest of the store's writes.
- Workbench adds a Timeline/Datenzugriff tab switch. Audit tab queries
  the new GET /api/v1/me/ai-audit endpoint, renders decrypt events
  with color-coded status pills (ok/failed/scope-violation) and
  stable reason strings.
- getManaAiUrl() added to api/config for the audit fetch.

mana-ai:
- GET /api/v1/me/ai-audit (JWT-gated via shared-hono authMiddleware)
  backed by readDecryptAudit() — withUser + RLS double-gate so a user
  can only read their own rows.
- Limit capped at 1000, newest-first.

Missions without a grant continue to work exactly as before; the
grant UI is purely additive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:53:11 +02:00
Till JS
4b29f6d293 fix(ai-missions): swap structuredClone for JSON-roundtrip deepClone
Browser \`structuredClone\` itself fails on Svelte 5 \$state Proxies
("Failed to execute 'structuredClone' on 'Window': [object Array]
could not be cloned") — it doesn't transparently unwrap the Proxy the
way I'd hoped. The structured-clone algorithm refuses any non-cloneable
host object, including Svelte's reactive wrappers.

JSON.parse(JSON.stringify(...)) traverses through the Proxy by reading
each property normally, producing a plain-data copy that Dexie can
serialise without complaint.

Mission payloads are pure JSON values (strings/numbers/arrays/objects)
so JSON-roundtrip is lossless. The new \`deepClone\` helper is local to
this file with a comment pointing at the structured-clone failure.

77/77 webapp tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:50:05 +02:00
Till JS
394931e3b3 fix(ai-missions): strip Svelte \$state Proxies before Dexie writes
`createMission`, `updateMission`, and `{start,finish}Iteration` all
received caller-supplied objects that can be Svelte 5 \$state Proxies
(MissionInputPicker binds the inputs array with \$state). IndexedDB's
structured-clone algorithm doesn't accept proxied arrays and throws
`DataCloneError: [object Array] could not be cloned` — visible to
users as "Mission anlegen" failing silently after clicking Create.

Wrap each proxy-carrying payload in `structuredClone()` at the store
boundary:
  - createMission: `inputs` + `cadence`
  - updateMission: whole `patch` (anything can be proxy)
  - startIteration: `plan`
  - finishIteration: `plan` (conditional)

`structuredClone` is the native browser / Bun helper; strips Proxies
while preserving Dates / Maps / Sets / nested plain data. Store stays
robust to any future caller that forgets to snapshot before passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:44:05 +02:00
Till JS
9809b06adf feat(app-registry): new 'AI' category at top of the app picker
Groups the six AI-Workbench apps plus the Companion chat under a
dedicated category, moved to order 0 so they're the first thing a user
sees when adding a page. Brain icon.

- `categories.ts`
  - New `ai` type added to AppCategory union at order 0
  - Old 'companion' category stays for derived projections (myday,
    activity, goals) that aren't themselves AI-driven — renamed doc
    only, behavior unchanged
  - APP_CATEGORY_MAP reassigns: companion, ai-missions, ai-workbench,
    ai-rituals, ai-policy, ai-insights, ai-health → 'ai'
- `AppPagePicker.svelte` collapsed-state map gains `ai: false` so the
  new category is expanded by default (it's the user's new front door)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:34:07 +02:00
Till JS
37e39a5ddb feat(ai): AI features as top-level workbench apps (not sub-routes)
Reverts the previous /companion-carousel misstep. The user's model is
that Missions / Workbench / Rituals / Policy / Insights / Health each
live as their OWN app in the root `/` workbench scene, alongside todo /
calendar / notes / etc. — openable from the normal app picker, freely
combinable with any other module card.

- New modules, each with a ListView.svelte usable inside AppPage's
  PageShell (no self-wrapping, no shell-control props):
    `lib/modules/ai-missions/ListView.svelte`
    `lib/modules/ai-workbench/ListView.svelte`
    `lib/modules/ai-rituals/ListView.svelte`
    `lib/modules/ai-policy/ListView.svelte`
    `lib/modules/ai-insights/ListView.svelte`
    `lib/modules/ai-health/ListView.svelte`
- Registered in `app-registry/apps.ts`: `ai-missions`, `ai-workbench`,
  `ai-rituals`, `ai-policy`, `ai-insights`, `ai-health`. Each picks a
  distinct color + icon.
- `/companion/+page.svelte` restored to the simple chat it was before.
  The companion app (chat) remains as its own registered app —
  unchanged.
- Removed: `lib/modules/companion/pages/` + the
  `workbench-settings.svelte.ts` store (both were the dead-end
  PageCarousel approach).

User model now:
  ` / ` (workbench root) → [+ App] → any of the 6 AI apps + any module
  /companion                         → full-screen chat (unchanged)
  /todo, /calendar, …                → module-inline ghost inbox stays

svelte-check clean; webapp AI tests 71/71 green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:23:20 +02:00
Till JS
9686198a16 feat(companion): refactor into PageCarousel — every AI feature is a page
Drops the split /companion/missions + /companion/workbench +
/companion/rituals sub-routes and rebuilds /companion around the
shared PageCarousel pattern (same one /todo uses). Every feature is
now a self-contained page the user opens/closes/reorders/resizes in
one unified surface.

New pages:
- **Home** (default) — compact stats + one-click shortcuts to every
  other page, with "offen" badge when already open
- **Chat** — conversation sidebar + active chat inline
- **Missions** — list ↔ create ↔ detail master-detail inside one pane
- **Workbench** — timeline grouped by iteration + Revert per bucket,
  Mission filter dropdown (replaces the old ?mission=… query-param)
- **Rituals** — migrated from /companion/rituals
- **Policy** — NEW: 3-way per-tool toggle (auto/propose/deny) with
  localStorage-backed overrides merged into DEFAULT_AI_POLICY live
- **Insights** — NEW: approval rate, 14-day bar chart of AI events,
  per-mission stats, top-5 recurring user-feedback strings. All from
  local Dexie liveQueries, no server calls.
- **Health** — NEW: foreground runner status, manual tick trigger,
  link out to status.mana.how for server-runner uptime

Plumbing:
- `stores/workbench-settings.svelte.ts` — persistent open-pages list
  in localStorage (id + widthPx + heightPx + maximized); open/close/
  resize/moveLeft/moveRight helpers
- `pages/page-meta.ts` — central registry (title, color, icon,
  description) consumed by home shortcuts + PagePicker
- `pages/PagePicker.svelte` — lists available (not-yet-open) pages
  with icon + description
- Old sub-routes deleted: /companion/{missions,workbench,rituals}/

Webapp tests still 71/71 green; svelte-check clean on the new pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:11:16 +02:00
Till JS
ce944ef14f docs(ai): document observability + Revert + full scope in webapp CLAUDE.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:53:37 +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
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
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
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
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
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
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