Commit graph

3004 commits

Author SHA1 Message Date
Till JS
3b5d58ecbe feat(shared-llm): Phase 4 — persistent LLM task queue
Until now, modules wanting to use the orchestrator had to await each
LLM call inline in their store code. That's fine for foreground tasks
("user clicked summarize") but a non-starter for background work
("auto-tag every new note", "generate a title for every voice memo
after STT finishes"). Background tasks need to:

  - Queue up while no LLM tier is ready, then drain when one becomes
    available (e.g. user just enabled the browser tier from settings)
  - Survive page reloads, browser restarts, and the user navigating
    away mid-execution
  - Run one at a time without blocking the foreground UI
  - Allow modules to subscribe to results reactively without polling
  - Retry transient failures (network, model loading) but not
    semantic ones (tier-too-low, content blocked)

Phase 4 ships exactly that.

Architecture:

  packages/shared-llm/src/queue.ts — LlmTaskQueue class
    + QueuedTask interface (the persistent row shape)
    + EnqueueOptions (refType/refId/priority/maxAttempts)
    + TaskRegistry type (name → LlmTask map)
    + LlmTaskQueueOptions (table + orchestrator + registry +
                           retryBackoffMs + idleWakeupMs)

  Public API:
    - enqueue(task, input, opts) → string  (returns the queued id)
    - get(id), list(filter)
    - retry(id), cancel(id), purge(olderThanMs)
    - start(), stop()  (idempotent processor lifecycle)

  apps/mana/apps/web/src/lib/llm-queue.ts — web app singleton
    - Dedicated `mana-llm-queue` Dexie database (separate from the
      main `mana` IDB; see comment for the rationale: ephemeral
      per-device state, no encryption needed, no sync needed, doesn't
      belong in the long-frozen `mana` schema)
    - Wires up the queue with llmOrchestrator + taskRegistry
    - Exposes startLlmQueue() / stopLlmQueue() for the layout hook

  apps/mana/apps/web/src/lib/llm-task-registry.ts
    - Maps task names → task objects so the queue processor can
      look up the implementation when pulling rows off the table.
      Closures can't be persisted, so we round-trip via name.
    - Currently registers extractDateTask + summarizeTextTask;
      module-side tasks land here as we add them.

  apps/mana/apps/web/src/routes/(app)/+layout.svelte
    - startLlmQueue() in handleAuthReady's Phase A (auth-independent)
      so guests + authenticated users both get the queue
    - stopLlmQueue() in onDestroy as a fire-and-forget cleanup

Processor loop semantics (the heart of the implementation):

  1. On start(), reclaim any 'running' rows from a crashed previous
     session — reset them to 'pending'. The orphan recovery is the
     reason a crash mid-task doesn't leave the queue stuck.
  2. findNextRunnable() picks the highest-priority pending task whose
     `notBefore` (retry-backoff timestamp) is in the past. Sort key:
     priority desc, then enqueuedAt asc (FIFO within priority).
  3. Mark the task running, increment attempts, look up the LlmTask
     in the registry, hand it to orchestrator.run().
  4. On success: mark done, store result + source + finishedAt.
  5. On error:
       - TierTooLowError or ProviderBlockedError → fail immediately,
         no retry. These are not transient — the user's settings or
         the content itself need to change.
       - Anything else → if attempts < maxAttempts, reset to pending
         with notBefore = now + retryBackoffMs (default 60s). Else
         mark failed.
  6. When no work is pending, sleep on a Promise that resolves when
     either (a) someone calls enqueue() (which fires notifyWakeup),
     or (b) idleWakeupMs elapses (default 30s, safety net for any
     missed wakeup signal).

Module-side reactive reads use Dexie liveQuery directly on the queue
table — no special subscription API on the queue itself. This is
consistent with how every other Mana module reads its data, so the
mental model stays uniform:

  const tags = useLiveQuery(
    () => llmQueueDb.tasks
      .where({ refType: 'note', refId, taskName: 'common.extractTags' })
      .reverse().first(),
    [refId]
  );

Smoke test: a new "Queue" tab in /llm-test lets you enqueue the
existing extractDate / summarize tasks and watch the live state of
the queue table via liveQuery. The display includes per-row state
badge (pending/running/done/failed), tier source, attempt count,
input/output, and a "Done/failed löschen" button that exercises
purge().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:51:20 +02:00
Till JS
6e20c298ac fix(mana/web): wrap var(--color-X) usages with hsl() in non-Phase-6 components
After the themes.css rewrite (commit 919fcca4b), --color-X holds raw HSL
channels instead of full hsl() strings. Files using `var(--color-X)` standalone
in scoped CSS were already broken — the value is not a valid CSS color, so
the browser fell back to either the literal hex fallback (`var(--color-X, #...)`)
or to inherited (white in light mode). The Phase 1 rewrite is neutral for
those files (same broken behavior as before), but the now-canonical convention
is to wrap with hsl().

Sweep 11 components/routes that are NOT in the Phase 6 visual rewrite scope:

- Breadcrumbs, KeyboardShortcutsModal
- dashboard/TilePanel, dashboard/TileResizeHandle
- page-carousel/PageCarousel
- workbench/scenes/{ConfirmDialog, SceneRenameDialog, SceneTabs}
- routes/(app)/{llm-test, observatory, spiral}

Mechanical replacement: `var(--color-X[, fallback])` → `hsl(var(--color-X))`.
The hex fallbacks are dropped because :root in themes.css now defines all
--color-X values statically.

TilePanel had two unknown-token references that don't exist in the new
themes.css schema and were silently rendering their hex fallbacks:
  - `--color-text` → `--color-foreground` (semantic synonym)
  - `--color-destructive` → `--color-error` (shadcn name for the same concept)

Skipped from this sweep:
- Files in Phase 6 modules (places, habits, contacts, todo, dreams, finance,
  calendar, notes, photos, automations, cycles, events, zitare) — they will
  be migrated together with their hand-rolled palettes in Phase 6.
- skilltree/types.ts uses --color-branch-{intellect,body,…} tokens that have
  never been defined anywhere — long-standing bug, needs actual brand colors
  added to themes.css to fix properly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:45:34 +02:00
Till JS
2d3c2bfc6f fix(local-llm): set Vite worker.format='es' for code-splitting support
The Phase 3 build failed at the worker bundling step with:
  "Invalid value 'iife' for option 'worker.format' - UMD and IIFE
  output formats are not supported for code-splitting builds."

Vite defaults workers to IIFE format which can't handle code-split
imports. @mana/local-llm's new worker.ts imports @huggingface/
transformers, which itself is internally code-split into many
chunks (the ONNX runtime, the model classes, the tokenizer
families, the lazy backend selectors). IIFE has no way to load
those at runtime.

Switch the web app's vite.config.ts to `worker: { format: 'es' }`.
Module workers are supported in every browser that supports
WebGPU (Chrome 80+, Edge 80+, Safari 15+), so no users lose
support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:33:03 +02:00
Till JS
45f368f471 feat(local-llm): Phase 3 — move inference into a Web Worker
The browser tier of @mana/local-llm was running entirely in the main
JS thread. With Gemma 4 E2B that meant ~50-200 ms of synchronous
tensor work per forward pass × ~150 forward passes per generation =
the UI froze for 10-30 seconds during a single chat reply. Scrolling,
clicks, animations all stopped.

Move the actual inference into a Dedicated Web Worker. The main
thread keeps a thin LocalLLMEngine proxy with the same public API
(load / unload / generate / prompt / extractJson / classify /
onStatusChange / isSupported), so existing callers — the /llm-test
page, the playground module, @mana/shared-llm's BrowserBackend, the
Svelte 5 reactive bindings — need NO changes.

File layout after the split:

  src/engine.ts       — main-thread proxy (lazy worker init,
                        postMessage protocol, pending request map,
                        status broadcast handling, convenience
                        wrappers for prompt/extractJson/classify)
  src/worker.ts       — Web Worker entry point (typed message
                        protocol, single LocalLLMEngineImpl instance,
                        forwards status changes back to main thread)
  src/engine-impl.ts  — the actual transformers.js engine (renamed
                        from the previous engine.ts contents). NOT
                        exported from index.ts — only the worker
                        imports it. Same two-step tokenization,
                        aggregated progress reporting, streaming
                        token handling as before; just running in
                        a different thread now.

Worker construction uses Vite's documented `new Worker(new URL(
'./worker.ts', import.meta.url), { type: 'module' })` pattern, which
makes Vite split worker.ts (and its transformers.js dep) into its
own bundle chunk at build time. The proxy is lazy-init: the Worker
constructor is never touched at module-import time, so SSR stays
clean (Worker doesn't exist on Node).

Message protocol (typed end-to-end):

  Main → Worker:
    { id, type: 'load',     modelKey: ModelKey }
    { id, type: 'unload' }
    { id, type: 'generate', opts: SerializableGenerateOptions }
    { id, type: 'isReady' }

  Worker → Main:
    { id, type: 'result',  data?: unknown }
    { id, type: 'error',   message: string }
    { id, type: 'token',   token: string }       — streaming chunk
    {     type: 'status',  status: LoadingStatus }  — broadcast

The proxy assigns a unique id per request, stores the resolve/reject
+ optional onToken callback in a Map<id, PendingRequest>, and routes
incoming responses by id. Status messages have no id and fire every
registered status listener — same UX as before, just one extra hop.

Streaming: the worker re-attaches the streaming callback on its
side. Each emitted token gets posted back as `{ id, type: 'token',
token }` and the proxy invokes the original `onToken` callback. The
final `result` arrives as a normal response and resolves the
Promise. From the caller's perspective generate() still feels
identical — same async iterable feel via onToken, same return value.

Worker termination on unload: transformers.js doesn't expose a
dispose API, so we terminate the worker after unload and create a
fresh one on the next load. This is the only reliable way to
release VRAM between model swaps.

CSP: no header changes needed. The worker is loaded from a
same-origin URL (Vite emits it as
/_app/immutable/workers/worker.[hash].js), so 'self' in script-src
already covers it. The blob: + cdn.jsdelivr.net + wasm-unsafe-eval
allowlists we added during the original WebLLM/transformers.js
bring-up still apply because the worker still runs the same ONNX
runtime that needed them.

DistributiveOmit type helper: TS's plain `Omit<Union, K>` collapses
discriminated unions to an intersection in some configurations,
which broke the type narrowing at the postRequest call sites for
each request variant. Adding a tiny `DistributiveOmit<T, K>` helper
fixes the type-check without restructuring the protocol.

What this commit deliberately does NOT do:

- Change the public API surface. The whole point is that callers
  remain untouched.
- Add multi-tab worker coordination via SharedWorker or
  BroadcastChannel. Each tab still spawns its own dedicated worker
  with its own copy of the model in VRAM. Multi-tab dedup is
  Phase 2.5/Phase 4 work — see the design doc summary in the
  previous Phase 1 commit message.
- Add a persistent task queue. Fire-and-forget background tasks
  are Phase 4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:27:10 +02:00
Till JS
919fcca4b7 refactor(shared-tailwind): rewrite themes.css to single-layer shadcn convention
Pre-launch theme system audit found multiple parallel layers in themes.css
(--theme-X full hsl strings, --X partial shadcn aliases, --color-X populated
by runtime store with raw channels) plus dead-code companion files. The
inconsistency caused light-mode regressions when scoped-CSS consumers
wrote `var(--color-X)` standalone — the variable holds raw HSL channels
which is invalid as a color value, browser fell back to inherited (white).

Rewrite to one consistent layer:

  - Source of truth: --color-X defined as raw HSL channels (e.g.
    `0 0% 17%`) in :root, .dark, and all variant [data-theme="..."]
    blocks. Matches the format the runtime store
    (@mana/shared-theme/src/utils.ts) writes, eliminating the
    static-fallback-vs-runtime mismatch and the corresponding flash
    of unstyled content on hydration.

  - @theme inline uses self-reference + Tailwind v4 <alpha-value>
    placeholder so utility classes generate correctly AND opacity
    modifiers work: `text-foreground/50` → `hsl(var(--color-foreground) / 0.5)`.

  - @layer components (.btn-primary, .card, .badge, etc.) wraps
    var(--color-X) refs with hsl() — they were broken in light mode
    too for the same reason.

Convention going forward (also documented in the file header):

  1. Markup: use Tailwind utility classes (text-foreground, bg-card, …)
  2. Scoped CSS: hsl(var(--color-X)) — always wrap with hsl()
  3. NEVER raw var(--color-X) in CSS — that's the bug pattern

Net file: 692 → 580 LOC. Single source layer, no indirection.

Also delete dead companion files (zero imports anywhere):
  - tailwind-v4.css (had broken self-reference, never imported)
  - theme-variables.css (legacy hex-based palette)
  - components.css (legacy component utilities)
  - index.js / preset.js / colors.js (Tailwind v3 preset format,
    irrelevant under Tailwind v4)

package.json exports map shrinks accordingly to just `./themes.css`.

Consumers using `hsl(var(--color-X))` (~379 files across mana-web,
manavoxel-web, arcade-web) keep working unchanged — the public API
name `--color-X` is preserved. Only the broken pattern `var(--color-X)`
(~61 files) needs a follow-up sweep, handled in a separate commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:13:06 +02:00
Till JS
3a3cd126cf feat(mana/web): unified guest-mode prompt in the bottom-stack notification slot
Adds a single, deduped notification mechanism for the moments when the
app needs to nudge a signed-out user toward registration / login —
replacing the silent failures that used to happen when a guest hit a
server-only feature.

──── New: lib/stores/guest-prompt.svelte.ts ────

A mana-web-local singleton with a tiny API:

  guestPrompt.requireAccount(featureKey, message?, actionLabel?)
  guestPrompt.dismiss(id)
  guestPrompt.clear()

Notifications are deduped by featureKey, so three failed LLM calls in
a row don't stack three identical "you need an account" stripes. The
"Anmelden" button uses an injectable navigator (via setGuestPromptNavigator)
so the layout can wire SvelteKit's goto for client-side routing
instead of the default window.location.assign fallback.

──── Hooked into api/base-client.ts ────

fetchWithRetry now short-circuits BEFORE the network call when the
user is unauthenticated — surfaces guestPrompt.requireAccount('api')
and returns "Anmeldung erforderlich" instead of burning the round-trip
to a server that's just going to 401. Saves latency, log noise, and
gives a better German error message than "Sitzung abgelaufen".

When a 401 does come back from the server (token expired mid-session),
fetchWithRetry calls guestPrompt.requireAccount('api:401', ..., 'Neu anmelden')
in addition to the existing return-with-error path.

Both hooks live in one central place so every feature that uses
createApiClient(...) — LLM, subscriptions, profile, credits, events,
gifts, etc. — gets the prompt for free without per-module changes.

──── Rendered in routes/(app)/+layout.svelte ────

The bottom-stack already had a NotificationBar slot for the time-based
guest nudge from createGuestMode (3-min trigger). The new event-driven
prompts merge into the SAME bar via array spread — one stripe, no
visual stacking — so the user only ever sees one band even when both
sources have something to say.

handleAuthReady() also calls guestPrompt.clear() when the user signs
in, so leftover guest prompts don't carry into the authenticated session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:11:54 +02:00
Till JS
752f31bfad fix(mana/web): handle vault-locked race + add guest plaintext fallback
Two related issues in the encryption pipeline that were both surfacing
as silent failures when a user tried to log a mood / write to any
encrypted field shortly after page load or while signed out:

1. Boot-time race
   The layout fires authStore.initialize() and vaultClient.unlock()
   in the same tick. The very first user mutation can land before the
   network round-trip that fetches the master key returns. encryptRecord
   then synchronously sees a null key and throws VaultLockedError —
   surfacing in the UI as "click does nothing" because nothing in the
   call chain catches it.

   Fix: KeyProvider gets a waitForKey(timeoutMs) method.
   MemoryKeyProvider implements it via the existing onChange listener,
   so callers resume as soon as setKey lands. encryptRecord now waits
   up to 2 s before throwing, which converts a near-miss race into a
   transparent millisecond delay.

2. Guest plaintext fallback (Option A in the chat thread)
   Guests have no auth token, so the server vault is unreachable by
   definition. Refusing every encrypted-field write would hide the
   bulk of the app behind a sign-up wall — undesirable for the
   try-before-you-buy local-first flow.

   Fix: encryptRecord now silently no-ops when getCurrentUserId() is
   null, writing plaintext to the local Dexie. guest-migration.ts
   waits for the vault (10 s budget) and then encrypts the registry
   fields per-table BEFORE the re-insert, so the on-disk state after
   sign-in matches "user signed up first, then typed everything".

   If the vault never opens (auth/network failure on /me/encryption-vault),
   migration aborts cleanly — guest data stays put rather than being
   re-inserted as plaintext under the real user id.

UI side: cycles/ListView.svelte wraps every dayLogsStore.logDay call
in a safeLogDay helper that catches VaultLockedError and surfaces a
toast pointing the user at Settings → Sicherheit. Previously the
unhandled rejection from a click handler vanished into the console.

Tests:
- record-helpers.test.ts now stamps a fake current user in beforeEach
  so the new guest-skip doesn't no-op the encryption asserts. The
  "throws when locked" test uses fake timers to flush the 2 s wait
  without sitting on it.
- aes.test.ts: anonymous-class KeyProvider stub gains the new
  waitForKey method to satisfy the interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:11:21 +02:00
Till JS
1702caa4f7 feat(shared-llm) + fix(mana/web): tiered LLM orchestrator + workbench proxy fix
Bundles two unrelated changes that landed together due to a concurrent
lint-staged race in a multi-session edit. Splitting after the fact would
churn the parallel session's working tree, so the message is amended to
honestly describe both pieces.

──── 1. feat(shared-llm): tiered LLM orchestrator (Phase 1) ────

Replaces the unused NestJS @mana/shared-llm package with a tiered LLM
orchestrator that routes Mana tasks across four user-controlled privacy
tiers:

  none        — deterministic parsers / heuristics, no LLM
  browser     — Gemma 4 E2B via @mana/local-llm (WebGPU, on-device)
  mana-server — services/mana-llm with Ollama (gemma3:4b on Mac Mini)
  cloud       — services/mana-llm with google/* model (Gemini API)

The user picks which tiers Mana is allowed to use. The orchestrator
walks the user's tier list in order, picks the first one that's
available + ready + permitted for the input's content class, and runs
the task. If everything fails, it falls through to a per-task
deterministic runRules() implementation when one is provided.

Package shape moved from NestJS-style (Module/Service/__tests__/) to a
flat browser-package layout (deps are @mana/local-llm + svelte peer).
All NestJS legacy files deleted: __tests__/, interfaces/, types/,
utils/, llm-client*.ts, llm.module.ts, standalone.ts, etc.

Phase 2 (UI work — settings page section, onboarding step, source
badge component, cloud-consent dialog) is a follow-up and does not
block this commit. The orchestrator is fully functional from the
Router tab right now.

──── 2. fix(mana/web): unwrap \$state proxy in workbench-scenes ────

Adding an app to a workbench scene threw DataCloneError. scenesState
is a \$state array, so current.openApps was a Svelte 5 proxy and
spreading it into a new array left proxy entries inside; IndexedDB's
structured clone refuses to serialise those. Snapshot before handing
the array to patchScene / createScene so Dexie sees plain objects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:09:54 +02:00
Till JS
56065c8537 fix(mana/web): unwrap $state proxy in workbench-scenes Dexie writes
Adding an app to a workbench scene threw DataCloneError. scenesState
is a $state array, so current.openApps was a Svelte 5 proxy and
spreading it into a new array left proxy entries inside; IndexedDB's
structured clone refuses to serialise those. Snapshot before handing
the array to patchScene / createScene so Dexie sees plain objects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:44:00 +02:00
Till JS
761851847f Revert "refactor(mana/web): migrate zitare DetailView to theme tokens (P5 phase 1 sample)"
This reverts commit 74140e1154.
2026-04-09 00:36:43 +02:00
Till JS
6fd4655273 fix(questions): unwrap \$state proxy for tags before Dexie write
Creating a new question crashed in mana-sync's pending-change hook
with DataCloneError because the tags array is a Svelte 5 \$state proxy
and IndexedDB / structured-clone can't serialize proxies.

Surfaced while clicking through the deep-research feature end-to-end
in the browser — the form would silently fail to submit with the error
buried in the console.

Use \$state.snapshot() to deep-clone tags into a plain array before
persisting. Other fields are primitives so they're already plain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:31:53 +02:00
Till JS
74140e1154 refactor(mana/web): migrate zitare DetailView to theme tokens (P5 phase 1 sample)
First file in the P5 visual-track consolidation. Replaces hand-rolled
#374151/#9ca3af/#e5e7eb palette + paired :global(.dark) duplications
with var(--color-foreground), var(--color-muted-foreground),
var(--color-border), var(--color-surface-hover) etc. — the same
theme tokens already used by shared-ui Card / PageHeader / FormModal.

Net: 260 → 245 LOC (-15), 7 hand-rolled rules eliminated, all
:global(.dark) selectors gone (theme system handles light/dark via
.dark class on <html>).

Brand colors that should NOT track the theme primary stay hardcoded:
the violet category badge (#8b5cf6) keeps its literal value, the
favorite-active red gets var(--color-error, #ef4444) with fallback.

This is the smallest of the 8 files identified by the P5 audit:
zitare DetailView (7 rules) → cycles → calendar → contacts → todo →
finance → notes → dreams (31 rules). Migration runs file-by-file
with one commit each so visual diffs are easy to review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:29:39 +02:00
Till JS
7901a5df2f docs(local-llm): document browser-local LLM stack and CSP requirements
Add packages/local-llm/CLAUDE.md as the package-level reference for
browser-local LLM inference. The package went through a non-trivial
engine swap from WebLLM/Qwen to transformers.js/Gemma 4 E2B on
2026-04-08, and the bring-up surfaced enough sharp edges that the
next person (or AI agent) touching this code will save real time
having them written down in one place rather than re-discovering
them error by error.

Captured topics:

- What the package is, what library/model is currently used, and
  the deliberate engine-agnostic API surface that lets future swaps
  stay contained to this package.

- Why we chose transformers.js + Gemma 4 over staying on WebLLM
  (MLC compilation lag for new model architectures) and what the
  return path looks like once MLC ships Gemma 4 builds.

- The seven CSP directives that browser-local inference needs and
  WHY each one is required:
    * script-src: 'wasm-unsafe-eval', cdn.jsdelivr.net, blob:
    * connect-src: huggingface.co + *.huggingface.co + cdn-lfs-*,
                   *.hf.co + cas-bridge.xethub.hf.co (XET CDN),
                   cdn.jsdelivr.net (for the WASM preload fetch)
  Including the subtle "jsDelivr is needed in BOTH script-src and
  connect-src" trap that produces identical-looking error messages
  for two distinct underlying causes.

- The Vite SSR module-cache gotcha: CSP additions made in
  packages/shared-utils/security-headers.ts do NOT hot-reload across
  the workspace package boundary, while additions made directly in
  apps/mana/apps/web/src/hooks.server.ts do. Includes the diagnostic
  pattern (compare which additions show up in the next CSP error
  vs which don't) and the workaround (move them into hooks.server.ts
  via setSecurityHeaders options).

- The two-step tokenization pattern that's mandatory for
  Gemma4Processor: apply_chat_template(tokenize:false) → string, then
  processor.tokenizer(text, return_tensors:'pt'). The collapsed
  apply_chat_template(return_dict:true) path looks shorter but
  produces a malformed input shape and crashes model.generate() deep
  inside the forward pass with "Cannot read properties of null
  (reading 'dims')" — opaque from the call site.

- The transformers.js v4 quirk that model.generate() returns null
  (not a tensor) when a TextStreamer is attached. The streamer is
  the only stable text channel; the engine always attaches one and
  uses the streamer's collected text as the canonical output, with
  a chars/4 fallback for token counts.

- API surface (Svelte 5 example), how to add a new model to the
  registry, deploy notes (no base image rebuild needed for local-llm
  changes alone, but IS needed if shared-utils CSP defaults change),
  browser cache semantics, and hard browser support requirements
  (WebGPU, ~1.5–2 GB VRAM for E2B q4f16, no CPU/WASM fallback).

Also link to the new doc from the root CLAUDE.md Shared Packages
table so people land on it from the standard discovery path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:27:50 +02:00
Till JS
e8423e7551 fix(local-llm): use two-step tokenization to fix Gemma 4 generate crash
The previous attempt to fix the "Cannot read properties of null
(reading 'dims')" chat error was incomplete: I only stopped passing
the bogus return_tensor:'pt' option to apply_chat_template. The
underlying issue was that apply_chat_template's all-in-one mode
(return_dict:true) does not produce a proper Tensor-backed
{ input_ids, attention_mask } pair for multimodal-capable processors
like Gemma4Processor — it returns a shape that has no .dims on
input_ids, so model.generate() crashes deep inside the forward pass
the moment it tries to read the sequence length.

Switch to the documented two-step pattern from the Gemma 4 model
card: call apply_chat_template with tokenize:false to get the
formatted prompt as a plain string, then run that string through
processor.tokenizer with return_tensors:'pt' to get a proper Tensor
pair. The tokenizer's return_tensors option is the *Python*
convention and IS supported by transformers.js's Tokenizer class
(the API name collision between apply_chat_template's return_tensor
boolean and Tokenizer's return_tensors string is one of those nasty
spots where the JS port intentionally diverges from Python).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:19:24 +02:00
Till JS
7f1513b5a3 fix(local-llm): handle null model.generate() return + bogus return_tensor
First end-to-end Gemma 4 inference attempt threw "Cannot read
properties of null (reading 'dims')" the moment a chat message was
sent. Two bugs piled on top of each other:

1. apply_chat_template() was being called with `return_tensor: 'pt'`,
   which is the Python `transformers` convention. transformers.js's
   equivalent option is just a boolean (the default), and the string
   'pt' is unrecognized — older versions silently ignored it, but the
   v4 code path now produces a less predictable input shape when it
   sees the unknown value. Drop it.

2. model.generate() in transformers.js v4 returns null (not a tensor)
   when a streamer is attached. The previous engine code only attached
   a streamer if the caller passed an `onToken` callback, then
   unconditionally tried to slice the tensor return for token counting
   — which crashed because the chat tab DOES pass onToken for live
   streaming. The streamer collected the text fine, but generate()
   returned null and our tensor read blew up.

Restructure so the streamer is always attached and is the canonical
text channel. The tensor return is now only used for token counting
when present, and falls back to a chars/4 estimate when it isn't, so
the /llm-test UI still shows roughly meaningful prompt/completion
counts on either v3 (returns tensor) or v4 (returns null with
streamer). The user-facing GenerateResult.content now always comes
from the streamer's accumulated string instead of decoding the
tensor's sliced suffix, which is more robust across versions.

Also wrap the model.generate() call in try/catch so that versions
of transformers.js that throw at end-of-streaming (after the
streamer has already delivered all tokens) don't lose the answer.
We only re-throw if the streamer collected nothing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:15:33 +02:00
Till JS
cc6ebee6f5 fix(csp): allow blob: in script-src so onnxruntime-web Worker can spawn
After jsDelivr was allowlisted, transformers.js progressed one step
further: it successfully fetched the ort-wasm-simd-threaded.asyncify.mjs
loader, then tried to wrap it in a `URL.createObjectURL(new Blob([...]))`
and instantiate the result as a multi-threaded Web Worker. The blob:
URL scheme is treated as its own CSP source by browsers, so the
existing script-src — which only allows 'self', specific HTTPS hosts,
and 'wasm-unsafe-eval' — blocked it.

Add `blob:` to the mana-web scriptSrc list. The blob: scheme always
inherits the document origin (you can't `URL.createObjectURL` a Blob
from another origin), so this allowlists nothing more than our own
runtime-generated worker scripts. It does NOT loosen the protection
against remote-script injection.

Worth knowing for future debugging: when transformers.js or any
WebGPU/onnxruntime-web stack hits "Failed to fetch dynamically
imported module: blob:..." after a successful dynamic import from
a CDN, the next CSP layer is always blob: in script-src.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:06:33 +02:00
Till JS
e4e3360ca8 fix(csp): move jsdelivr allowlist to mana-web hooks (Vite SSR cache workaround)
The previous two attempts at allowlisting cdn.jsdelivr.net for
transformers.js's onnxruntime-web loader landed in shared-utils
security-headers.ts. The actual file change was correct (verified by
grep), the commits got pushed, the live security-headers.ts on disk
had the additions — but Vite's SSR module cache for cross-workspace-
package imports kept serving the OLD compiled shared-utils to
hooks.server.ts. Net effect: edits to hooks.server.ts hot-reloaded
fine (proven by the *.hf.co connect-src additions showing up
immediately) while edits to shared-utils/security-headers.ts did not.
A dev server restart should clear it but I'd rather not depend on
manual intervention every time we touch the shared CSP.

Move the jsdelivr allowlist out of the shared default and into
mana-web's hooks.server.ts via the existing scriptSrc + connectSrc
options. hooks.server.ts is in the SvelteKit app's own source tree so
it HMRs reliably, no SSR cache to fight. As a bonus this is also
architecturally cleaner: cdn.jsdelivr.net is only needed by mana-web
because mana-web is the only Mana app that bundles @mana/local-llm —
other apps get a slightly tighter CSP for free.

The pattern to remember: changes to packages/shared-utils that affect
SSR (response headers, server hooks) require either a dev server
restart OR a manual `rm -rf apps/.../node_modules/.vite` to take
effect. Client-side changes hot-reload fine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:03:38 +02:00
Till JS
96023394b5 refactor(mana/web): extract QuickActionsList molecule shared by both widget variants
Two QuickActionsWidget files lived in parallel under different widget
systems — `dashboard/widgets/` (the user-customizable dashboard, i18n
keys, 3 actions: credits/feedback/profile) and `core/widgets/` (the
mana home screen, hardcoded German strings, 5 actions: todo/calendar/
contacts/context/times). The two rendered the same shape character-
for-character: optional emoji-prefixed title + a list of rounded-card
links each with icon + label + description. Only the data and a
slightly different padding/icon sizing differed.

Extract <QuickActionsList> in $lib/components that takes the actions
array directly (consumers resolve i18n before passing in). Both widget
files become thin wrappers — the dashboard one resolves $_(...) keys
and passes the result, the core one passes its hardcoded data with
`compact` set.

LOC: 110 → 102 across the 3 files (-8 net, plus the shared 70-LOC
molecule). Small numerically, but the bigger win is that future
changes to the link layout (hover state, padding, icon style) happen
once instead of twice — and the two widget files no longer accidentally
drift in sizing/spacing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:02:09 +02:00
Till JS
8772a9b9e1 fix(csp): also allow jsdelivr in connect-src for transformers.js WASM preload
The earlier fix added cdn.jsdelivr.net to script-src so the dynamic
import() of onnxruntime-web's loader .mjs would resolve. But that's
only half the story: transformers.js also issues plain fetch() calls
to PRE-LOAD the .wasm binary and the .mjs factory before the backend
selection code path is even reached. fetch() is governed by
connect-src, not script-src, so the wasm preload was still blocked
with "Failed to pre-load WASM binary: TypeError: Failed to fetch".
The visible downstream symptom was identical to the previous bug
("no available backend found. ERR: [webgpu] TypeError: Failed to
fetch dynamically imported module"), which made it look like the
script-src fix hadn't taken effect.

Add cdn.jsdelivr.net to the default connect-src too, alongside the
existing script-src entry, with a comment explaining why both are
required.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:59:52 +02:00
Till JS
b50a5c9ac7 fix(local-llm): allow jsdelivr in CSP + aggregate transformers.js progress
Two issues hit while loading Gemma 4 E2B in /llm-test for the first
time on a local dev server.

1. CSP script-src blocked cdn.jsdelivr.net.
   @huggingface/transformers v4 lazy-loads the onnxruntime-web WASM
   loader shim via a runtime dynamic `import()` from
   cdn.jsdelivr.net/npm/onnxruntime-web@... at backend selection time
   (the package itself is bundled, but the WASM-loader is fetched on
   demand so the static bundle stays small). With the previous CSP the
   import was blocked and "no available backend found" was the only
   downstream error. Allowlist cdn.jsdelivr.net in the shared CSP
   script-src so every Mana web app picks this up automatically.

2. Loading bar oscillated wildly during the model download.
   transformers.js downloads many shards in parallel (config.json,
   tokenizer.json, generation_config.json, model.onnx, model_data.bin,
   …) and fires the progress callback per file. The previous engine
   code reported the latest event verbatim, so the bar bounced
   between whichever file happened to be progressing fastest.

   Replace per-file reporting with a Map<file, {loaded, total}>
   accumulator and emit an aggregated total on every event. The
   denominator can grow as new files are discovered (causing brief
   small dips), but both numerator and denominator are individually
   monotonic, so the aggregate is much smoother. Also include a
   human-readable byte count and file count in the status text:
       Downloading model (47%, 240 MB / 510 MB, 8 files)

   Pin completed files to 100% on the 'done' event so the final
   aggregate visibly hits 100% before the loading→ready transition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:56:52 +02:00
Till JS
243c09d97c refactor(mana/web): extract useItemContextMenu helper for ListView right-click menus
Nine files (8 ListViews + todo's TaskList) reimplemented the same
context-menu state machinery character-for-character: a typed
$state object with visible/x/y/<itemKey>, a handleItemContextMenu
function that calls preventDefault and stuffs the click position
in, and a close handler that resets the entity field.

Extract `useItemContextMenu<T>()` in $lib/data/item-context-menu.svelte
that returns a reactive handle with `.state` (visible/x/y/target),
`.open(e, target)`, and `.close()`. Consumers derive their menu
items from `ctxMenu.state.target` and pass `ctxMenu.close` directly
to <ContextMenu onClose>.

Per file: ~10 LOC of state declaration + handler removed; consumer
items array switches from `ctxMenu.<entity>` to `ctxMenu.state.target`.
Across the 9 files this is ~−90 LOC of pure boilerplate; helper itself
is 50 LOC. Net small (~−40 LOC) but the boilerplate is gone and the
shape is one helper away from being adjustable globally.

Note: shared-ui already exports a `createContextMenuState` factory,
but it's a plain default-value object — not a Svelte 5 reactive
helper. This new wrapper composes with the existing `ContextMenuState<T>`
type from shared-ui rather than replacing it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:55:31 +02:00
Till JS
0af9094096 fix(csp): allow HF XET CDN (cas-bridge.xethub.hf.co) for transformers.js
After the WebLLM → transformers.js migration, the first attempt to load
Gemma 4 E2B in /llm-test was blocked by CSP at the *weight shard*
download step (tokenizer.json got through fine — it lives on
huggingface.co directly). HF has rolled out a new XET-backed CDN for
large model files at cas-bridge.xethub.hf.co, served from
*.xethub.hf.co (the parent zone is hf.co, NOT huggingface.co — so our
existing wildcard `*.huggingface.co` did not cover it).

Open the broader hf.co wildcard (`https://*.hf.co`) so future XET host
rotations don't bite us, plus the explicit cas-bridge.xethub.hf.co
entry for older CSP-strict browsers that want narrower matches first.
The legacy huggingface.co + cdn-lfs.huggingface.co entries stay in place
for repo metadata and any model still on the old LFS path.

