Skip to content

feat(studio): Phase 1a foundation - mode gate, op registry, object store#87

Merged
meidad merged 37 commits into
mainfrom
feat/studio-phase-1a
Jun 17, 2026
Merged

feat(studio): Phase 1a foundation - mode gate, op registry, object store#87
meidad merged 37 commits into
mainfrom
feat/studio-phase-1a

Conversation

@meidad

@meidad meidad commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

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.

Summary

Changes

Test plan

  • pnpm check passes (format, typecheck, lint)
  • pnpm test passes
  • Manual testing done

Related issues

meidad and others added 30 commits June 12, 2026 20:33
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>
meidad and others added 6 commits June 16, 2026 16:16
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>
@meidad meidad marked this pull request as ready for review June 17, 2026 04:11
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 meidad merged commit 8d3c24a into main Jun 17, 2026
6 checks passed
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>
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.

1 participant