Metic 3.0.0: unified TypeScript core + Bun server (#532) - #561
Open
hessius wants to merge 64 commits into
Open
Conversation
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>
#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>
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; | ||
| } |
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>
This was referenced Jul 16, 2026
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) |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/coreTypeScript 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-agnosticPlatformseam (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.apps/bun-server,@metic/server): proxy-mode server + NodePlatform(fs-JSON repos, env secrets, TTL AI cache, SQLite backend, timer scheduler). Transparent/api/v1machine proxy, static SPA,handle()for/api/*, Socket.IO→WS telemetry hub, Tailscale routes over the LocalAPI unix socket.Platform(IndexedDB/Capacitor) implements the same seam; the legacy ~3700-lineDirectModeInterceptoris deleted. Native iOS parity (image generation + profile-creation progress) confirmed on-device.feat!):apps/servergone; unified distroless single-binary image with a CI size gate.Notes
VERSIONstays3.0.0-beta.1(beta channel; latest stays 2.x).Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com