feat(studio): Phase 1a foundation - mode gate, op registry, object store#87
Merged
Conversation
First slice of Nomos Studio (hosted-only photo editor). Dependency-free, unit-tested, no cloud calls yet. - config/mode.ts: FEATURES.studio() gate (hosted-only, the inverse of the BYO gates). - studio/ops.ts: versioned zod op registry (OP_SPEC_VERSION) and per-op routing metadata (kind / localized / identityRisk) used by the engine and the identity gate. validateOp() is the single validation point before an op joins the chain. - storage/object-store.ts: ObjectStore interface + local-fs driver (dev/eval) with org-scoped keys, path-traversal guards, content hashing, and dev presign. The cloud driver targets Google Cloud Storage (GCP-only, ADC / workload identity, V4 signed URLs; no AWS) and lands with the hosted infra. Tests: 19 new (ops 9, object-store 10). typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ing) - db/schema.sql: studio_assets + studio_edits tables (idempotent), with parent_edit_id, idempotency_key (UNIQUE per asset), preview_key, content_hash, status, cost_usd, identity_score; FK cascade so GDPR delete drops the chain. - db/types.ts: Kysely table interfaces + Database registry. - studio/assets.ts: TenantContext-scoped CRUD + appendEdit() with transactional idempotency (a committed key returns the existing edit) and optimistic concurrency (StaleParentError on a non-head parent), advancing head_edit_id atomically. Tests: 9 (OCC, idempotency, scoping, mapping) via the Kysely mock. typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- studio/engine.ts: StudioEngine.edit() capability router. validate -> load asset -> consent gate (generative only) -> append (OCC + idempotency) -> run provider -> identity gate (face-risk ops) -> persist output + ~256px preview -> record. Providers, object store, identity gate, consent check, and preview maker are all injected, so it is testable without sharp, the Google SDK, a DB, or a bucket. - studio/consent.ts: org-level cloudAI toggle (config-backed, default OFF) and ConsentRequiredError. Deterministic/on-device ops are never gated. - studio/identity-gate.ts: assertIdentityPreserved (pluggable face embedder, cosine similarity, IdentityDriftError; skips when no embedder so dev/eval run without an embedding model). The manifest invariant's entry point. Tests: 18 new (engine 6, consent 5, identity-gate 7). typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(GCP) - deps: sharp 0.35, @google/genai 2.8. - providers/local-sharp.ts: LocalSharpProvider for adjust/crop (free, offline), makePreview (~256px history thumbnail), and compositeMasked (mask-bounded paste-back so untouched pixels stay bit-exact). - providers/gemini-image.ts: GeminiImageProvider for the cloud ops (editSemantic, eraser, cutout, upscale, restore) through an injectable GenAIImageClient. One SDK, two surfaces (Gemini API dev / Vertex prod, ADC, GCP-only, no AWS). Localized ops paste-back via the sharp compositor; a safety refusal is a typed ProviderRefusedError. The real client wraps @google/genai. Tests: 9 (local-sharp 6 incl. pixel-level paste-back, gemini 3 with a fake client). typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- sdk/studio-mcp.ts: buildStudioMcpServer(userId) exposes studio_edit (instruction), studio_adjust, studio_cutout, studio_upscale, studio_restore, studio_history, scoped per-user via TenantContext. buildStudioEngine() wires LocalSharpProvider always + GeminiImageProvider when GCP creds are present; a consent error returns a clear "enable Cloud AI" message to the agent. - agent-runtime: inject nomos-studio per turn, gated on FEATURES.studio() so power-user installs never load image tooling. Tests: 2 smoke. typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- studio/gc.ts: runStudioGc (per owner) + runStudioGcForUser. Two sweeps: expire unconfirmed uploads past a TTL, and aged intermediate edit results that are no longer the chain head; drop their objects (originals + the live head output are kept). DB is the clock: rows are marked expired before the object is deleted. - cron-engine: handle __studio_gc__ (runs runStudioGc). - gateway: seed the studio-gc job (every 24h) when FEATURES.studio(). Tests: 3. typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two entries so the spec audit guards wiring + DB effects: - studio-gc (cron sentinel __studio_gc__): liveness on runStudioGc / runStudioGcForUser; effect on studio_edits status='expired' (notExercised); invariants (originals kept, expire-before-delete, user_id-filtered). - studio (turn, hosted-gated): liveness on buildStudioMcpServer / buildStudioEngine / assertIdentityPreserved; effects on studio_assets + studio_edits + a noDoubleEncode guard on params; invariants (immutable original, user_id-filtered, consent gate, identity gate, idempotency). The cron meta-check is satisfied: __studio_gc__ is handled (cron-engine), seeded (gateway), and declared here. Full verification is pnpm eval:audit (needs a DB). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
proto/nomos.proto: StudioCreateAsset, StudioGetAssetUrl, StudioEdit (stream MStudioEvent), StudioHistory + their messages. Blobs move via presigned PUT/GET, never gRPC. src/daemon/mobile-api.ts: the four handlers, user_id-scoped through the authenticated TenantContext. - StudioCreateAsset: create a pending asset + return a presigned PUT url. - StudioGetAssetUrl: presigned GET for the current head (or original). - StudioEdit: stream progress/done/error; confirms a pending asset, resolves the parent to the head, runs the engine; maps ConsentRequired/StaleParent to clear user messages. - StudioHistory: the op chain + the current head. Runtime-loaded proto, so no daemon codegen. typecheck clean; full suite 564 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- scripts/isolation-check.ts: write assets + edits as two users through the real createAsset/appendEdit, then assert neither user can getAsset or listEdits the other's rows; sweep studio_edits + studio_assets in cleanup. - studio-mcp.ts: drop em dashes from the tool-list comment (house style). typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P0:
- consent bypass: cutout was kind:"deterministic" but only the cloud provider
supports it, so it reached Google with consent OFF. The consent gate now keys
off the RESOLVED provider's kind (new StudioProvider.kind), not the op's
declared kind; cutout is relabeled generative. (engine, ops, providers)
- data loss: the conversational/MCP path never confirmed an asset, so it stayed
pending and __studio_gc__ deleted the ORIGINAL after 24h. engine.edit now
confirms a pending asset, and the GC pending-sweep only reaps assets with no
edits (head_edit_id IS NULL). (engine, gc)
P1:
- appendEdit locks the asset row (FOR UPDATE) so concurrent appends serialize
(no chain fork, no 23505 on a racing retry). (assets)
- appendEdit returns {edit, created}; the engine returns an idempotent retry
(created=false) without re-running the provider or re-charging. (assets, engine)
P2:
- studio_edits.params + studio_assets.metadata were JSON.stringify'd into jsonb
(double-encode). Pass the object so kysely-postgres-js single-encodes, matching
the guarded style_profiles / auto_dream_state pattern. (assets)
- GC marks rows expired BEFORE deleting objects (DB is the clock). (gc)
- StudioEdit validates the asset id is a UUID and the client mask_key is under the
tenant org prefix; the stream is wrapped so call.end() always runs.
GetAssetUrl/History validate the UUID -> graceful not-found, not a 500. (mobile-api)
The identity gate remaining a no-op until a face embedder is installed is the
documented Phase 1a state (assertIdentityPreserved skips with a warning).
Regression tests added (cutout consent, GC head-guard, FOR UPDATE, idempotent
retry). typecheck clean; full unit suite 565 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Scrub product-facing descriptions from comments + the manifest summary: drop the "photo editor" / "Nomos Studio" framing (now just "Studio, a hosted-only feature") and references to the private design doc. Code, symbols, gates, RPCs, and the functional in-tool descriptions are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the identity gate's daemon side, two ways: - studio/face-embedder.ts: an optional server-side embedder over an operator-provided face-recognition ONNX model (NOMOS_FACE_MODEL_PATH), loaded lazily via onnxruntime-node (an OPTIONAL dep, non-literal import so build never requires it). Preprocesses a face crop with sharp -> CHW float32, runs the model, returns the embedding. Graceful no-op when the runtime/model is absent. installServerFaceEmbedder() wires it via setFaceEmbedder at gateway boot when configured (gated on FEATURES.studio()). - Privacy-preferred path: StudioReportIdentity RPC + recordIdentityScore let the on-device (iOS Vision) check report its similarity score (0..1, clamped), recorded on the edit's identity_score. UUID-validated, user-scoped. The face model is deliberately NOT bundled (keeps the repo light) and the on-device check is the primary path; the server embedder is for operators who provide a model. Tests: face-embedder graceful-degradation (3) + recordIdentityScore (1). typecheck clean; full unit suite 569 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…upport Found by a real end-to-end run against the local DB + Gemini: - markEditFailed now rolls the asset head back to the failed edit's parent (in a transaction, conditional on head still being that edit), so a failed edit never leaves the chain head on a dead node. Verified: after a failed generative edit, HEAD points at the last successful edit. - the Gemini provider + buildStudioEngine read GOOGLE_API_KEY (repo convention, matches embeddings.ts) alongside GEMINI_API_KEY; surface detection prefers the API key (dev) and falls back to Vertex (prod, via GOOGLE_CLOUD_PROJECT / NOMOS_STUDIO_PROVIDER). - scripts/studio-e2e.ts: a real end-to-end dev exercise (create -> adjust -> idempotent retry -> generative -> assert rows/objects; self-cleaning). The same run confirmed the deterministic pipeline (DB + object store + preview + OCC/idempotency) and that the manifest effect SQL goes nonzero. typecheck clean; full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Synthetic/solid images trip Gemini's IMAGE_RECITATION guard; a real photo (picsum, synthetic fallback) exercises a true edit. Confirmed end-to-end against the local DB with a paid Gemini key: upload -> deterministic adjust + preview -> idempotent retry -> real generative edit (editSemantic, 1.76MB output, $0.039) -> op chain all done -> effect SQL nonzero. The full hosted pipeline works. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On-device renders (Core Image adjust, MediaPipe makeup/reshape) were preview-only. This adds a `deviceRender` op so the client can persist the exact pixels it previewed (WYSIWYG) as a non-destructive edit in the chain. - ops: deviceRender schema (tool/detail label) + meta (deterministic, never consent- or identity-gated; the user already saw the result). - engine: EditRequest.inlineInputBytes; for deviceRender the client bytes ARE the provider input (the chain source is still loaded only when an identity gate needs an original-vs-result comparison). Inline bytes are never honored for any other op (no source bypass). - local-sharp: deviceRender re-encodes through sharp (strips EXIF/GPS, rejects a malformed upload, clamps the long edge to 4096px). - proto + MobileApi.StudioEdit: bytes input_image (capped ~12MB, deviceRender only). - tests: ops + engine (inline-bytes path, missing-bytes failure) + local-sharp (re-encode/clamp, malformed reject). Verified end to end against the local DB via studio-e2e: chain adjust -> adjust -> deviceRender -> editSemantic, all done, deviceRender output stored at $0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Boots the actual grpc-js MobileApi server in power-user mode and drives StudioEdit op=deviceRender + input_image bytes over a real client: asserts a done event, a stored JPEG output, the persisted studio_edits row, and that the handler rejects input_image on a non-deviceRender op. Covers the wire layer (proto bytes decode -> handler guards -> engine) that studio-e2e bypasses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uncher
Wires the deterministic retouch op to the new private nomos-studio-sidecar
(FastAPI + MediaPipe + OpenCV) over localhost HTTP, with a graceful cloud
fallback.
- ops: `retouch` ({strength}). Meta deterministic + low identity-risk; consent
follows the RESOLVED provider (sidecar = free; Gemini fallback = consent-gated).
- SidecarProvider (deterministic): POSTs to the sidecar /v1/edit, pins the v1
contract. Registered in buildStudioEngine BEFORE Gemini so a reachable sidecar
wins; absent -> retouch falls through to the Gemini fallback (prompt added).
- sidecar-launcher: three launch modes (external URL / `uv run` from the sibling
clone / pod container). Best-effort; null -> cloud fallback. Daemon-scoped URL
singleton (buildStudioEngine runs per-turn). Wired into gateway start/stop
behind FEATURES.studio().
- studio_retouch MCP tool; .env.example entries.
- tests: ops, SidecarProvider (mock fetch), launcher (external-URL/idempotent).
Verified end to end against a live sidecar via studio-sidecar-check: retouch
routed to mediapipe-sidecar at $0, output stored.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/expand/sky) Adds the Phase 3 generative depth bets as first-class ops routed through Gemini (Vertex in prod), each with a tuned prompt and a studio-mcp tool: - muscle (AI Muscle: abs/arms/chest/full), hairstyle transfer, beard add/remove/trim, relight (direction/mood), expand (generative outpaint/uncrop), sky/background replacement. - All generative + consent-gated; identityRisk "none" on purpose — these intentionally change appearance, so the preservation identity gate must not false-positive and block them. - studio_muscle/_hairstyle/_beard/_relight/_expand/_sky MCP tools. - ops tests (validation + defaults + meta). Verified end to end against real Gemini via studio-e2e: a relight edit routed to gemini, $0.039, output stored, chain extends to relight[done]. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Confirmed defects from the Phase 2+3 review: - OCC (assets.ts): appendEdit now requires the parent edit to be done WITH output before chaining; a child submitted while the parent is still running passed the head check and silently built on the ORIGINAL bytes. resolveInputKey (engine) fails loud instead of falling back to the original when a parent has no output. - mask tenant isolation (engine.ts): masks are resolved from req.maskKey OR op.params.maskKey (eraser requires the param mask, which was ignored) and validated by parsing the embedded assetId and requiring getAsset(ctx) to succeed — closes a cross-user object read within a shared org. - SidecarProvider: re-encodes the HTTP response through sharp at the trust boundary (rejects malformed/oversized payloads, strips metadata, clamps size) instead of trusting raw base64. - sidecar-launcher: spawn detached (own process group) so teardown group-kills the python grandchild; adopt an instance already on the port instead of EADDRINUSE; process.on(exit) backstop kill; env-tunable boot budget (default 90s) for cold `uv` venv installs. - tests: OCC parent-not-done rejection + done-parent chaining; provider re-encode + malformed-response rejection. Verified e2e (chain intact) and a live sidecar round-trip ($0, re-encoded) + malformed input -> HTTP 400. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Strengthens the spec-driven audit so the new work is guarded, not just covered generically by "studio_edits done is nonzero": - per-op-family effect SQL (notExercised: the eval drives editSemantic/cutout, not these): deviceRender, retouch, and the generative depth ops (muscle/hairstyle/beard/relight/expand/sky). - ensureStudioSidecar added as an entry symbol so the sidecar wiring stays liveness-checked. - new invariants: parent-must-be-done OCC, deviceRender free/WYSIWYG/ungated, client mask must resolve to a same-user asset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ls-lock.json) Keeps tool-installed skills (e.g. npx skills add Banuba/ai-skills) out of the repo so a git add -A never sweeps them in. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The iOS app talks to the Connect server (HTTP/1.1, port 8767), but connect-server.ts only wired 27 of the MobileApi RPCs — the five Studio RPCs (and three Loop RPCs) were registered ONLY on the raw gRPC server (8766, used by Mac/CLI). So every iOS Studio call (studioCreateAsset, studioEdit, …) hit an unrouted path and got HTTP 404 — "the requested URL was not found on this server" — even though Chat worked. iOS Studio was never reachable. - Add studioCreateAsset / studioGetAssetUrl / studioHistory / studioReportIdentity (unary) and studioEdit (server-streaming) to the Connect router, plus the missing listLoops / setLoopEnabled / deleteLoop. - studioEdit needs streaming: add a serverStream() adapter that funnels the gRPC handler's call.write()/end()/destroy() into an async generator (the same shape the inline `chat` handler uses), reusing the JWT-on-metadata auth like unary(). Deploy this to the hosted daemon to make iOS Studio work. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The local-fs object store presigned an upload as a `file://` URL — useless to the iOS client, which then failed its HTTP PUT (the "open image" error). GCS isn't wired, so local-fs is the only driver, meaning client uploads never worked end-to-end. When NOMOS_OBJECT_STORE_PUBLIC_URL is set, LocalFsObjectStore now presigns signed HTTP PUT/GET URLs (HMAC over method+key+expiry, per-boot secret) and the daemon serves them on its Connect HTTP port via handleBlobRequest — same host:port the client already reached us on, so it's reachable. Unset → still `file://` (server-side eval/engine use). connect-server.ts checks the blob route before the Connect adapter (non-blob requests pass through with their body untouched). Prod uses GCS V4 signed URLs and skips all this. Tested: presign HTTP URL → PUT → GET round-trip, bad-signature 403, non-blob passthrough. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… path) Generative Studio edits are gated behind `studio.cloud_ai_enabled` (per-customer config, default OFF). But setCloudAIEnabled() is called only in tests — no RPC, settings handler, or iOS toggle ever writes it — so the gate was unflippable and every typed/generative edit hit "Cloud AI is turned off". isCloudAIEnabled() now honors a dev override env `NOMOS_STUDIO_CLOUD_AI` (1/true/yes/on) that short-circuits to true before the DB read, so the local hosted stack can use cloud edits. Unset in production → the per-customer flag still governs (unchanged). A real user-facing consent toggle (RPC + iOS UI) is the proper follow-up. Verified: consent unit tests (override on → true, off-value → DB) + a tsx run of the real isCloudAIEnabled() printing true with the env set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GetSettings now returns a `studio_cloud_ai` permission (enabled = isCloudAIEnabled()), and handleUpdatePermission special-cases that id → setCloudAIEnabled(boolean) instead of the generic permission.<id> config key. This gives the iOS app a real read/write path for the cloud-AI consent flag (it had none — setCloudAIEnabled was test-only), reusing the already-wired UpdatePermission RPC so no proto change was needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
studio-e2e.ts exercises the engine but uses store.put() directly, bypassing the presigned-PUT-over-HTTP path the iOS client actually uses. This new script drives the real round-trip against the Connect server's blob-route wrapper: presign PUT -> HTTP PUT -> serve -> presign GET -> HTTP GET, plus a tampered-signature 403. Caught nothing server-side (it passes) — the client-side reachability is the real issue (see iOS fix). Run: pnpm tsx scripts/studio-blob-e2e.ts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…face-aware) Two daemon failures on Studio edits, both fixed: 1) "invalid mask reference" on every region (masked) edit. handleStudioCreateAsset minted the object key with a throwaway randomUUID() while createAsset returned a DIFFERENT DB-generated row id, so the key embedded a uuid that was never an asset id. The engine's resolveMask extracts `/studio/<id>/` from the mask key and requires getAsset(<id>) to resolve — which always failed. The key now embeds the asset's OWN id (createAsset takes an explicit id), restoring the documented invariant. Existing assets still load (the read path uses the stored object_key directly); no migration. 2) "IMAGE_SAFETY" refusal on portrait editSemantic. The generateContent call passed no config, so the safety filters sat at default and blocked legitimate, consented edits of the user's own photo. It now sends safetySettings=BLOCK_NONE on the configurable categories. SURFACE-AWARE: the HARM_CATEGORY_IMAGE_* categories that drive IMAGE_SAFETY exist only on Vertex — the @google/genai types mark them "not supported in Gemini API" and sending them on the API-key surface 400s the whole request — so Gemini API gets the 4 text categories and Vertex adds the 4 image ones. A safety finish reason is now surfaced as a human-readable refusal instead of a bare "IMAGE_SAFETY". Non-configurable guards (minors, CSAM, public figures) are unaffected. Tests: engine mask round-trip (resolves when the key embeds the asset id; throws on a foreign-asset mask); both provider surfaces over a mocked SDK (Vertex = 8 categories incl. IMAGE_*, Gemini API = 4 text-only); refusal humanization. 599 pass, typecheck + lint clean. scripts/studio-safety-probe.ts A/Bs old-vs-new against real creds (the live check I can't run here). The surface split was caught by an adversarial review of the first cut. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…or Home Adds the list endpoint behind the Home "Pick up where you left off" launchpad. Photo editing is task-based (not in the chat transcript), so each asset + its op chain is a resumable session; this returns recent ready (worked-on) sessions, newest first, each with the head edit's preview/op so the client renders a thumbnail + label in one call. - proto: StudioListAssets + MStudioAssetSummary (preview_url, head_op, edit_count, finalized, updated_at). Regenerated TS stubs. - assets.listAssets(ctx, limit): user_id-scoped, status='ready', head-edit left-join + a grouped done-edit count; reads metadata.finalizedAt as the finalized flag. - mobile-api handleStudioListAssets: presigns a thumbnail (head preview → output → original) per session. Registered on both the gRPC and Connect routers. - manifest: listAssets added to the studio feature's entry symbols (liveness). The finalized flag is wired through but always false for now — the finalize action + in-app Finished gallery land with the export screen next. 601 tests pass (+2), typecheck + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…to-apply edits
The editor's chips are now about THE photo, not a static toolbar. New StudioSuggestEdits
RPC: a vision model (Gemini 2.5 Flash) looks at the current head and returns the
highest-impact fixes as {label, prompt} pairs — a short chip + the editSemantic
instruction it applies on tap.
- suggest.ts: suggestEdits(bytes, mime) -> EditSuggestion[]. Text output (no IMAGE_SAFETY
path), JSON-mode + a tolerant parser (parseSuggestions strips fences, validates shape,
clamps). Degrades to [] on any failure so the editor falls back to static chips.
- Reuses the Gemini surface/creds via an extracted createGenAI() (shared with the image
client; no behavior change there).
- handleStudioSuggestEdits: gated on the SAME Cloud-AI consent as editing (it sends the
photo to the cloud); analyzes the current head. Registered on gRPC + Connect; manifest
liveness updated. Regenerated TS stubs.
608 tests pass (+7), typecheck + lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pare #5 Suggestions: the vision prompt now proactively scans for retouch families (skin, eyes, teeth, lips, hair, gentle facial refinement, and figure work only when a body is in frame) and suggests tastefully — gated on real visual evidence, age, and shot type (no wrinkle removal on young skin, no body reshaping on a headshot, identity preserved). Distilled from a multi-agent research pass over what people actually ask consumer editors. #4 Compare: StudioGetAssetUrl gains an `original` flag so the client can presign the immutable original (not the head) for a before/after wipe — needed for resumed sessions that hold the head but not the original. Stubs regenerated. 608 tests pass, typecheck + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds 'soften freckles' to the skin retouch family with a tasteful guardrail — freckles are often a liked feature, so the model only offers it when they're heavy/uneven and reduction would clearly flatter, never by default. (Distinct from age-spot fading, which preserves freckles.)
…ver soften #4 The suggest prompt now mandates that every proposed edit IMPROVE technical quality — increase sharpness, clarity, and fine detail, keep or raise resolution, and never produce a softer/blurrier/lower-detail result. When the photo is soft or low-detail it proposes sharpening; skin smoothing must still retain pores. Adds a 'Sharpen Detail' example.
…-enhance Appends a universal quality guard to every generative prompt in GeminiImageProvider — typed edits, suggestion chips, region edits, auto-enhance, and the agent/MCP path all get it. It tells the model to keep/raise sharpness, detail, and resolution and never soften, blur, smear, or over-denoise (skin smoothing must retain pores), preserving identity. So a user prompt like "warm it up" no longer quietly degrades the photo on re-render.
…ly drives the edit
The old masked path edited the WHOLE image then pasted it back stretched to the original
dims, so the model never saw the marked region ("remove this" missed) and a different-aspect
model output distorted/reframed the result. Now a masked localized op:
- finds the brushed region's bounding box (maskBoundingBox, padded + clamped),
- crops the original to it and edits JUST that crop (model focuses on what's marked),
- composites the edited crop back at the box, feathered by the mask, at the ORIGINAL
dimensions (compositeRegion) — only the marked area changes, no reframing.
Empty mask falls back to a whole-image edit. 610 tests pass (+2: maskBoundingBox +
compositeRegion).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…its + suggestions
Studio now feeds the same extract -> user-model/vault -> inject loop the rest of the app
uses, so the agent learns how this user likes their photos and applies it automatically.
- Capture: each committed editSemantic fires a fire-and-forget signal (recordEditSignal)
from the engine.
- Learn: a background pass every few edits distills the signals (Haiku) into an editable
photo-style.md VAULT NOTE (surfaces in the wiki) + photo_style USER_MODEL entries.
studio/learn.ts; gated by NOMOS_ADAPTIVE_MEMORY; per-user scoped.
- Apply: suggestEdits injects the style ("favor it when it fits this photo"); auto-enhance
carries a `personalize` flag -> the engine fetches the style -> the provider appends it
as a styleHint in the generative prompt. Explicit typed edits are NEVER personalized
(they don't set personalize), so the style can't fight an instruction.
Manifest entry + invariants added; 618 tests pass (+8), typecheck + lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e bug it caught Adds runStudioLearn to the agent eval: it drives the REAL capture -> distill -> store path (4 edits fill recordEditSignal's buffer, flushPhotoStyle distills via Haiku) and asserts the photo-style.md vault note, the photo_style user_model entries, the apply-side readPhotoStyle, and per-user isolation. The two feature-manifest effects are promoted from notExercised to hard SQL checks, so the spec audit now guards that both durable stores actually populate. Wiring this up immediately caught a real bug: the Haiku distiller sometimes emits the JSON object twice (two back-to-back fenced blocks), which defeated parseStyle's fence-strip + JSON.parse, so NOTHING was ever written. parseStyle now scans out the first brace-balanced object (string-aware), recovering fenced, prose-wrapped, and duplicated-block outputs. Verified against a real DB + real distill: photo-style.md = 1 row, photo_style prefs = 5, isolation holds. 3 regression cases added. 621 tests pass; typecheck + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cleanup of unused leftovers from the Claude Code SDK adaptation, all verified to have zero call sites (typecheck + lint + 621 tests still green): - Delete src/sdk/compact-prompt.ts entirely — COMPACT_PROMPT / formatCompactSummary / buildCompactContinuationMessage are exported but never imported anywhere. - Drop the dead "Formatting" cluster from cost-tracker.ts (formatCost, formatDuration, formatSessionSummary, formatModelPricing) + its now-unused formatTokenCount import. The live CostTracker class, getCostTracker(), canonicalizeModel, and the interfaces are untouched (the UI uses its own local formatters). - Update CLAUDE.md's cost-tracker description to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
meidad
added a commit
that referenced
this pull request
Jun 17, 2026
Bring the branch up to date with main (Studio Phase 1a #87 + SDK cleanup) so PR #86 merges cleanly. Both branches extended the MobileApi, so the conflicts were unions plus one semantic merge: - proto/nomos.proto: union of the Tasks/Brain/Inbox/Today RPCs (#86) and the Studio RPCs (#87). - src/daemon/mobile-api.ts: union of imports, the handler-registration map, and the handler bodies. handleGetSettings keeps #86's config-driven implementation and grafts in the Studio `studio_cloud_ai` permission row (handleUpdatePermission already routes it to setCloudAIEnabled). - Regenerated src/gen from the merged proto. typecheck + lint clean; 644 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.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.
First slice of Nomos Studio (hosted-only photo editor). Dependency-free, unit-tested, no cloud calls yet.
Tests: 19 new (ops 9, object-store 10). typecheck clean.
Summary
Changes
Test plan
pnpm checkpasses (format, typecheck, lint)pnpm testpassesRelated issues