Update the comment block above the CSP additions to reflect that the
package now uses transformers.js + ONNX shards rather than the old
WebLLM/MLC path, including a quick map of which HF domain serves what.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:51:50 +02:00
Till JS
63a91e36a2 fix(research): use /v1/chat/completions for mana-llm (not /api/v1/)
End-to-end testing surfaced a 404 from the synth path. mana-llm
(services/mana-llm/src/main.py) mounts the OpenAI-compatible API at
/v1/* — there's no /api prefix.

The first quick-depth e2e run only worked because the planner is
skipped on quick (it just uses the question itself), so llmJson never
fired; only llmStream did, and the streaming path also used the wrong
prefix but the test happened to land before this was caught.

The other apps/api modules (chat, guides, context, traces) all use the
wrong /api/v1/ path too — that's a separate, pre-existing bug to be
addressed in their own commits.

Verified by re-running a standard-depth research run end-to-end against
mana-llm pointed at the GPU server's ollama with gemma3:4b/12b: plan +
retrieve + extract + synth all succeed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:37:07 +02:00
Till JS
30766f153e fix(macmini): auto-rebuild stale sveltekit-base before per-app web builds
NOTE: the previous commit 048184bef carried this commit message but
accidentally bundled an unrelated PickerOverlay refactor instead of
this script change (lint-staged stash interaction). This is the
actual fix.

Per-app web Dockerfiles do `FROM sveltekit-base:local` and do NOT
re-COPY packages/shared-* — those packages are baked into the base
image. So a change to packages/shared-utils, packages/shared-ui, etc.
only reaches the live web app if the base image is also rebuilt.

This bit us THREE times on 2026-04-08 alone:
1. CSP fix in shared-utils ('wasm-unsafe-eval') sat unused in
   production for over an hour because every `build-app.sh mana-web`
   reused the cached base layer with old shared-utils.
2. The BaseListView export in shared-ui after the ListView
   consolidation refactor — mana-web's build failed because Rollup
   couldn't resolve the new symbol from the stale base.
3. Same shape, different package, repeatedly during the Gemma 4
   migration push.

The pattern is identical every time and the manual workaround
(`build-app.sh --base` first) is something you only think to run if
you already know how the layering works. Make the script catch it.

New `is_base_image_stale` helper compares the base image's `Created`
timestamp against the latest git commit touching paths the base image
actually depends on (packages/, docker/Dockerfile.sveltekit-base,
pnpm-lock.yaml). When building any *-web service, if the image is
stale or missing, the base is rebuilt automatically before the
per-app build kicks off, with the triggering commit's oneline
printed for transparency.

Date parsing handles macOS Docker's local-TZ-offset RFC3339 format
(`...+02:00`, not Z). We strip from char 19 onward and parse the
literal local clock time with BSD date (no -u). GNU date is the
fallback for Linux dev boxes. If parsing fails for any reason we
conservatively force a rebuild rather than risk shipping stale code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:35:46 +02:00
Till JS
048184bef0 fix(macmini): auto-rebuild stale sveltekit-base before per-app web builds
Per-app web Dockerfiles do `FROM sveltekit-base:local` and do NOT re-COPY
packages/shared-* — those packages are baked into the base image. So a
change to packages/shared-utils, packages/shared-ui, etc. only reaches
the live web app if the base image is also rebuilt.

This bit us THREE times on 2026-04-08 alone:
1. CSP fix in shared-utils ('wasm-unsafe-eval') sat unused in production
   for over an hour because every `build-app.sh mana-web` cheerfully
   reused the cached base layer that still contained the old shared-utils.
2. Same problem with the BaseListView export in shared-ui after the
   ListView consolidation refactor — mana-web's build failed because the
   Rollup pass couldn't resolve the new symbol from the stale base.
3. Same shape, different package, repeatedly.

The pattern is identical every time and the manual workaround
(`build-app.sh --base` first) is something you only think to run if you
already know how the layering works. Make the script catch it.

New `is_base_image_stale` helper compares the base image's `Created`
timestamp against the latest git commit touching paths the base image
actually depends on:
  - packages/                       (all shared-* packages baked in)
  - docker/Dockerfile.sveltekit-base
  - pnpm-lock.yaml                  (transitive dep changes)

When building any *-web service, if the image is stale or missing, the
base is rebuilt automatically before the per-app build kicks off, with
the triggering commit's oneline printed for transparency.

Date parsing notes:
- macOS Docker emits the Created field with the local TZ offset
  ("...+02:00"), not Z. We strip the fractional + offset suffix and
  parse the literal local clock time with BSD date (no -u), which is
  what the original timestamp meant on this host. GNU date is the
  fallback for Linux dev boxes and handles the full ISO directly.
- If parsing fails for any reason we conservatively force a rebuild
  rather than risk shipping stale code.

Verified end-to-end against the live Mac Mini's current state earlier
today: image 55s newer than the last packages/ commit at the time →
"fresh, skip" (correct). When the next packages/ commit lands, the
script will see commit_epoch > image_epoch and trigger the base
rebuild automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:34:29 +02:00
Till JS
3dc16bb885 refactor(mana/web): extract addTagId / removeTagIdWithUndo helpers
Four ListViews (calendar, contacts, places, todo) reimplemented the
same drag-drop tag append logic, and four matching DetailViews
reimplemented the same "remove tag with undo toast" logic. Extract
both into pure helpers in $lib/data/tag-mutations.ts that take a
store-agnostic update function — works for the standard tagIds
modules and for todo's metadata.labelIds via tasksStore.updateLabels.

Side win: places/views/DetailView's removeTag had no undo toast
(every other module did). Consolidating fixes the inconsistency.

zitare is the outlier — its drag target is a Quote, but the tags
live on a (possibly-not-yet-existing) Favorite record. Stays custom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:26:15 +02:00
Till JS
1f26aa4f2f feat(local-llm): swap WebLLM/Qwen for transformers.js + Gemma 4 E2B
Replace the entire @mana/local-llm engine with a transformers.js-based
implementation backed by Google's Gemma 4 E2B (released 2026-04-02).
The external API of LocalLLMEngine — load(), generate(), prompt(),
extractJson(), classify(), onStatusChange(), isSupported() — is
preserved 1:1, so the /llm-test page, the playground module, and the
Svelte 5 reactive bindings in svelte.svelte.ts need no changes
beyond updating the default model key.

Why the engine swap: MLC has not (and as of today still hasn't)
published Gemma 4 builds for WebLLM. The webml-community team and
HuggingFace's onnx-community already have Gemma 4 E2B running in
the browser via transformers.js + WebGPU, with a documented
Gemma4ForConditionalGeneration class shipped in @huggingface/transformers
v4.0.0. Going through the ONNX route gets us the latest Google model
six days after release instead of waiting on MLC compilation.

Trade-offs accepted (discussed before this commit):
- transformers.js is a more generic ONNX runtime, so per-token
  throughput will be ~20-40% lower than WebLLM would deliver for the
  same model size. For a 2B model on a modern WebGPU device that's
  still well above interactive latency.
- The JS bundle gains ~2-3 MB (the ONNX runtime). Negligible compared
  to the 500 MB model download.
- transformers.js v4 is brand new (released alongside Gemma 4) so the
  Gemma4ForConditionalGeneration code path has very little battle
  testing yet. The risk is partially offset by webml-community's
  reference implementation.

What changed file by file:

- packages/local-llm/package.json: drop @mlc-ai/web-llm, add
  @huggingface/transformers ^4.0.0; bump version 0.1.0 → 0.2.0; rewrite
  description.

- packages/local-llm/src/types.ts: add `dtype` field to ModelConfig
  ('fp32' | 'fp16' | 'q8' | 'q4' | 'q4f16') so each model can request
  the quantization that matches its uploaded ONNX shards.

- packages/local-llm/src/models.ts: replace the old Qwen 2.5 + Gemma 2
  registry with a single `gemma-4-e2b` entry pointing at
  onnx-community/gemma-4-E2B-it-ONNX with q4f16 quantization. Future
  models can be added by appending entries — the /llm-test picker
  reads MODELS dynamically and picks them up automatically.

- packages/local-llm/src/cache.ts: replace the WebLLM-specific
  hasModelInCache helper with a generic Cache API probe that looks for
  `https://huggingface.co/{model_id}/resolve/main/tokenizer.json` in
  any open cache. tokenizer.json is small, downloaded first, and
  always present, so its presence is a reliable proxy for "model has
  been loaded before".

- packages/local-llm/src/engine.ts: full rewrite. Internally we now
  hold a transformers.js model + processor pair (created via
  AutoProcessor.from_pretrained + Gemma4ForConditionalGeneration.from_pretrained
  with `device: 'webgpu'`), and translate our LoadingStatus union from
  the library's `progress_callback` shape. generate() applies Gemma's
  chat template via the processor, runs model.generate() with optional
  TextStreamer for streaming, then slices the prompt tokens off the
  output tensor to compute per-call usage. The convenience methods
  (prompt, extractJson, classify) are unchanged because they only call
  generate() under the hood.

- packages/local-llm/src/generate.ts and status.svelte.ts: deleted.
  These were orphaned from a much earlier engine API (referenced
  `getEngine()` / `subscribe()` / `LlmState` symbols that haven't
  existed for a while) and were never re-exported from index.ts —
  they only showed up because `tsc --noEmit` was crawling the src
  tree. Their functionality lives in engine.ts + svelte.svelte.ts now.

- apps/mana/apps/web/package.json: swap the direct dep from
  @mlc-ai/web-llm to @huggingface/transformers. This is the same
  trick we used for the previous adapter-node externals warning —
  having it as a direct dep makes adapter-node's Rollup pass treat
  it as external automatically.

- apps/mana/apps/web/vite.config.ts: swap ssr.external entry from
  @mlc-ai/web-llm to @huggingface/transformers. Add a comment
  explaining the why so the next person doesn't wonder.

- apps/mana/apps/web/src/routes/(app)/llm-test/+page.svelte: change
  the default selectedModel from 'qwen-2.5-1.5b' to 'gemma-4-e2b'.
  All other model display strings come from the MODELS registry, so
  this is the single hard-coded reference that needed updating.

- pnpm-lock.yaml: regenerated. Confirmed @mlc-ai/web-llm is gone (0
  references) and @huggingface/transformers is in (4 references).

CSP: no header changes needed. We already opened connect-src for
huggingface.co + cdn-lfs.huggingface.co + raw.githubusercontent.com
when fixing the WebLLM blockers earlier today, and 'wasm-unsafe-eval'
is already in script-src — both transformers.js (ONNX runtime) and
WebLLM (MLC runtime) need that. If transformers.js spawns its
inference into a Web Worker via a blob URL we may need to add
`worker-src 'self' blob:` once we hit the first runtime test, but
the existing CSP should be enough for the synchronous path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:22:32 +02:00
Till JS
83828e5a44 fix(research): handle zero-hit retrieval — skip empty insert + graceful summary
Smoke-testing /api/v1/research/start with mana-search down surfaced a
crash: drizzle's .values([]) throws "values() must be called with at
least one value", which dropped the run into status='error' even though
the failure is a perfectly normal "no results" case.

Two changes:
- Guard the sources insert behind enriched.length > 0
- If retrieval returns nothing, short-circuit straight to status='done'
  with an explicit German "keine Quellen gefunden" summary instead of
  feeding an empty corpus to the synthesiser

The same path also triggers when every sub-query genuinely returns no
results (very specific question, niche domain) so this isn't just an
ops-failure case.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:19:17 +02:00
Till JS
e82851985b feat(questions): deep-research module — mana-search + mana-llm pipeline
End-to-end deep-research feature for the questions module: a fire-and-
forget orchestrator in apps/api that plans sub-queries with mana-llm,
retrieves sources via mana-search (with optional Readability extraction),
and streams a structured synthesis back to the web app over SSE.

Backend (apps/api/src/modules/research):
- schema.ts: pgSchema('research') with research_results + sources
- orchestrator.ts: three-phase pipeline (plan / retrieve / synthesise)
  with depth-aware config (quick=1×, standard=3×, deep=6× sub-queries)
- pubsub.ts: in-process event bus, single-node, swappable for Redis
- routes.ts: POST /start (202, fire-and-forget), GET /:id/stream (SSE),
  POST /start-sync (test only), GET /:id, GET /:id/sources
- Credit gating via @mana/shared-hono/credits — validate up-front,
  consume best-effort on `done`. Failed runs cost nothing.

Helpers (apps/api/src/lib):
- llm.ts: llmJson() + llmStream() over mana-llm OpenAI-compat API
- search.ts: webSearch() + bulkExtract() over mana-search Go service
- responses.ts: shared errorResponse / listResponse / validationError

Schema deployment:
- drizzle.config.ts (research-scoped) + drizzle/research/0000_init.sql
  hand-authored migration, deployable via psql -f or drizzle-kit push.
- drizzle-kit added as devDep with db:generate / db:push scripts.

Web client (apps/mana/apps/web/src/lib/api/research.ts):
- Typed start() / get() / listSources() / streamProgress(). The stream
  uses fetch + ReadableStream (not EventSource) so we can attach the
  JWT via Authorization header. Special-cases 402 for friendly toast.
- New PUBLIC_MANA_API_URL plumbing in hooks.server.ts + config.ts.

Module store (modules/questions/stores/answers.svelte.ts):
- New write-side store with createManual / startResearch / accept /
  softDelete. startResearch creates an optimistic empty answer, opens
  the SSE stream, debounces token deltas in 100ms batches into the
  encrypted local row, and on `done` replaces the streamed text with
  the parsed { summary, keyPoints, followUps } payload + citations
  resolved against research.sources.id.

Citation rendering (modules/questions/components/AnswerCitations.svelte):
- Tokenises [n] markers in the answer body into clickable pills with
  hover popovers showing title / host / snippet / external link.
- Lazy-loaded via a session-scoped source cache (stores/sources.svelte.ts)
  that deduplicates concurrent fetches.

UI (routes/(app)/questions/[id]/+page.svelte):
- Recherche card with three-state button (start / cancel / re-run),
  animated phase indicator, source counter.
- Confirmation dialog warning about web/LLM transmission since the
  question itself is locally encrypted.
- Toasts for success / error / cancel via @mana/shared-ui/toast.
- Re-run flow soft-deletes prior research-driven answers but keeps
  manual ones intact.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:15:35 +02:00
Till JS
30787e36d2 refactor(mana/web): consolidate DetailView scaffolding into DetailViewShell + useDetailEntity
Every module's inline-editable DetailView reimplemented the same
plumbing: liveQuery → optional decryptRecord → reset on id change
→ focused/confirmDelete state → save-on-blur → deleteWithUndo via
toastStore. Plus ~150 LOC of duplicated scoped CSS for the
.detail-view / .title-input / .properties / .prop-row / .section /
.danger-zone style track.

Extract two pieces:

  - useDetailEntity (svelte runes module, $lib/data/detail-entity.svelte.ts)
    handles the JS plumbing: liveQuery + optional decrypt + reset
    on id change + focused/confirmDelete state + deleteWithUndo.
    Supports a custom `loader` for cross-table joins (events+timeBlocks,
    timeEntries+timeBlocks, tasks+timeBlocks).

  - DetailViewShell ($lib/components/DetailViewShell.svelte)
    handles the visual scaffold: outer flex column with scroll,
    loading/not-found state, body snippet, danger zone with confirm
    flow. Exports the shared field/property/section/meta classes as
    :global so consumer snippets can use them without redefining.

Migrated 16 of the 18 DetailViews. Skipped:
  - zitare: no DB entity (quotes from bundled @zitare/content),
    no edit/delete flow.
  - events: different page shape (centered max-width, edit/view
    modes, eventId via direct prop instead of params, nested guest
    list / RSVP sections).

Side wins:
  - 6 encrypted modules (storage, uload, music, questions, calendar,
    todo) now route their decrypt logic through one path instead of
    six separate `liveQuery + decryptRecord({ ...raw })` variations.
  - times/views/DetailView had the same latent type bug as the
    ListView (reading .date / .startTime / .endTime / .source off
    LocalTimeEntry, which doesn't define them). Now uses toTimeEntry()
    via the loader option for the joined TimeEntry shape.

Net impact: ~3640 LOC removed across the 16 files (~49% reduction),
~510 LOC added for shell + helper. Net ~3130 LOC saved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:10:42 +02:00
Till JS
c3cb9dd533 refactor(mana/web): consolidate ListView scaffolding into BaseListView
Every workbench-style module ListView reimplemented the same
liveQuery + filter + scroll-area + empty-state shell. Extract a
shared <BaseListView> in @mana/shared-ui (with toolbar/header/
listHeader/item/empty snippets) and migrate the 17 modules whose
list templates fit the workbench tailwind track.

While here:
- migrate DeckCard onto the existing (previously unused) shared
  Card atom from shared-ui/atoms.
- fix a latent type bug in times/ListView: it was reading .date /
  .startTime / .isRunning off LocalTimeEntry, which doesn't define
  them. Now uses the proper joined TimeEntry via toTimeEntry() like
  the rest of the times module.

Modules with their own scoped-CSS layout track (calendar, finance,
contacts, notes, places, todo, photos, habits, automations, dreams,
cycles) and outliers (calc, events, playground, zitare) are left
alone — migrating them would be a visual rewrite, not a structural
shell swap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:40:47 +02:00
Till JS
d941ff2231 fix(mana-auth): account lockout was structurally dead + add failure-path tests
While adding negative-path integration tests for the auth flow I
discovered that *neither* of the lockout primitives in
services/mana-auth/src/services/security.ts has actually been
working in production. Two independent silent failures that combined
into a "the lockout never triggers, ever" outcome:

1. recordAttempt() inserted into auth.login_attempts with explicit
   `id = gen_random_uuid()`, but auth.login_attempts.id is a
   `serial integer` column with `nextval('auth.login_attempts_id_seq')`
   as default. The UUID-into-integer cast threw a type error every
   single time, the bare `catch {}` swallowed it as "non-critical",
   and not a single login attempt was ever persisted. Lockout's "5
   failures in 15 min" check was running against an empty table.

2. checkLockout() built `attempted_at > ${new Date(...)}` via the
   drizzle sql template, but postgres-js cannot bind a JS Date object
   directly — it tries to byteLength() the parameter and crashes with
   `Received an instance of Date`. Same anti-pattern: bare `catch`,
   returns `{locked: false}` (fail-open), no log, completely invisible.

Both are "silent broken since the encryption-vault series of changes"
class — caught only because the integration test for the lockout flow
expected the 6th login attempt to return 429 and got 200 instead.

Fixes:
- recordAttempt(): drop the bogus `id` column from the INSERT (let the
  sequence default assign it), default ipAddress to null instead of
  letting `${undefined}` collapse the parameter slot, and surface
  errors in the catch instead of swallowing them silently.
- checkLockout(): pass `windowStart.toISOString()` instead of the Date
  object so postgres-js can serialize it. Same catch upgrade — log the
  cause when failing open.

Failure-path test additions (tests/integration/auth-failures.test.ts):
- wrong password: assert 401, no JWT, +1 LOGIN_FAILURE in security_events,
  +1 row in auth.login_attempts
- account lockout: 5 failed attempts then 6th returns 429 with
  remainingSeconds, even with the correct password
- unverified email login: 403 with code = EMAIL_NOT_VERIFIED
- validate with garbage token: valid !== true
- resend verification: second mail arrives in mailpit

Plus the run-integration-tests.sh helper now runs both .test.ts files
and tests/integration/package.json's `test` script does the same.

Negative-control: reverted the recordAttempt fix (re-added the bogus
gen_random_uuid id), the wrong-password test failed at the
login_attempts assertion. Reverted the checkLockout fix, the lockout
test failed at the 429 assertion. Both fixes verified to be load-bearing.

6 tests, 45 expects, ~1.3s on a warm cache.
2026-04-08 18:29:00 +02:00
Till JS
a9178ec2fb fix(docker): drop stale shared-subscription-* COPY lines from sveltekit-base
The base image referenced packages/shared-subscription-types and
packages/shared-subscription-ui, which were consolidated into
packages/subscriptions a while back and no longer exist on disk.
`build-app.sh --base` therefore failed every time with:

  failed to compute cache key: "/packages/shared-subscription-ui": not found

That latent failure was harmless until today: the CSP fix for WebLLM
in @mana/shared-utils never made it into the live mana-web container
because shared-utils lives inside sveltekit-base:local (not COPYed by
the per-app Dockerfile), and rebuilding the base was impossible. With
the stale lines removed the base image rebuilds, picks up the current
shared-utils, and downstream apps inherit the fixed CSP automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:28:59 +02:00
Till JS
c5e5963cbe fix(macmini): repair container auto-recovery (broken --env-file path)
Two unrelated bugs in scripts/mac-mini/ensure-containers-running.sh,
both caught while debugging a mana-auth crash loop on 2026-04-08:

1. The recovery path passed --env-file "$PROJECT_ROOT/.env.macmini" to
   docker compose, but that file has never existed on the server — only
   .env does, and compose auto-loads it from the working directory. The
   explicit --env-file silently caused recovered containers to start with
   empty secrets (e.g. blank MANA_AUTH_KEK), which made mana-auth crash
   the moment it came back up. The auto-recovery loop was therefore
   self-defeating: it kept "fixing" auth into the same broken state
   every 5 minutes for hours, with no notification because compose
   exited 0. Drop --env-file entirely and cd into PROJECT_ROOT so
   compose's standard .env discovery applies.

2. mana-infra-minio-init is a one-shot job container that legitimately
   sits in "exited" state after running once. The script flagged it as
   "stuck" every cycle, tried to "recover" it, and spammed the log with
   ERROR lines. Add an explicit ONESHOT_INIT_CONTAINERS allowlist and
   skip those names in both the initial scan and the post-recovery
   verification.

Also tee compose output into the log so future failures actually leave
a breadcrumb instead of disappearing into the void.

Also: bump @mlc-ai/web-llm from a transitive dep (via @mana/local-llm)
to a direct dep of @mana/web. SvelteKit's adapter-node post-build
Rollup pass uses the web app's direct deps as its externals heuristic;
without this entry it warns "@mlc-ai/web-llm ... could not be resolved
- treating it as an external dependency" on every build. Functionally
harmless (the dynamic import in LocalLLMEngine only fires in the
browser), but the warning hid a real adapter-node misconfiguration
that would have bitten us if we'd ever tried to SSR /llm-test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:17:31 +02:00
Till JS
94ab125fbb fix(mana/web): SessionWarning + SuggestionToast into bottom-stack
Two more centred bottom-anchored toasts had the same problem the
EncryptionIntroBanner had in 2a437a586: their own position: fixed
with hardcoded bottom + transform centring put them in stacking
contexts that the QuickInputBar (z-index 90) either covered up
(SessionWarning, z-index 45 → hidden) or sat under (SuggestionToast,
z-index 9999 → covered the input bar instead).

Both moved into .bottom-stack as .bottom-stack-notification children
in (app)/+layout.svelte, with the parent handling positioning and
the components themselves stripped down to in-flow flex items.

- SessionWarning: was a free-floating element inside (app)/+layout
  but outside the bottom-stack — moved into the stack, kept the
  authStore.isAuthenticated gate so it only renders for logged-in
  users
- SuggestionToast: was mounted in the ROOT layout, but its only
  consumer (automationsStore) is an (app)-only module so the toast
  never made sense on auth/landing pages. Moved into (app) bottom-
  stack, removed from root layout

CSS cleanup in both: dropped position: fixed, bottom, left,
transform, max-width, z-index. Slide-up keyframes rewritten to use
translateY only (no more parent-transform-X to fight with).

Stack order in (app)/+layout.svelte from top to bottom now:
  1. EncryptionIntroBanner  (one-time)
  2. NotificationBar         (guest nudge, conditional)
  3. SessionWarning          (auth-only, conditional)
  4. SuggestionToast         (auto-dismissing, conditional)
  5. QuickInputBar
  6. TagStrip
  7. PillNav

Corner-anchored toasts (PwaUpdatePrompt right-12px, SyncConflictToast
right-1rem) intentionally NOT moved — they live in different visual
real estate and don't compete with the centred stack column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:16:55 +02:00
Till JS
2a437a5861 fix(mana/web): EncryptionIntroBanner sits inside the bottom-stack
The one-time encryption intro banner used its own position: fixed at
bottom: 1.5rem with z-index: 60, mounted at the root layout level.
That put it in a stacking context the QuickInputBar covered up — the
search bar visually sat ON TOP of the banner instead of below it,
making the privacy claim half-readable and the dismiss X impossible
to click.

Same fix the guest nudge got in c8ed58b7d: move into the bottom-stack
flex container in (app)/+layout.svelte and let the parent handle
positioning. The banner is now the FIRST child of the stack so it
renders above the guest nudge / QuickInputBar / TagStrip / PillNav
and stays in flow as the stack reflows when nav collapses.

- Removed `<EncryptionIntroBanner />` from root +layout.svelte (it
  doesn't belong above the (app) gate anyway since it self-checks
  isVaultUnlocked() which is always false outside auth context)
- Mounted inside `.bottom-stack` as the first `.bottom-stack-notification`
  child in (app)/+layout.svelte
- Stripped position: fixed / bottom / left / transform / max-width /
  z-index from the banner CSS — now an in-flow flex item with
  width: 100% (the wrapper centres + caps width via the existing
  bottom-stack-notification rules)
- Slide-up animation rewritten to use translateY only since the
  parent no longer transforms the banner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:08:35 +02:00
Till JS
09f81d77de test(integration): assert security_events audit rows after register + login
Adds two assertions to the auth-flow integration test that exercise the
audit-log path:

  - after register: expect exactly 1 REGISTER row in auth.security_events
    for the new user
  - after login: expect exactly 1 LOGIN_SUCCESS row for the same user

This locks in the fix from the previous commit (security.ts ?? null
guard for optional fields) and catches any future regression where
security.logEvent silently swallows a SQL error and the audit log goes
into the void.

Verified by reverting security.ts to the broken pre-fix version and
re-running — the test fails with `Expected: 1, Received: 0` at the
register-audit assert in 211ms instead of taking hours of production
debugging.

Also adds an explicit DELETE FROM auth.security_events to the afterAll
cleanup. The FK from security_events.user_id to auth.users(id) is
ON DELETE CASCADE so it would clean up implicitly anyway, but listing
it explicitly makes the cleanup intent obvious from the test source.

Net: 24 → 26 expects per run. Still ~22s end-to-end on a warm cache.
2026-04-08 18:05:57 +02:00
Till JS
624f5ce00b fix(csp): allow wasm-unsafe-eval so @mana/local-llm can instantiate WebLLM
WebAssembly.instantiate() was blocked by script-src on every app using
shared security headers. 'wasm-unsafe-eval' is the narrow CSP source
that whitelists WASM compilation only — it does NOT re-enable eval() or
new Function(). Required by the MLC WebGPU runtime that powers the
in-browser Qwen models on /llm-test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:05:30 +02:00
Till JS
01632dfa49 docs(devlog): voice quick-add pipeline + LLM parsing live in prod
Covers today's voice quick-add session: shared <VoiceCaptureBar>
across five modules, generic /api/v1/voice/{transcribe,parse-task,
parse-habit} endpoints, LLM-driven structure extraction with tag
matching, the mana-llm Ollama routing fix (Colima LAN-range RST
gotcha), gemma3:12b + few-shot prompt iteration, 49 unit tests,
the .env.secrets persistent dev-secret layer, and the two real
bugs found during prod deployment (SvelteKit prod-export
restriction + $env/dynamic/private PUBLIC_-prefix exclusion).

18 commits, ~+1.220 LOC net, voice quick-add now live on mana.how
for todo + habits with full structured extraction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:01:21 +02:00
Till JS
4fd5ff3199 feat(local-llm): add Gemma 2 + allow HF/MLC hosts in CSP
WebLLM was blocked by connect-src — model config and weight shards live
on huggingface.co (+ cdn-lfs.* for LFS), and the WebGPU model_lib WASM
comes from raw.githubusercontent.com (binary-mlc-llm-libs). Also wires
Gemma 2 2B/9B into the model registry so /llm-test picks them up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:00:57 +02:00
Till JS
ed746297b5 fix(mana-auth): security_events INSERT crashed on undefined optional fields
logEvent() builds its INSERT via a raw `sql` tagged template:

    sql\`INSERT INTO auth.security_events
        (..., user_id, ip_address, user_agent, metadata, ...)
        VALUES (..., \${params.userId}, \${params.ipAddress},
                     \${params.userAgent}, \${...metadata}, ...)\`

Most call sites only pass userId+eventType (or only eventType for the
LOGIN_FAILURE / PASSWORD_RESET_REQUESTED / PROFILE_UPDATED /
PASSWORD_CHANGED / ACCOUNT_DELETED events). The other params land in
the template as `undefined`, and postgres-js's tagged-template renderer
collapses `${undefined}` into literal nothing — producing this:

    VALUES (gen_random_uuid(), $1, $2, , , $3::jsonb, NOW())
                                       ^^^^

Postgres rejects with "syntax error at or near \",\"". The catch block
swallowed it as a `console.warn('Failed to log security event
(non-critical):', params.eventType)` with no error detail, which is why
this has been silently broken for who knows how long — every register,
every login, every password change has been losing its audit row.

Fix:
- Coerce optional params to `null` (`params.userId ?? null`) before
  interpolation. NULL is what postgres-js renders for an explicit null.
- Surface the actual error in the catch warn so the next time something
  similar happens it shows up in logs instead of just "non-critical".

Verified the diagnosis by toggling `log_statement = all` on the test
postgres, triggering a register, and reading the literal failed
statement out of postgres logs.
2026-04-08 17:59:23 +02:00
Till JS
4fce6a3ede feat(env): persistent dev secrets via .env.secrets override
Local dev secrets like MANA_STT_API_KEY had no persistent home — they
lived only in the gitignored, generator-overwritten per-app .env files.
Every `pnpm setup:env` wiped them, so devs had to re-paste keys after
any env regeneration. Same recurring friction for MANA_LLM_API_KEY,
MANA_AUTH_KEK, OAuth keys, etc.

New layer: `.env.secrets` at the repo root.

- Gitignored, optional, never required for the build to pass
- Read by generate-env.mjs AFTER .env.development; non-empty values
  override the matching key, so the merged result drives every per-app
  .env the generator writes
- Empty values fall through to the .env.development defaults — a
  freshly-copied .env.secrets.example is a no-op
- One source of truth for all dev secrets, propagated to every app
  with one `pnpm setup:env`

Files:
- `.env.secrets.example` — committed template documenting all known
  secret keys (mana-stt, mana-llm, auth KEK, sync JWT, MinIO, third-
  party APIs). Devs `cp .env.secrets.example .env.secrets` and fill in.
- `.gitignore` — ignores .env.secrets, allows .env.secrets.example
- `scripts/generate-env.mjs` — loads .env.secrets if present, prints
  "Loaded N secrets from .env.secrets" so devs see the override
  taking effect
- `scripts/setup-secrets.mjs` + `pnpm setup:secrets` — convenience
  script that SSHes to mana-server, greps the prod .env for the keys
  defined in .env.secrets.example, and writes them locally. Confirms
  before overwriting an existing .env.secrets unless --force is set;
  reports which keys couldn't be found on the remote so devs know
  what's left to fill manually
- `docs/LOCAL_DEVELOPMENT.md` + `docs/ENVIRONMENT_VARIABLES.md` —
  walk-through and architecture diagram update

Verified end-to-end:
- `rm .env.secrets apps/mana/apps/web/.env && pnpm setup:env` →
  STT key empty (no regression for devs who haven't opted in)
- `pnpm setup:secrets --force && pnpm setup:env` →
  STT key propagated, "Loaded 3 secrets from .env.secrets" in output
- POST /api/v1/voice/transcribe with a real audio file →
  full transcript back via gpu-stt.mana.how, end-to-end working

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:50:37 +02:00
Till JS
93748c0c9c feat(playground): real LLM playground module backed by mana-llm + saved snippets
The playground route was previously a stub. This turns it into a proper
module:

- A streaming chat surface that talks to mana-llm's OpenAI-compatible
  /v1/chat/completions and /v1/models. The SSE chunk parser is hand-rolled
  in modules/playground/llm.ts (~30 lines) rather than pulling a dep —
  the wire format is straight OpenAI and the playground is the only
  consumer right now. If chat / todo enrichment / cycles insights end up
  hitting the same surface, this lifts cleanly into $lib/data/llm-client.ts.
- A persisted **snippets** store: name + systemPrompt + (model, temperature)
  defaults that the user can pin and reorder. Stateless chat history stays
  out — that's what the chat module is for. Both `name` and `systemPrompt`
  are encrypted (same pattern as notes/dreams), with a registry entry in
  data/crypto/registry.ts and a Dexie schema in data/database.ts.
- Standard module wiring: collections.ts / queries.ts / types.ts /
  stores/snippets.svelte.ts / module.config.ts, registered in
  module-registry.ts alongside the other 30+ modules.
- ListView.svelte and the (app)/playground/+page.svelte route consume
  the new store + the streaming client.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:41:18 +02:00
Till JS
d3a1f00072 feat(crypto): roll places + locationLogs into the encryption registry
Phase 8 follow-up. Places carries GDPR-sensitive PII so it gets the same
treatment as the rest of Phase 7+8, with one deliberate carve-out:

- `places` encrypts the user-typed surface (name / description / address)
  but leaves lat/lng plaintext so the proximity matcher in
  tracking.svelte.ts can run during background geolocation logging without
  a vault unlock. The trade-off is documented inline in registry.ts: a
  handful of named POIs is much less sensitive than the full movement
  trail.
- `locationLogs` IS the movement trail, so every coordinate field
  (latitude, longitude, accuracy, altitude, speed, heading) is encrypted.
  Indexed columns (timestamp, placeId, [placeId+timestamp]) stay plaintext
  for the time-range scans in the log view.
- `placeTags` stays out of the registry — pure FK join table, no user
  content, same pattern as manaLinks.

queries.useAllPlaces / useLocationLogs now decrypt before mapping to the
DTO. placesStore.create/update snapshot the plaintext DTO before
encryptRecord mutates the local in place — same pattern as
notes/dreams/contacts. trackingStore.logPosition decrypts the place set
before running the nearest-place match (the lat/lng carve-out means this
still works pre-unlock, but downstream consumers want the decrypted name).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:41:00 +02:00
Till JS
e8de377cfe fix(macmini): mount prometheus config directly so /-/reload picks up edits
VictoriaMetrics + vmalert previously copied prometheus.yml/alerts.yml from
/mnt/prometheus-config/ into /etc/prometheus/ at container start. The copy
silently drifted from the host file whenever the container wasn't restarted —
which is exactly what hid the matrix/element removal from status.mana.how
until 2026-04-08, when VM was still actively scraping the deleted targets
because its in-container config snapshot pre-dated the cleanup.

Now both containers mount ./docker/prometheus directly into /etc/prometheus
(resp. /etc/alerts) read-only and point the binary at it, and deploy.sh
issues POST /-/reload to both after each deploy so config edits go live
without a container recreate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:25:48 +02:00
Till JS
a7f3577ee2 fix(voice): set MANA_LLM_URL on mana-web — $env/dynamic/private hides PUBLIC_
The first prod deploy of voice quick-add (3b41b39a3) silently fell
back for every transcript: title=transcript verbatim, dueDate=null,
priority=null, labels=[]. The endpoint code was reaching the
fallback() path even though mana-llm was healthy and reachable from
inside the mana-web container.

Root cause: SvelteKit's $env/dynamic/private explicitly excludes any
env var that starts with the public prefix (default PUBLIC_). The
parse-task code read

  env.MANA_LLM_URL || env.PUBLIC_MANA_LLM_URL || 'http://localhost:3025'

expecting to fall back to PUBLIC_MANA_LLM_URL when MANA_LLM_URL was
unset, but $env/dynamic/private treats PUBLIC_MANA_LLM_URL as if it
didn't exist on the server side. So it always fell through to
http://localhost:3025, which from inside mana-web is nothing,
fetch threw, and coerce returned the fallback shape.

Two fixes:

1. docker-compose.macmini.yml — set MANA_LLM_URL (no prefix) on
   mana-web alongside PUBLIC_MANA_LLM_URL. The PUBLIC_ var is still
   needed for the browser-side playground and status page; the
   private one is what the parse endpoints actually read.

2. parse-task and parse-habit — drop the dead env.PUBLIC_MANA_LLM_URL
   fallback so the next dev who reads the code doesn't think it'd
   ever work. Add a comment explaining the SvelteKit gotcha so the
   next person setting up a new env var doesn't repeat this mistake.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:20:03 +02:00
Till JS
5af4ddab3c test(integration): end-to-end auth flow test with Mailpit + CI gating
Adds a 13-step integration test that exercises register → email
verification → login → JWT validation → /me/data → encryption-vault
init/key → logout against a real stack of postgres + redis + mailpit +
mana-auth + mana-notify in docker compose.

Verified locally that this catches every regression we hit on
2026-04-08 in well under a second:

  - missing nanoid dependency → register endpoint 500
  - missing MANA_AUTH_KEK env passthrough → mana-auth never starts
  - missing encryption-vault SQL migrations → vault endpoints 500
  - wrong cookie name in /api/v1/auth/login → no accessToken in response
  - mana-notify SMTP misconfigured → mailpit poll times out

Files:

- docker-compose.test.yml — minimal isolated stack on alt ports
  (postgres 5443, redis 6390, mailpit 1026/8026, mana-auth 3091,
  mana-notify 3092). Runs alongside the dev stack without collision.
  Postgres healthcheck runs a real query rather than just pg_isready
  to avoid the race where pg_isready reports healthy while the docker
  init scripts are still running on a unix socket.

- tests/integration/auth-flow.test.ts — bun test that drives the full
  flow via fetch + mailpit's REST API. Cleans up its test user from
  postgres in afterAll. Self-contained, no extra deps.

- tests/integration/README.md — what's covered, why it exists, how
  to run locally + extend.

- scripts/run-integration-tests.sh — orchestrator. Brings up the
  stack, pushes the @mana/auth Drizzle schema, applies the
  encryption-vault SQL migrations (002, 003), restarts mana-auth so
  it sees the fresh tables, runs the test, tears down on exit.
  KEEP_STACK=1 to leave it up for manual mailpit inspection.

- docker-compose.dev.yml — also adds Mailpit as a regular dev service
  (ports 1025/8025) so local development can have a working email
  capture without spinning up the test stack.

- .github/workflows/ci.yml — new auth-integration job that runs on
  every PR. Calls run-integration-tests.sh; on failure dumps
  mana-auth + mana-notify logs and the mailpit message queue. Marked
  as a required check via the existing PR validation pipeline.

Reproduced 3 clean runs and 1 negative-control run (removed nanoid
from package.json → mana-auth container exits → script aborts with
non-zero) before committing. Full happy path runs in ~22s on a warm
Docker cache.
2026-04-08 17:14:02 +02:00
Till JS
3b41b39a32 fix(voice/parse-task): extract helpers to coerce.ts so prod build passes
SvelteKit's production build forbids non-handler exports from a
+server.ts file — dev runs them fine but `pnpm build` errored with
"Invalid export 'coerce' in /api/v1/voice/parse-task" when trying to
deploy mana-web with the new unit tests.

Move ParseResult, fallback, DATE_TRIGGER_PATTERNS,
PRIORITY_TRIGGER_PATTERNS, transcriptMentions, coerce, and extractJson
into a sibling coerce.ts module. The +server.ts file imports from
there and only exports POST, which is the prod build's hard rule.

Tests now import from ./coerce instead of from the route handler,
which also drops the $env/dynamic/private resolution dance from the
test fast path — coerce.test.ts now runs in ~130ms instead of ~400ms
because it pulls in zero SvelteKit runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:12:26 +02:00
Till JS
4f6609a595 docs(devlog): backfill daily devlogs for 2026-04-01 through 2026-04-07
Fills the gap between the last entry (2026-03-31) and today. The 2026-04-06
slot is intentionally skipped — no commits that day.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:11:10 +02:00
Till JS
0119e48edb test(voice/parse-task): unit tests for coerce + transcriptMentions guards
The deterministic guards in parse-task's coerce() are the load-bearing
defense against gemma3 hallucinating dueDate / priority on bare tasks.
The integration tests against the live LLM cover the happy path
end-to-end, but they go offline as soon as mana-llm is unreachable —
the unit tests cover the guard logic in isolation with synthetic LLM
responses, so a regression in the rules is caught even when the LLM
itself is dark.

22 cases:

- transcriptMentions: substring matching, case-insensitivity, empty
  pattern list, the German + English date words from the few-shot
  examples, and the negative cases ("Mülltonnen rausstellen",
  "Buy milk") that must NOT trigger.

- coerce: fallback shape on garbage input, transcript-as-title when
  the model omits one, time-component stripping ("2026-04-09T14:00:00"
  → "2026-04-09"), malformed dueDate rejection, the dueDate /
  priority hallucination guards (drop when the transcript has no
  trigger word), real-date / real-priority preservation, label
  filtering (cap at 3, drop non-strings, empty array on non-array
  input), invalid priority value rejection.

Helpers exported solely for the tests via a __test object — the
production endpoint goes through buildPrompt + coerce as before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:04:52 +02:00