Skip to content

Metic 3.0.0: unified TypeScript core + Bun server (#532) - #561

Open
hessius wants to merge 64 commits into
mainfrom
version/3.0.0
Open

Metic 3.0.0: unified TypeScript core + Bun server (#532)#561
hessius wants to merge 64 commits into
mainfrom
version/3.0.0

Conversation

@hessius

@hessius hessius commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Metic 3.0.0 — unified TypeScript core + Bun server

Epic #532. This is the architectural cutover that ends the dual-runtime split: all analysis / profile / curve / recommendation / dial-in / machine-API logic now lives once in a shared @metic/core TypeScript package, consumed by both the browser/native app and a new Bun server. The Python backend is deleted.

Highlights

  • @metic/core (packages/core): single source of truth for the request handler (handle() / tryHandle()), a host-agnostic Platform seam (storage, blob store, machine.fetch, AI provider), and every /api/* route family: profiles CRUD, shot history/analysis, dial-in, annotations, pour-over, recommendations, profile generation, image-proxy, system meta, settings. Backed by a contract-test parity suite.
  • Bun server (apps/bun-server, @metic/server): proxy-mode server + Node Platform (fs-JSON repos, env secrets, TTL AI cache, SQLite backend, timer scheduler). Transparent /api/v1 machine proxy, static SPA, handle() for /api/*, Socket.IO→WS telemetry hub, Tailscale routes over the LocalAPI unix socket.
  • Browser cutover: browser Platform (IndexedDB/Capacitor) implements the same seam; the legacy ~3700-line DirectModeInterceptor is deleted. Native iOS parity (image generation + profile-creation progress) confirmed on-device.
  • Python backend removed (feat!): apps/server gone; unified distroless single-binary image with a CI size gate.
  • Android: hardware back-button support.

Notes

  • VERSION stays 3.0.0-beta.1 (beta channel; latest stays 2.x).
  • All 13 open Dependabot PRs were resolved: 5 Python closed (backend removed), 8 web closed as superseded (3.0.0 already current; react-day-picker removed as dead code).
  • Parity enforced by contract tests; live-verified end-to-end against a physical machine + real Gemini throughout.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

hessius and others added 30 commits July 6, 2026 01:23
Approved brainstorm output: collapse the dual Python/TS runtimes onto a single
shared packages/core handler consumed by both apps/frontend (native + browser)
and a compiled single-binary Bun apps/server on distroless. Drops Python, nginx,
s6, mosquitto, MCP, and MQTT/HA; keeps Tailscale UI via LocalAPI-over-socket.
Seamless upgrade for existing server users (frozen /api/*, /api/ws/live, and
/data contracts; flat-JSON persistence behind a Storage interface).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
7-phase plan (monorepo foundation, core extraction, browser platform+host,
node platform+Bun server, tailscale/health/startup, distroless container,
cutover+rollout). Scheduled after 2.7.0. Full TDD step detail for Phase 1;
task-level detail for Phases 2-7 (re-baselined per phase at implementation).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Establishes the architectural keystone for the 3.0.0 unified TS core: a
single Web-standard request handler handle(req, platform) plus the Platform
interface that is the sole seam between portable logic and each host runtime
(browser direct mode, Bun proxy mode). Includes an in-memory mock Platform and
a contract-test harness that locks the frozen /api/* contract host-independently.

Refs #532

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
First Phase 2 extraction slice, proving the extract-and-rewire mechanism
end-to-end: the pure Espresso-Compass logic now lives canonically in
@metic/core (with its test as a core contract test), and the frontend consumes
it via a file: workspace dependency. Verified green: core tests, frontend
vitest, and the full vite build all resolve the linked package.

Refs #532, #528

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Moves the portable, framework-free logic into @metic/core as its canonical
home, each with its test as a core contract test (253 tests total):
analysisSchema, shotFacts, analysisLint, uuid, decentConverter, tags,
profileAnalysis, profileRecommendation. Frontend paths become thin re-export
shims (via the @metic/core file: dependency) so all existing importers keep
working unchanged. This is the substance of former issue #528 done as
extraction toward the unified core.

Verified green: 253 core contract tests + typecheck; 1118 frontend tests
(migrated suites now live in core); full vite build resolves the linked package.

Refs #532, #528

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ic/core

The web-builder stage flattened apps/web into /build and never copied
packages/core, so the frontend's file:../../packages/core dependency could not
resolve under bun install. Copy packages/core into the build context and build
from /build/apps/web, keeping the two-levels-up relative path intact (also fixes
the VERSION path read by vite.config). Verified with a local web-builder build.

Refs #532

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…o @metic/core

Moves the framework-free AI pieces into @metic/core (with their tests as core
contract tests): AIServiceError/AIErrorCode (aiErrors), the analysis domain
knowledge + fact-sheet builder (analysisKnowledge), and the shared prompt
builders (prompts). Frontend paths remain thin re-export shims. The i18n- and
SDK-coupled provider/retry/model-resolver layers stay in the frontend for the
later provider abstraction (Phase 2.6).

Verified green: 298 core contract tests + typecheck; 1073 frontend tests; full
vite build resolves the linked package; lint clean.

Refs #532, #528

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds apps/bun-server, the 3.0.0 single-binary Bun server that consumes
@metic/core. Verified end-to-end against a live Meticulous machine.

- platform/node.ts: filesystem-backed Node implementation of the core
  Platform interface (JSON repos over the existing /data shapes, env
  secrets, TTL aiCache, blob store, timer scheduler).
- machineProxy.ts: transparent reverse proxy for /api/v1/* to the machine
  (streams body, mirrors status/headers, strips hop-by-hop).
- static.ts: SPA static serving with immutable asset caching + index
  fallback and path-traversal protection.
- telemetryHub.ts: single upstream Socket.IO connection fanned out to
  browser WebSocket clients at /api/ws/live, emitting the exact flat
  snapshot shape (+ _ts, _heartbeat) the frontend already consumes;
  mapping mirrors direct-mode useMachineTelemetry for runtime parity.
- server.ts / main.ts: Bun.serve wiring (WS upgrade, /api/v1 proxy,
  /api/* -> handle(), static) and serve|healthcheck subcommands.

Additive only: Python apps/server and DirectModeInterceptor remain the
live runtimes until the Phase 7 cutover. 19 tests pass; typecheck clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Make the package's own `bun run typecheck` gate clean: explicit generic
on aiCache.get and unknown-cast fetch mocks. No runtime behavior change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port the shot-annotations CRUD route family into @metic/core against the
Platform storage seam, matching Python (shot_annotations_service.py) and
native (directModeStorage.ts) behavior exactly.

- handleAnnotationRoutes dispatcher: GET/PATCH/DELETE single annotation and
  GET summaries, keyed by `${date}/${filename}`.
- PATCH parity: rating-only merge keeps existing text; clearing both deletes.
- validateRating: integer 1-5, else 422; invalid JSON -> 400.
- Node Platform: add fsKeyedMapRepo backing annotations with a single
  shot_annotations.json object map (Python data-volume compatible, handles
  slash/colon-containing ids that the dir-based fsRepo would collide).
- 14 core contract tests + 1 node-platform repo test. Live-verified end-to-end
  through the Bun server against machine 192.168.50.168.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port the Dial-In Guide session routes into @metic/core against the Platform
storage seam, matching Python (dialin_service.py + dialin.py) and native
(directModeStorage.ts + DirectModeInterceptor.ts dispatch) exactly.

- handleDialInRoutes: create/get/list(+status filter)/delete sessions, add
  iterations (auto-incrementing, active-only), update iteration
  recommendations, rule-based /recommend, and /complete. Served under both
  /api/dialin/... and the bare /dialin/... alias.
- Validation mirrors the native runtime (coffee roast/process enums, taste
  x/y in [-1,1], recommendation string lists) and returns FastAPI-style
  { detail } errors with matching 400/404 status.
- Rule-based recommendations reproduce the exact taste-coordinate guidance and
  strings shared by both runtimes; the Python AI path layers on later with the
  provider abstraction (native /recommend is already rules-only).
- Node Platform: back dialInSessions with fsKeyedMapRepo -> dialin_sessions.json
  (id-keyed map matching Python's on-disk layout for Phase 7 migration), same
  approach as annotations.
- 20 core contract tests. Live-verified end-to-end through the Bun server
  against machine 192.168.50.168.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port the pour-over preferences routes into @metic/core against the Platform
storage seam, matching Python (pour_over_preferences.py + pour_over.py) and
native (directModeStorage.ts + DirectModeInterceptor.ts dispatch).

- handlePourOverRoutes: GET returns stored per-mode preferences (defaults when
  unset), PUT normalizes, persists, and echoes the normalized result.
- Validation mirrors the native runtime's strict typing (wrong types throw)
  and its ratio-mode defaults of dose 18g / ratio 15, a deliberate divergence
  from the Python nulls that this unification adopts since the native/browser
  runtime is what the frontend relies on. Unknown keys are dropped.
- Error shapes match native: 400 { detail: "Invalid preferences" } for bad
  input or invalid JSON, 500 for load/save failures.
- Node Platform: rename the singleton file to pour_over_preferences.json to
  match Python's on-disk layout for Phase 7 migration.
- 6 core contract tests. Live-verified end-to-end through the Bun server.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a PlatformAI seam (isConfigured/generateText/optional generateImage)
to the core Platform interface, mirroring the frontend AIProvider so the
browser Platform can delegate to the active provider in Phase 3.

Wire a Gemini-backed PlatformAI into the Node platform via @google/genai,
reading GEMINI_API_KEY/GEMINI_MODEL from the environment.

Port the dial-in recommendation prompt builder to core and make the
dial-in /recommend route AI-first: build the prompt, call the provider,
strip markdown fences, parse JSON, cap at 6 recommendations (source "ai");
on any error or when unconfigured, fall back to the rule-based path
(source "rules"). This matches the Python oracle and gives the native
runtime AI dial-in recommendations once wired in Phase 3.

Add scriptedAI/throwingAI/unconfiguredAI mock helpers and contract tests
for the AI path plus a prompt-builder unit test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend the Platform's machine seam with a fetch(path, init?) method so core
routes can read machine state (shot history, profiles) uniformly across hosts.
The Node platform joins relative paths onto the resolved base URL and fetches
server-side; the browser platform will fetch over the LAN in Phase 3.

Add scriptedMachine/unwiredMachine mock helpers for contract tests and cover
the Node URL-join + unconfigured-rejection behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port the shot-analysis building blocks from the native DirectModeInterceptor
into @metic/core so both runtimes share one implementation:
- computeRichLocalAnalysis (structured local shot analysis) + its helpers
- parseRecommendationsJson / isActionableRecommendation /
  isRecommendationPatchable / termMatches (RECOMMENDATIONS_JSON extraction)
- buildAnalyzeLlmPrompt and the PROFILING_KNOWLEDGE constant it needs

The ports are byte-identical (prompts) or logic-identical (analysis) to the
native source, reusing the already-ported buildShotFacts. Add contract tests
for the analysis output shape and the recommendation parsing/patchability
rules. These modules back the shot-analysis routes ported next.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add the /api/shots/analyze, /api/shots/analyze-llm and
/api/shots/analyze-recommendations routes to @metic/core, served by handle()
for both runtimes (also under the bare /shots/... alias):
- analyze: fetch the shot from the machine and return the structured local
  analysis (computeRichLocalAnalysis).
- analyze-llm: build the analysis prompt over the shot data, run it through
  the AI provider seam with the existing validate/retry/repair loop, and cache
  the result for recommendation extraction.
- analyze-recommendations: parse the RECOMMENDATIONS_JSON block (from the
  posted text or the cache) and flag patchability against the profile's
  variables fetched from the machine.

Reuses the ported shot-analysis logic, prompt builder and recommendation
parser. Adds 9 contract tests driven by the scriptedMachine/scriptedAI mocks.
Verified live through the Bun server against a real machine and Gemini:
structured analysis, a full AI analysis, and cached recommendation extraction.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add /api/profiles/recommend and /api/profiles/find-similar to @metic/core
(also under the bare /profiles/... alias), reusing the shared
profileRecommendation scorer. Profiles are read from the machine with
?full=true so stages are included for structural scoring in a single request,
matching the server's canonical behavior and improving the native runtime
(which previously scored off stage-less list data).

Adds 5 contract tests asserting parity with the shared scorer. Verified live
through the Bun server against a real machine catalogue.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add POST /api/profile/{name}/apply-recommendations to @metic/core (also under
the bare /profile/{name}/... alias). It patches selected recommendations onto a
machine profile and saves it back:
  - global temperature / final_weight,
  - adjustable profile variables (info-only / non-adjustable are skipped),
  - stage exit_triggers and limits,
  - a fuzzy fallback that recovers model-invented positional ids
    (e.g. "pressure_2") by type + current_value + stage.

Client-only metadata (change_id, in_history, has_description) is stripped
before saving, matching the native runtime. The server's defensive bounds
guards (temperature <= 100 C, final_weight > 0) are kept so invalid values are
skipped rather than written to the machine. Adds 12 contract tests.

Also fixes a latent DOM-lib typing issue in routes/profiles.ts (toNumber took
FormDataEntryValue, which is unavailable under the bun-server tsconfig) and
exports the MockMachine test type. Verified live through the Bun server against
a real machine (idempotent apply round-trips find -> get -> patch -> save).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add POST /api/profile/{id}/regenerate-description to @metic/core (also under
the bare /profile/{id}/... alias). It regenerates a profile's human-readable
description, preferring an AI write-up and falling back to a deterministic
static summary:
  - resolve the profile from the machine (identifier as profile id, then name),
  - when AI is configured, build the barista prompt (with the shared tags
    prompt), generate, strip the Tags line, resolve any leftover variable
    placeholders to concrete values, cache the description and parsed tags,
  - otherwise (or on AI failure / a "generated without AI" signal) build and
    cache the static description.

The two parity oracles resolve the profile from runtime-specific caches
(server: profile-generation history; native: machine shot history + in-memory
profile caches) that have no host-independent equivalent, so this port resolves
through the machine client, the one path available on every host. The browser
Platform will map the new description / ai-tags storage repos onto its existing
native caches in Phase 3.

Adds two new Platform storage repos (descriptions, aiTags) wired in the Node
platform (profile_descriptions.json, profile_ai_tags.json) and the mock. Ports
buildStaticProfileDescription + resolveDescriptionPlaceholders into
logic/profileDescription.ts (reusing the existing core tags helpers). Adds 13
contract tests. Verified live through the Bun server against a real machine +
real Gemini (AI description by id and by name, placeholder-free, tags stripped,
cached to disk, 404 path).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…he core package

Ports the flagship profile-generation route to @metic/core against the
Platform seam: builds the full profiling prompt (+optional image), runs the
AI generate/validate/retry loop, converts the Gemini JSON to OEPF, and writes
the new profile to the machine, caching the analysis text as its description.

Adds profileValidator, oepf, and the prompt/validate/retry helpers, all ported
byte/logic-identical from the native oracle.

Fixes a latent parity bug in the OEPF converter (both runtimes): object-form
dynamics whose points were already [t, v] arrays skipped $ref/number
resolution, so arithmetic expression strings (e.g. "10000 * ($ratio / 2.5)")
survived and the machine rejected the save with "is not referencing a variable
but is a string". Now array-form points are resolved like the other two
dynamics branches. Live-verified end-to-end against machine 192.168.50.168 +
real Gemini: profile generated, saved to the machine, then cleaned up.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ystone)

Adds apps/web/src/services/platform/browserPlatform.ts: the direct-mode
counterpart to the Bun server's Node platform. It implements the @metic/core
Platform interface with browser primitives so the SAME handle(request,
platform) can run entirely client-side, the foundation for replacing the
bespoke per-route logic in DirectModeInterceptor.

- Storage: IndexedDB-backed repos that persist documents VERBATIM under the
  settings key-value store, faithfully mirroring the Node fsKeyedMapRepo /
  fsSingletonRepo semantics (so the shared contract tests hold on both hosts).
  Blob store maps to the profile-image IDB store (Blob <-> Uint8Array).
- Machine: fetch joins core's path onto getDefaultMachineUrl(); takes an
  injectable original fetch to avoid re-entrancy with the fetch interceptor.
- AI: delegates to the frontend's active provider (identical generateText
  signature); image output converted Blob -> bytes. No scheduler (direct mode
  cannot run deferred actuation, so schedule routes 501, matching the legacy
  interceptor).

Includes an integration test that runs @metic/core handle() against the real
IndexedDB-backed platform (fake-indexeddb + fake machine fetch + scripted
provider) across annotations, dial-in, pour-over, machine-fetch, and AI seams.
7 tests. Not yet wired into window.fetch: the live cutover and
DirectModeInterceptor deletion are gated on on-device parity verification.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ore package

Adds the system/meta route family to @metic/core so the Bun server (proxy
mode) serves GET/POST /api/settings, GET /api/network-ip, and GET /api/version
that the frontend calls at startup. Previously only the native
DirectModeInterceptor implemented these; the server returned 404, leaving
settings and version unavailable in proxy mode.

- packages/core/src/routes/system.ts: settings payload mirrors the stable
  frontend contract (geminiApiKeyConfigured/meticulousIp/authorName/
  geminiModel/mqttEnabled); the raw AI key is never returned. Configured state
  uses platform.ai.isConfigured() (the canonical cross-host signal).
- platform.ts: add optional Platform.appVersion for GET /api/version.
- node platform: resolveAppVersion() (APP_VERSION env, else VERSION file);
  browser platform: appVersion from the __APP_VERSION__ build define.
- 9 contract tests; live-verified against the Bun server + machine
  192.168.50.168 (settings round-trip, version, network-ip) and via a headless
  browser load of the built proxy-mode frontend (startup 404s cleared).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rlay

Adds the /api/history family to @metic/core using native semantics
(user-approved): the machine shot history from /api/v1/history filtered
by local soft-delete tombstones, with per-shot notes and AI descriptions
overlaid from the Platform storage. Includes list, single detail with
profile_json, notes GET/PATCH round-trip, and DELETE tombstone.

Fixes a latent default-parameter bug where an absent limit query param
resolved to 0 (Number(null)) instead of the intended fallback of 50.

Live-verified through the Bun server against machine 192.168.50.168:
20 shots normalized, notes round-trip, tombstone delete 20 to 19.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds the GET read family to @metic/core with native semantics, all
served through the shared visible-history loader so local soft-deletes
apply consistently:
  - /api/last-shot
  - /api/shots/dates
  - /api/shots/recent
  - /api/shots/recent/by-profile
  - /api/shots/by-profile/{name}
  - /api/shots/data/{date}/{filename}

Extracts the machine-history store access (loadVisibleHistory, overlay,
notes, descriptions) into logic/historyStore so the history and shots
families share one implementation, and adds hasRecentShotAnnotation to
machineHistory. Annotation flags reuse the ported annotations summaries.

Live-verified through the Bun server against machine 192.168.50.168:
20 recent shots, 4 profile groups, paged by-profile, and 338-point
telemetry converted for /api/shots/data.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds the machine actuation and status family to @metic/core with native
semantics (mirrors DirectModeInterceptor + commands.py/system.py):
  - POST /api/machine/command/start | stop | load-profile
  - POST /api/machine/preheat
  - GET  /api/machine/status/health   (watcher /status on port 3000)
  - GET  /api/machine/system-info
  - GET  /api/machine/status          (synthetic idle)
  - GET  /api/machine/detect          (501)
  - POST /api/machine/schedule-shot   (501, no scheduler)

Ports the pure watcher-response transform + size/uptime parsers into
logic/watcher, reached through the machine seam which already passes
absolute URLs through on both platforms.

Live-verified through the Bun server against machine 192.168.50.168:
status/health returns 7 live services + system metrics, status idle,
detect 501; system-info degrades to null per key exactly as native does
against this firmware. Actuation paths covered by contract tests to
avoid dispensing on the live machine.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port the machine profile CRUD + actuation family (run-profile,
run-with-overrides, import, import-all, convert-decent, import-from-url,
order, load, rename, delete, machine/profiles list, profile/{id}/json,
orphaned, target-curves, edit, profile/{name} GET, sync/status, sync)
into @metic/core against the Platform seam, mirroring the native
DirectModeInterceptor oracle. Adds logic/targetCurves.ts and
logic/profileList.ts. 34 contract tests; live-verified against machine
192.168.50.168 (36 profiles, stages, target-curves, rename + edit
round-trips).

Fix a latent parity bug in BOTH runtimes: /target-curves resolved the
profile from the machine profile list, which omits stages, so it
returned an empty curve set for saved (non-override) profiles. Both
runtimes now fetch the full profile by id when the list entry lacks
stages, matching the include_stages fallback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port modelResolver (dynamic Gemini model discovery + ranking) into
@metic/core as the single source of truth; the native
services/ai/modelResolver becomes a thin re-export shim. Add optional
PlatformAI.listModels()/currentModel() seam, implemented by the Node
platform (Gemini SDK models.list + core ranking) and the browser
platform (active provider). Add system routes /api/available-models
(live discovery, static fallback, text-only filtering), /api/changelog
(GitHub releases), /api/update-method, /api/tailscale-status, and 501
stubs for check-updates/restart/beta-channel/feedback. 8 new contract
tests (445 core total). Live-verified against machine 192.168.50.168 +
real Gemini: 18 text models (no image/tts/embedding leakage), 5 real
releases, stubs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extract the bundled pour-over recipe library into
packages/core/src/data/recipes.ts as the single source of truth and add
routes/recipes.ts serving GET /api/recipes (sorted by name) and
GET /api/recipes/{slug} (404 on unknown). The native DirectModeInterceptor
now imports POUR_OVER_RECIPES from core instead of carrying its own inline
copy (removes ~11KB of duplicated data). Un-ignore packages/core/src/data
so the source asset is tracked. 3 contract tests (448 core total); native
interceptor suite still green (55). Live-verified against the Bun server:
9 recipes sorted, single-slug lookup, 404 path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The built config.json ships a dev serverUrl (http://localhost:8000) that
points at a dead host in production. The legacy nginx deployment rewrote
it to an empty string so the SPA talks to its own origin over relative
URLs. The Bun server now does the same: GET /config.json returns
{"serverUrl":""} regardless of the build artifact. Adds a booted-server
test and live-verified through the Bun server.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
hessius and others added 11 commits July 12, 2026 22:50
#532)

Close the last SPA-referenced /api/* gap before deleting the Python backend.
SyncReport.tsx posts to /api/profiles/sync/accept/{id}, but sync tracking (stored
profile_json + content_hash in the profile-generation history) is a legacy
server-only concept: in the unified runtime profiles live on the machine and the
SPA reads them directly, so sync/status already reports zero pending items and
there is nothing to accept. Return a benign success matching the frozen response
shape so the UI never leaks a notFound.

The other Python-only endpoints tested in test_main.py (/api/logs,
/api/history/migrate, /api/profiles/repair, /api/bridge/*) are not referenced by
the SPA and are intentionally dropped with the Python/MQTT stack in Phase 7.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e 7, #532)

BREAKING CHANGE: the 3.0.0 image is a single distroless Bun process. The Python
FastAPI server (apps/server), MCP server (apps/mcp-server submodule), MQTT bridge
(apps/bridge), Mosquitto broker, nginx and s6-overlay are all removed; their
logic now lives in @metic/core and is served by apps/bun-server.

Removed:
- apps/server, apps/mcp-server, apps/bridge
- docker/{mosquitto.conf,mosquitto-external.conf,nginx.conf,s6-rc.d,scripts,gemini-settings.json}
- docker-compose.homeassistant.yml and the Home Assistant MQTT integration

Deprecations (with notices in README/HOME_ASSISTANT.md/UPDATING.md):
- Home Assistant MQTT auto-discovery is gone; live telemetry + machine control
  are unaffected (served over the built-in /api/ws/live WebSocket).
- The in-UI self-updater is removed (/api/trigger-update -> 503); update via
  image pull or the optional Watchtower sidecar.

Infra + tooling:
- docker-compose.yml: drop the mosquitto-data volume, add STORAGE_BACKEND, and
  switch the healthcheck to the binary's own `/app/metic healthcheck` (no curl
  in distroless).
- scripts/install.sh + scripts/addons.sh: drop the HA/MQTT prompts and downloads
  (Watchtower + Tailscale addons retained). Legacy uninstall paths still purge
  the old mosquitto-data volume.
- CI (tests.yml): replace the Python server-tests + ruff/black lint steps with a
  Bun `Core + Server Tests` job (@metic/core vitest+tsc, apps/bun-server
  test+tsc).
- dependabot: swap the two pip ecosystems for npm on packages/core +
  apps/bun-server.

Version: bump VERSION and apps/web to 3.0.0-beta.1 (publishes on the beta tag
only; latest stays on 2.x until device validation).

Verified: @metic/core 539 tests + tsc clean; apps/bun-server 46 tests + tsc
clean; distroless image built + smoke-tested against machine 192.168.50.168
(health, version, static SPA, config override, tailscale-status, /api/v1 proxy,
core routes, /api/ws/live telemetry).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The MQTT bridge and MCP server backends were deleted in the 3.0.0 Phase 7
cutover, but PROXY_FLAGS still advertised bridgeStatus and mcpServer as
enabled. In proxy mode this surfaced a dead MQTT Bridge settings section
(Home Assistant links, addon snippets) and let /api/bridge/status leak a
404 notFound, breaking dual-runtime parity with native mode where both
flags are already false.

Turn both flags off in every mode, delete the now-unreachable MQTT Bridge
settings section plus its dead imports, snippet constants, copy helper and
mqttEnabled settings field, and drop the six orphaned i18n keys across all
6 locales. Update the flag and parity tests to classify both features as
removed (disabled everywhere).

Verified in-browser against the live machine: Settings renders with no
MQTT Bridge section, no Home Assistant references, and zero app-initiated
/api/bridge calls. Full web suite green (1036 tests).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Register a Capacitor App backButton listener on native Android and route
it through the same back-navigation the mobile swipe gesture uses, with
modal dismissal and an unsaved-changes guard layered on top. Priority:

1. Close the topmost open Radix overlay (dialog, sheet, menu, popover) by
   dispatching an Escape keydown, since the hardware back button emits no
   keyboard event. Also closes the app-level QR, add-profile and import
   dialogs.
2. If the current view has unsaved edits, confirm before discarding.
3. Navigate to the previous view (breadcrumb), mirroring the swipe map.
4. On the main screen there is nowhere to go back to, so the press is a
   no-op and the app is not exited.

The view-back mapping is extracted into a shared navigateBack() reused by
the swipe handler. Unsaved-changes state is exposed through a small global
registry (useUnsavedChangesGuard) so editable views opt in without
threading dirty state up to the app root; the profile detail editor is
wired up as the first consumer. Reuses the existing
profileEdit.unsavedChanges string, so no new locale keys are needed.

Native-only: no-op on iOS and the web.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
(cherry picked from commit 869f640)
Port the 2.6.0 on-device prompt compaction fix into the 3.0.0 unified
core architecture. On-device providers (Apple Intelligence, local Gemma)
advertise a small context window and were overflowing the full profile
and shot-analysis prompts, surfacing "exceeded model context window
size" errors during analysis and generic failures during profile
generation.

Add a contextWindowTokens seam so core can detect a small-window
provider and build compact prompt variants:

- @metic/core: PlatformAI.contextWindowTokens() optional seam;
  needsCompactPrompt() + threshold in ai/contextWindow.ts; compact
  variants buildCompactProfilePrompt and buildCompactAnalyzeLlmPrompt;
  shots.ts and analyzeAndProfile.ts pass compact when the provider
  window is at or below the threshold. Hosted Gemini omits the seam so
  it always gets the full prompt.
- apps/web: provider capability contextWindowTokens (LocalLLM = 4096),
  browserPlatform surfaces it into core, parity copy of the compact
  profile prompt, BrowserAIService honours it.

Dual-runtime parity: native profile-gen and shot-analysis run through
core; the provider-path parity copy keeps apps/web in sync. Tests added
on both sides.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The loadImages() promise inside the profile-image useEffect was neither
awaited nor error-handled, so a rejected fetchImagesForProfiles() would
surface as an unhandled rejection (ai-guard high-severity floating
promise). Add a .catch() that logs and swallows, matching the
non-critical loadSyncStatus() effect above it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port the 2.6.0 shot-analysis boundary fix to @metic/core. The machine flips a
telemetry sample's status to the next stage on the tick where the exit
condition fires, so the satisfying sample is labeled as the next stage. Exit
evaluation now captures each stage's boundary (the next stage's start_* values)
and, when the in-stage value falls short of a trigger, rescues it with the
transition value instead of falsely reporting "failed". Rescue-only so
already-satisfied triggers keep their in-stage reported value.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…osted key is saved

Two defensive fixes for a hosted API key not taking effect until reinstall or
re-onboarding:

- The App AI gate and native Keychain mirror hardcoded the Gemini key slot, so
  a non-Gemini hosted provider (OpenAI/OpenRouter/compatible) never flipped the
  gate. Both now use the active provider's slot and isAIConfigured() (mode- and
  provider-aware) as the single source of truth.
- Settings never called setAIMode('hosted') the way onboarding does, so a
  stored 'none'/degraded mode left a freshly entered hosted key inert. Saving a
  hosted key in Settings now promotes AI_MODE to hosted when it is not already
  hosted-inclusive, mirroring the onboarding path.

Red-green tested on both runtimes (App.direct + SettingsView.nativeKey).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
feat!: Metic 3.0.0 unified Bun + @metic/core runtime, delete Python backend
# Conflicts:
#	VERSION
#	apps/server/services/analysis_service.py
#	apps/web/package.json
#	apps/web/src/services/ai/profilePromptFull.compact.test.ts
#	apps/web/src/services/interceptor/DirectModeInterceptor.ts
#	apps/web/src/services/interceptor/analyzeLlmPrompt.test.ts
#	packages/core/src/ai/test_stage_boundary.py
The shadcn Calendar scaffold (ui/calendar.tsx) was never imported anywhere
in the app, making react-day-picker dead weight. Remove both instead of
upgrading unused code to v10, which also ends future Dependabot noise for
this dependency.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 16, 2026 05:31
Comment on lines +47 to +70
return async function serveStatic(pathname: string): Promise<Response> {
// Resolve within rootDir only; reject traversal.
const rel = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, "");
let candidate = join(rootDir, rel);
if (!candidate.startsWith(rootDir)) {
return new Response("Forbidden", { status: 403 });
}

if (pathname === "/" || pathname === "") {
candidate = indexPath;
}

if (existsSync(candidate) && !candidate.endsWith("/")) {
const isHtml = candidate.endsWith(".html");
return serveFile(candidate, isHtml ? NO_CACHE : IMMUTABLE);
}

// SPA fallback: unknown routes without a file extension serve index.html.
if (!extname(pathname) && existsSync(indexPath)) {
return serveFile(indexPath, NO_CACHE);
}

return new Response("Not found", { status: 404 });
};
Comment on lines +184 to +186
export async function getSession(platform: Platform, id: string): Promise<DialInSession | null> {
return sessionRepo(platform).read(id);
}
Comment on lines +119 to +124
page.map(async (entry) =>
normalizeHistoryEntry(
entry,
notesFor(overlay, entry.id),
await descriptionFor(platform, entry),
),
Comment on lines +18 to +37
export async function handleRecipesRoutes(
req: Request,
_platform: Platform,
): Promise<Response | null> {
const { pathname } = new URL(req.url);

if (pathname === "/api/recipes" && req.method === "GET") {
return jsonResponse(POUR_OVER_RECIPES);
}

const slugMatch = pathname.match(/^\/api\/recipes\/([^/]+)$/);
if (slugMatch && req.method === "GET") {
const slug = decodeURIComponent(slugMatch[1]!);
const recipe = POUR_OVER_RECIPES.find((r) => r.slug === slug);
if (!recipe) return notFound(`Recipe not found: ${slug}`);
return jsonResponse(recipe);
}

return null;
}
Comment on lines +33 to +62
export async function handleSchedulingRoutes(
req: Request,
_platform: Platform,
): Promise<Response | null> {
const { pathname } = new URL(req.url);
const method = req.method;

if (pathname === "/api/machine/recurring-schedules") {
if (method === "GET") {
return jsonResponse({ status: "success", recurring_schedules: [] });
}
if (method === "POST") return unsupported();
}

const recurringByIdMatch = pathname.match(/^\/api\/machine\/recurring-schedules\/([^/]+)$/);
if (recurringByIdMatch && (method === "PUT" || method === "DELETE")) {
return unsupported();
}

if (pathname === "/api/machine/scheduled-shots" && method === "GET") {
return jsonResponse({ status: "success", scheduled_shots: [] });
}

const scheduleShotByIdMatch = pathname.match(/^\/api\/machine\/schedule-shot\/([^/]+)$/);
if (scheduleShotByIdMatch && method === "DELETE") {
return unsupported();
}

return null;
}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

The Phase 6 distroless single-binary image has no curl, so the
post-build container smoke test in build-publish.yml failed at
`docker exec test-container curl -f .../health` even though the
container reports healthy. Switch to the binary's own healthcheck
subcommand, matching tests.yml.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
hessius and others added 2 commits July 16, 2026 18:52
Adds a shared @metic/core profile-source resolver (resolveProfileFromSource)
that turns any share input into a Meticulous profile: metprofiles.link/profile/{id}
links (resolved via the site's public /api/profiles/{id}/download endpoint),
direct links to JSON profiles, and raw profile JSON text. Decent Espresso
profiles are auto-detected and converted in every path.

The /api/import-from-url route now runs every input through the resolver, so
both runtimes (Bun server + browser/native app) behave identically. The
ProfileImportDialog 'From link' step becomes a unified link-or-JSON source box
(metprofiles, direct URL, or pasted JSON), sharing one code path.

Verified end-to-end: the shipped resolver fetches the live metprofiles Slay-ish
link and produces a profile deep-equal to the machine's JSON export.

Tests: 15 resolver unit tests + 4 route contract tests + 3 dialog component
tests. i18n updated for all 6 locales.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…caffold)

Adds the native "share a profile to Metic" path, complementing the web
`?import=` flow landed in 2a23a96. Shared content (a metprofiles link, a
direct profile URL, pasted profile JSON, or a shared .json/.met file) is
distilled into a single source string and fed through the same @metic/core
resolver and Add Profile dialog as the web importer.

- apps/web/src/services/shareImport.ts: extractShareSource (pure, tested),
  readSharedFile, registerShareTargetListener over @capgo/capacitor-share-target.
- App.tsx: share listener effect reuses the pendingImportUrl auto-import flow.
- Android: SEND intent-filters (text/*, application/json) on MainActivity.
- capacitor.config: CapacitorShareTarget appGroupId group.com.metic.app.
- iOS: metic:// URL scheme + App Group entitlement + Share Extension source
  (ShareViewController writes the plugin's share-target-data App Group key).
  The extension target/App Group signing must be added in Xcode; see
  apps/web/ios/ShareExtension/README.md. iOS files are inert until wired, so
  the CI iOS build stays green.

Verified: web tsc clean, eslint 0 errors, 1077 tests pass (8 new share tests).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
const { CapacitorShareTarget } = await import('@capgo/capacitor-share-target')
const handle = await CapacitorShareTarget.addListener('shareReceived', (event) => {
void extractShareSource(event as SharedContent).then((source) => {
if (source) onSource(source)
hessius and others added 8 commits July 17, 2026 06:26
Replaces the plugin README stub in the Xcode-created ShareExtension target
with a full implementation and wires up the App Group so shared content
actually reaches the app.

- ShareViewController: extracts a shared URL / text / .json / .met file,
  copies files into the App Group container, writes the plugin's
  share-target-data key, and foregrounds the app via metic://share.
- ShareExtension.entitlements + CODE_SIGN_ENTITLEMENTS on both the app and
  extension targets: App Group group.com.metic.app (previously the group was
  declared in capacitor.config but not bound to either target's signing).
- Info.plist: scope the activation rule to web URLs, text and one file so
  Metic no longer appears in the share sheet for photos, contacts, etc.
- Remove the now-redundant ios/ShareExtension scaffold; the live target lives
  at ios/App/ShareExtension (Xcode 16 synchronized folder).

Verified: swiftc -typecheck against the iphoneos SDK passes (0 errors).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Share-sheet and `?import=` deep-link imports now run headlessly and surface
a central popover reporting success (with the profile name), already-exists,
or failure (with a server-provided reason when available). Both paths and the
Add Profile dialog share a single `importProfileFromSource` service so their
behavior stays in lock-step.

Also lazy-loads the native-only capacitor-zeroconf plugin inside the discovery
native branch. The plugin's web stub creates a module-level rejected promise on
import, which floated as an unhandled rejection in the web bundle and tests;
loading it only on native keeps that out of web entirely.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tion

Share extension now targets UIApplication in the responder chain to reliably
foreground the host app, delays teardown so the app-switch isn't cancelled, and
falls back to a native "Open Metic" card when auto-open fails so the persisted
share is never stranded.

Apply the native-app selection/callout guards at the body level so Radix dialogs
and Sonner toasts (rendered in body-level portals outside #root) no longer allow
inadvertent text selection or let the iOS selection magnifier steal taps from the
dismiss button.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a "Removed in 3.0.0 (server version)" section covering the Home Assistant
MQTT bridge, the MCP server, and the in-app self-updater, and broaden the
telemetry note to point to it. Update the Architecture section to the unified
Bun single-binary layout (drop the stale nginx/FastAPI/MCP/Mosquitto/MQTT
diagram) and fix the footer "Runs on" line accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…reak

Upgrading to the 3.0.0 distroless image while keeping the old 2.x
docker-compose.yml leaves a curl-based healthcheck that can't run in the
shell-less image, so the container is flagged unhealthy and autoheal/Watchtower
restart-loops it (home screen stuck loading). Document the cause and the fix
(re-pull the maintained compose file or switch the healthcheck to
`/app/metic healthcheck`, then force-recreate), and mark the removed Web UI
updater option as gone in 3.0.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
In proxy/server mode the app's mount-time profile check awaited
/api/history, which in 3.0.0 is backed by the espresso machine's shot
log and can take 10s+ (and effectively hang under concurrent load).
Because isInitializing was only cleared in that fetch's finally block,
a slow or unresponsive machine left the home screen stuck on the
loading spinner indefinitely (Settings still worked as it renders
outside the init gate).

Clear the initializing gate immediately and populate the profile count
in the background, guarded by a 5s AbortController timeout so a slow or
hanging machine never leaves a request pending. profileCount only feeds
a cosmetic FormView button and never gates routing, so unblocking init
is safe. Native/direct mode already short-circuits and is unaffected.

Verified in headless WebKit against the live slow machine backend: the
start view now renders immediately instead of hanging on 'Loading...'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The trailing-** cleanup used a negative lookbehind /(?<!\*)\s+\*\*$/.
rolldown lowers lookbehind regex literals into runtime RegExp() calls,
which throw a SyntaxError on Safari < 16.4, breaking older-Safari web
users. Replace with an equivalent capture of the preceding non-* char
(or line start) so no lookbehind is emitted. Verified absent from the
production build; added regression tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants