The who module's chat endpoint was returning 502 to the browser
because mana-api called /api/v1/chat/completions on mana-llm and
got 404 — mana-llm exposes the OpenAI-compatible /v1/chat/completions
path with no /api/ prefix.
This is the same bug research had until commit 63a91e36a fixed its
path. The chat module (apps/api/src/modules/chat/routes.ts) still
has the wrong path — flagged as a follow-up.
Diagnostic from inside the mana-api container:
/v1/chat/completions → 422 (right path, empty body)
/api/v1/chat/completions → 404 (wrong path)
mana-api log line that flagged it:
who.llm_non_200 status:404
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mirror the planta two-step pattern: a FormData upload endpoint that
returns mediaId/publicUrl from mana-media, and a separate Gemini Vision
analysis endpoint that takes a photoUrl. Drops the base64 inline path
and the half-finished parallel-upload kludge in the old combined route.
Why: the old endpoint was wired neither in the frontend nor used
elsewhere, and the combined base64+upload+analyze design made it
impossible to show the photo to the user before AI ran or to re-analyze
without re-uploading.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
oven/bun:1 doesn't ship with npm or pnpm, so the previous
`RUN npm install -g pnpm@9.15.0` failed with `/bin/sh: 1: npm: not
found` on the first Mac Mini build. Bun's own install command
doesn't honor pnpm-workspace.yaml, so we can't use it as a drop-in.
Switch the builder stage to node:20-alpine which has npm built in,
install pnpm there, resolve the workspace graph, then COPY the
finished tree into the bun runtime stage. The runtime stage stays
on oven/bun:1 — bun handles pnpm's node_modules/.pnpm symlink farm
natively, so the workspace layout works the same as it does on a
developer machine.
Tested locally: `docker compose -f docker-compose.macmini.yml build
mana-api` now succeeds through the install stage. The runtime
stage is unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the missing production deployment artifacts for the unified
apps/api Hono/Bun server. Until now apps/api was code-only — built
during the consolidation sweep but never wired into the Mac Mini
compose stack, so all 17 product modules that depend on it
(calendar, todo, picture, planta, nutriphi, news, traces, presi,
music, contacts, storage, context, guides, research, chat, moodlit,
who) effectively had no backend in production. The frontend modules
shipped, but their compute calls fell through to localhost:3060 in
the browser and just failed.
This commit fixes the gap.
apps/api/Dockerfile (NEW)
-------------------------
Multi-stage Bun build that runs from the monorepo root so the four
workspace dependencies (@mana/shared-hono, @mana/shared-logger,
@mana/shared-storage, @mana/media-client) actually resolve. Builder
stage installs via pnpm with the --filter @mana/api... selector to
keep the install graph minimal; runtime stage copies the resulting
workspace tree (including the pnpm symlink farm) and runs the entry
script with bun directly — no compile step, since bun handles
TypeScript natively.
@mana/media-client lives under services/mana-media/packages/client,
not packages/, so the COPY path is the awkward
services/mana-media/packages/client → ./services/mana-media/packages/
client mirror to keep the workspace layout intact.
Healthcheck hits /health every 30s with a 15s start period — same
shape as the other Bun services in this compose file.
docker-compose.macmini.yml — new mana-api service
-------------------------------------------------
Slotted between glitchtip-worker and the games section. Build
context is the monorepo root (`.`) because the Dockerfile needs the
workspace tree. Container name `mana-api`, image `mana-api:local`,
mem_limit 384m (higher than the smaller Bun services because the
unified server holds 17 modules' route definitions + Drizzle schema
caches in memory).
Environment wires up everything apps/api needs:
- MANA_AUTH_URL → mana-auth:3001 for JWT validation
- MANA_LLM_URL → mana-llm:3025 for chat / picture / who LLM calls
- MANA_SEARCH_URL → mana-search:3012 for guides / research
- MANA_CREDITS_URL → mana-credits:3002 for credit validation
- MANA_MEDIA_URL → mana-media:3011 for image uploads
- DATABASE_URL → mana_platform Postgres for the few server-side
state stores (research_results, presi share-links, traces guides)
- MANA_SERVICE_KEY → for the credit/auth service-to-service calls
- LOGGER_FORMAT=json → structured logs for grafana ingestion
- CORS_ORIGINS=https://mana.how → only the unified web origin
needs access, the standalone game frontends don't call this
Port 3060 is exposed on the host so cloudflared can route
api.mana.how → mana-api:3060 (separate Mac Mini side step, not
in this commit).
docker-compose.macmini.yml — mana-web wiring
--------------------------------------------
Two new env vars:
PUBLIC_MANA_API_URL=http://mana-api:3060
PUBLIC_MANA_API_URL_CLIENT=https://api.mana.how
The hooks.server.ts injection plumbing for window.__PUBLIC_MANA_API_URL__
already existed (added in an earlier sweep but never had a value to
inject). The CSP connect-src list and the SSR injection script tag
also already include PUBLIC_MANA_API_URL_CLIENT — so once the env
arrives, the existing client-side getManaApiUrl() helper picks it
up automatically.
mana-web also gets a depends_on entry on mana-api with
condition: service_healthy so the web container doesn't start
serving requests against a dead API.
Verification
------------
docker compose -f docker-compose.macmini.yml config validates
cleanly (no YAML errors). Image build is NOT exercised in this
commit — that happens on the Mac Mini via build-app.sh after the
push lands.
Out of scope for this commit (Mac Mini side, manual steps):
1. ssh mana-server, git pull
2. ./scripts/mac-mini/build-app.sh mana-api (first build, ~3-5 min)
3. ./scripts/mac-mini/build-app.sh mana-web (rebuild with new env)
4. cloudflared route: add api.mana.how → mana-api:3060 to
~/.cloudflared/config.yml and `systemctl restart cloudflared`
5. Test https://api.mana.how/health from anywhere
6. Test https://mana.how/who in a browser
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server side of the who module. Three endpoints under /api/v1/who/*:
POST /chat
Hot path. Body: { gameId, characterId, message, history[] }.
Looks up character by id (server-side only — clients never see
personalities), builds a system prompt instructing the LLM to
roleplay the figure WITHOUT revealing its name and to append
[IDENTITY_REVEALED] when the player has guessed correctly,
forwards to mana-llm. Response: { reply, identityRevealed,
characterName? } — characterName only present on win.
Same credit pattern as chat module: validateCredits + consume
after the LLM call succeeds. Operation 'AI_WHO', cheap (0.1
credit) for local models, 5 for cloud.
POST /random
Picks a random character from a deck and returns just the id +
category + difficulty. Frontend uses this to start a new game
without ever knowing the personality pool. Server-side
randomness so a determined attacker can't predict picks.
POST /guess
Explicit "I think it's X" submission. Fallback path for when
the LLM forgets to emit the sentinel even though the player
clearly said the right name. Deterministic lowercase substring
match against the canonical name (with diacritic stripping +
last-name-only matching for unambiguous figures like "Tesla").
GET /decks
Public deck catalogue with counts and category labels. Zero
sensitive data — never leaks names or personalities. Used by
the picker UI on mount.
data/characters.ts holds 37 characters: the original 26 from
whopixels verbatim + 11 new for the antiquity / women / inventors
decks. Each entry is in one or more decks via a `decks` array, so
e.g. Marie Curie shows up in both `historical` and `women`. Adding
a new character is one entry.
The system prompt is the carefully-tested German prompt from the
original whopixels server.js — tells the LLM to respond in the
language the user writes, give subtle hints, never directly say
"I am X", and emit the sentinel only on a correct guess.
The explicit-guess matcher catches three patterns:
1. Exact normalized match ("Marie Curie" === "marie curie")
2. Last-name-only ("Curie" matches "Marie Curie")
3. Guess-contains-name ("I think it's Marie Curie" → contains)
Closes Phase A.1 of docs/WHO_MODULE.md.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
Rename the music module from "Mukke" to "Music" across the entire
codebase: API routes, web app module, shared packages, search provider,
dashboard widgets, i18n keys, app registry, and route paths.
Add POST /api/v1/music/cover/upload endpoint that uploads cover art
images through mana-media for deduplication, thumbnails, and Photos
gallery visibility.
Dexie table names (mukkePlaylists, mukkeProjects) kept unchanged to
preserve existing IndexedDB data.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Introduces a central `timeBlocks` table that owns the time dimension
(start, end, recurrence, live status) for all modules. Calendar, times,
habits, and todo modules keep only domain-specific data with a
timeBlockId reference. The calendar becomes a universal time view
showing events, tasks, habits, and time entries from all modules.
Key changes:
- New `$lib/data/time-blocks/` module (types, service, queries, collections)
- Dexie schema v3 with timeBlocks table + migration from existing data
- Calendar events store creates TimeBlock + LocalEvent pairs
- Times timer uses TimeBlock.isLive instead of LocalTimeEntry.isRunning
- Habits logHabit creates point-event TimeBlocks (with optional duration)
- Todo scheduled tasks create TimeBlock via scheduledBlockId
- Calendar views filter by blockType, show items from all modules
- All calendar views use getItemColor() for cross-module color support
Also includes mukke → music module rename.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Picture, Contacts, Planta, Storage, and NutriPhi image uploads now go
through mana-media instead of directly to S3. This enables SHA-256
deduplication, automatic thumbnail generation, EXIF extraction, and
makes all images visible in the Photos gallery. Non-image files (PDFs,
audio, docs) continue to use shared-storage directly. SVG avatars in
Contacts also stay on shared-storage since Sharp can't process SVGs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Complete consolidation of all 15 app servers into one Hono/Bun process.
Modules added: chat, context, picture, storage, todo, planta, nutriphi,
guides, moodlit, news, traces, presi
Total: 15 modules, one server, one port (3050), ~2400 LOC.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New consolidated Hono/Bun API server at apps/api/ that replaces individual
app servers. One process, one port, one auth middleware, one container.
Modules ported:
- calendar: RRULE expansion, ICS import, Google Calendar (stub)
- contacts: avatar upload (S3), vCard import/parsing
- mukke: audio upload/download presigned URLs, batch cover art
Architecture: each module registers routes under /api/v1/{module}/*
using the shared-hono middleware stack (auth, rate limit, error handler).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>